diff --git a/.changeset/khaki-eagles-shave.md b/.changeset/khaki-eagles-shave.md new file mode 100644 index 00000000..6b85e518 --- /dev/null +++ b/.changeset/khaki-eagles-shave.md @@ -0,0 +1,11 @@ +--- +'@getmunin/backend-core': minor +--- + +Inbound SMS threads into the existing conversation, and STOP unsubscribes + +Webhook-mode channels used to open a brand-new conversation for every inbound message. Two texts from the same person were two unrelated conversations, and no reply could ever be attributed to what it was replying to. Inbound messages now land in that contact's most recent open conversation on the channel, reopening it if it was snoozed; a closed conversation still starts a fresh one. New conversations also inherit the channel's `defaultAgentMode`, which the generic ingest path was dropping while the email path honoured it. + +An inbound SMS whose entire body is an opt-out keyword — `STOP`, `STOPP`, `SLUTT`, `AVMELD`, `UNSUBSCRIBE`, `END`, `QUIT`, `CANCEL`, `STOPALL`, case-insensitive, trailing punctuation ignored — now suppresses the CRM contact holding that number: `doNotContact` set, `unsubscribedAt` stamped, and a `crm_activities` note recording why. The message is still ingested so the conversation reads truthfully, and suppressed contacts drop out of `crm_list_contacts_in_segment`, so they leave every outreach audience. A message that merely contains the word ("can you cancel my order?") is an ordinary message. + +Honouring STOP is a legal requirement for SMS in the US and expected practice in the EU, so this lands before SMS outreach rather than alongside it. diff --git a/.changeset/tall-pianos-repeat.md b/.changeset/tall-pianos-repeat.md new file mode 100644 index 00000000..9ad05088 --- /dev/null +++ b/.changeset/tall-pianos-repeat.md @@ -0,0 +1,15 @@ +--- +'@getmunin/backend-core': minor +'@getmunin/dashboard-pages': minor +'@getmunin/types': minor +--- + +SMS channels can set how the agent handles inbound texts + +`defaultAgentMode` has always been a `conv_channels` column, but only the email path could write it — the vendor-backed path that creates every SMS channel had no way to set it, and neither did the dashboard. Every SMS number was stuck on `auto`, replying to inbound texts automatically. + +It is now settable on SMS channels through `conv_configure_channel`, the vendor tools, the `/v1` SMS endpoints, and a control in the Twilio and MessageBird dialogs alongside the one email already had. Set `draft_only` on a number you only run campaigns from and inbound replies are drafted for approval instead of auto-answered. + +Voice channels reject it with an explanation rather than accepting a value that would do nothing: an inbound call is run by the vendor's assistant, not by the Munin agent, so there is no reply for the mode to govern. + +Also corrects `conv_create_channel`'s description, which claimed "the `voice` and `sms` channel types are reserved and not yet wired to an adapter". Both SMS vendors and both voice vendors have shipped adapters; those channels are created with `conv_configure_channel`, which the description now says. diff --git a/.changeset/thin-plums-clap.md b/.changeset/thin-plums-clap.md new file mode 100644 index 00000000..bf927294 --- /dev/null +++ b/.changeset/thin-plums-clap.md @@ -0,0 +1,11 @@ +--- +'@getmunin/backend-core': patch +'@getmunin/dashboard-pages': patch +'@getmunin/types': patch +--- + +SMS channel dialogs: agent-reply select on create, sender switching actually clears + +The "Agent replies" select is now on the SMS create dialog, not just edit — a new channel no longer silently starts on `auto` until someone goes back and changes it. Renamed from "Default agent mode" / "Inbound replies" to a shared "Agent replies" across email and SMS dialogs, since "agent mode" is the database column name, not something an operator configuring a phone number has a mental model for. + +Twilio's From-number and Messaging-Service-SID fields are now a single "Send from" choice instead of two always-visible inputs with an "either, both is also OK" caveat. Switching the choice on an existing channel now actually clears the field you switched away from — `updateChannel` previously merged with `?? prev`, so the old value survived a switch and both were sent on every message. diff --git a/packages/backend-core/docs-fixtures/mcp-tools.json b/packages/backend-core/docs-fixtures/mcp-tools.json index 31f9c34c..a5b9dc89 100644 --- a/packages/backend-core/docs-fixtures/mcp-tools.json +++ b/packages/backend-core/docs-fixtures/mcp-tools.json @@ -2938,7 +2938,7 @@ { "name": "conv_create_channel", "title": "Conv: Create conversation channel", - "description": "Add a new conversation channel. Currently shipping adapters: `email` and `chat` (widget). Channel-specific configuration goes in `config`. The `voice` and `sms` channel types are reserved and not yet wired to an adapter.", + "description": "Add a new conversation channel. Channel-specific configuration goes in `config`. Use this for `email` and `chat` (widget) channels; `voice` and `sms` channels are vendor-backed and are created with conv_configure_channel instead, which handles their credential handoff.", "audiences": [ "admin" ], @@ -3905,7 +3905,7 @@ { "name": "conv_configure_channel", "title": "Conv: Configure a voice/SMS channel", - "description": "Create or update a voice or SMS channel for any supported vendor. Pass `vendor` + the vendor’s non-secret `config` fields (see conv_list_channel_vendors). Secret fields are rejected here: creating returns a pending channel plus a one-time link for a human to enter the secrets in the dashboard — the channel activates once they are saved and verified. Pass `channelId` to update; omit to create.", + "description": "Create or update a voice or SMS channel for any supported vendor. Pass `vendor` + the vendor’s non-secret `config` fields (see conv_list_channel_vendors). Secret fields are rejected here: creating returns a pending channel plus a one-time link for a human to enter the secrets in the dashboard — the channel activates once they are saved and verified. Pass `channelId` to update; omit to create. `defaultAgentMode` applies to SMS channels only.", "audiences": [ "admin" ], @@ -3934,6 +3934,15 @@ "minLength": 1, "maxLength": 120 }, + "defaultAgentMode": { + "description": "How the agent handles inbound messages on this channel: 'auto' replies directly, 'draft_only' files a draft for a human, 'off' does neither. SMS channels only — an inbound call is run by the vendor's assistant. Set 'draft_only' on an outreach-only number so replies are never auto-sent.", + "type": "string", + "enum": [ + "auto", + "draft_only", + "off" + ] + }, "config": { "type": "object", "propertyNames": { diff --git a/packages/backend-core/docs-fixtures/skills.json b/packages/backend-core/docs-fixtures/skills.json index 2520da43..e8e88438 100644 --- a/packages/backend-core/docs-fixtures/skills.json +++ b/packages/backend-core/docs-fixtures/skills.json @@ -186,7 +186,7 @@ "title": "Set up a voice or SMS channel", "description": "Configure a Vapi/Threll voice channel or Twilio/MessageBird SMS channel with non-secret config, hand off the API keys through a credential link, and verify the result.", "mimeType": "text/markdown", - "content": "# Set up a voice or SMS channel\n\nUse this when a customer wants Munin on a phone number — an AI voice line (Vapi, Threll) or two-way SMS (Twilio, MessageBird).\n\n## TL;DR\n\n1. `conv_list_channel_vendors` — see the supported vendors and each one's config fields; fields marked `secret: true` are never passed by you.\n2. `conv_configure_channel` with `vendor`, a `name`, and the **non-secret** config fields. The channel is created inactive and the response includes a one-time **credential link**.\n3. Share the credential link — the human enters the vendor API keys in the dashboard. Saving completes the vendor-side setup (webhook registration where applicable), verifies the credentials, and activates the channel. The link works once and expires after 24 hours; mint a fresh one with `conv_request_channel_credentials`.\n4. `conv_test_channel` re-verifies stored credentials any time; `conv_send_channel_test` (SMS) sends a real message.\n\n**Never ask for an API key, auth token, or signing key in the conversation** — the tool rejects secret fields.\n\n## Per-vendor non-secret config\n\n- **Vapi (voice)** — `assistantId` (required; the human finds it in the Vapi dashboard), `phoneNumberId` (only for PSTN calls), `publicKey` (only for in-browser voice via the widget), `replaceWebhook: true` if the assistant already has a non-Munin server URL. The link asks for the API key and a webhook secret of your choosing; on save Munin configures the assistant's server URL automatically.\n- **Threll (voice)** — `workerId` (required; from the Threll webapp), `accountId` (optional, resolved from the API key). The link asks for the API key; on save Munin creates the webhook subscription and stores the signing secret Threll returns.\n- **Twilio (SMS)** — `accountSid` (required) plus `fromNumber` or `messagingServiceSid`. The link asks for the auth token.\n- **MessageBird (SMS)** — `originator` (required). The link asks for the access key and signing key.\n\n## While the channel is pending\n\nA channel waiting on its credential link is `active: false` and every admin action on it (`conv_test_channel`, `conv_send_channel_test`, `conv_list_channel_options`, updates) answers `conv_invalid: channel is awaiting credentials`. If the link expired, mint a new one with `conv_request_channel_credentials { channelId }`.\n\n## Picking assistant/worker ids\n\n`conv_list_channel_options` lists a vendor's selectable options (Vapi assistants, Threll workers) **using a channel's stored credentials** — so it works only after the credential link is completed. For the initial create, the human reads the id from the vendor dashboard; to switch later, complete setup first, then call `conv_list_channel_options { channelId }` and update with `conv_configure_channel`.\n\n## Verify\n\n- `conv_test_channel { channelId }` — vendor-shaped credential check (Twilio account fetch, MessageBird balance, etc.), no message sent.\n- SMS: `conv_send_channel_test { channelId, to }` sends a real message end-to-end.\n- Voice: there is no tool that places a call. A human verifies the channel end-to-end from the dashboard — Channels → the channel's ⋯ menu → **Make a test call**.\n" + "content": "# Set up a voice or SMS channel\n\nUse this when a customer wants Munin on a phone number — an AI voice line (Vapi, Threll) or two-way SMS (Twilio, MessageBird).\n\n## TL;DR\n\n1. `conv_list_channel_vendors` — see the supported vendors and each one's config fields; fields marked `secret: true` are never passed by you.\n2. `conv_configure_channel` with `vendor`, a `name`, and the **non-secret** config fields. The channel is created inactive and the response includes a one-time **credential link**.\n3. Share the credential link — the human enters the vendor API keys in the dashboard. Saving completes the vendor-side setup (webhook registration where applicable), verifies the credentials, and activates the channel. The link works once and expires after 24 hours; mint a fresh one with `conv_request_channel_credentials`.\n4. `conv_test_channel` re-verifies stored credentials any time; `conv_send_channel_test` (SMS) sends a real message.\n\n**Never ask for an API key, auth token, or signing key in the conversation** — the tool rejects secret fields.\n\n## Per-vendor non-secret config\n\n- **Vapi (voice)** — `assistantId` (required; the human finds it in the Vapi dashboard), `phoneNumberId` (only for PSTN calls), `publicKey` (only for in-browser voice via the widget), `replaceWebhook: true` if the assistant already has a non-Munin server URL. The link asks for the API key and a webhook secret of your choosing; on save Munin configures the assistant's server URL automatically.\n- **Threll (voice)** — `workerId` (required; from the Threll webapp), `accountId` (optional, resolved from the API key). The link asks for the API key; on save Munin creates the webhook subscription and stores the signing secret Threll returns.\n- **Twilio (SMS)** — `accountSid` (required) plus `fromNumber` or `messagingServiceSid`. The link asks for the auth token.\n- **MessageBird (SMS)** — `originator` (required). The link asks for the access key and signing key.\n\n## While the channel is pending\n\nA channel waiting on its credential link is `active: false` and every admin action on it (`conv_test_channel`, `conv_send_channel_test`, `conv_list_channel_options`, updates) answers `conv_invalid: channel is awaiting credentials`. If the link expired, mint a new one with `conv_request_channel_credentials { channelId }`.\n\n## Picking assistant/worker ids\n\n`conv_list_channel_options` lists a vendor's selectable options (Vapi assistants, Threll workers) **using a channel's stored credentials** — so it works only after the credential link is completed. For the initial create, the human reads the id from the vendor dashboard; to switch later, complete setup first, then call `conv_list_channel_options { channelId }` and update with `conv_configure_channel`.\n\n## Inbound behaviour\n\nReplies thread. A second message from the same number on the same channel lands in that number's most recent open conversation rather than opening a new one; a snoozed conversation is reopened. A new conversation is started only when the previous one was closed.\n\n**Set `defaultAgentMode` per SMS channel.** `conv_configure_channel { channelId, vendor, defaultAgentMode }` decides what the agent does with texts arriving on that number: `auto` replies directly, `draft_only` files a draft for a human to approve, `off` does neither. Use `draft_only` on a number you only send campaigns from, so a reply is never auto-answered. It applies to SMS only — an inbound call is run by the vendor's assistant, not the Munin agent, so passing it on a voice channel is rejected.\n\n**SMS opt-out is automatic and irreversible from your side.** An inbound SMS whose entire body is an opt-out keyword — `STOP`, `STOPP`, `SLUTT`, `AVMELD`, `UNSUBSCRIBE`, `END`, `QUIT`, `CANCEL`, `STOPALL`, case-insensitive, trailing punctuation ignored — suppresses the CRM contact holding that phone number: `doNotContact` is set, `unsubscribedAt` is stamped, and a `crm_activities` note records it. The message is still ingested so the conversation reads truthfully. A sentence that merely contains one of those words (\"can you cancel my order?\") is an ordinary message and suppresses nothing.\n\nSuppressed contacts drop out of `crm_list_contacts_in_segment`, so they stop appearing in outreach audiences. Do not clear the flag to re-add someone — if they ask to opt back in, that is a new consent decision a human records.\n\n## Verify\n\n- `conv_test_channel { channelId }` — vendor-shaped credential check (Twilio account fetch, MessageBird balance, etc.), no message sent.\n- SMS: `conv_send_channel_test { channelId, to }` sends a real message end-to-end.\n- Voice: there is no tool that places a call. A human verifies the channel end-to-end from the dashboard — Channels → the channel's ⋯ menu → **Make a test call**.\n" }, { "uri": "skill://conv/strip-email-signature", diff --git a/packages/backend-core/src/modules/conv/channels/channel-admin.service.ts b/packages/backend-core/src/modules/conv/channels/channel-admin.service.ts index d740b7fe..e764b0c8 100644 --- a/packages/backend-core/src/modules/conv/channels/channel-admin.service.ts +++ b/packages/backend-core/src/modules/conv/channels/channel-admin.service.ts @@ -8,6 +8,7 @@ import { import { and, eq } from 'drizzle-orm'; import { getCurrentContext } from '@getmunin/core'; import { schema } from '@getmunin/db'; +import type { AgentMode } from '@getmunin/types'; import { CHANNEL_ADMIN_PROVIDERS, PENDING_SETUP_KEY, @@ -47,24 +48,48 @@ export class ChannelAdminService { channelId?: string; name?: string; config: Record; + defaultAgentMode?: AgentMode; }, opts?: { rejectSecrets?: boolean }, ): Promise { const provider = this.requireVendor(input.vendor); if (opts?.rejectSecrets) this.assertNoSecrets(provider, input.config); + if (input.defaultAgentMode && provider.kind !== 'sms') { + throw new BadRequestException( + `defaultAgentMode does not apply to ${provider.kind} channels — an inbound call is run by the vendor's assistant, not by the Munin agent`, + ); + } if (input.channelId) await this.assertNotPending(input.channelId); if (opts?.rejectSecrets && !input.channelId) { - return this.createPending(provider, { name: input.name, config: input.config }); + const pending = await this.createPending(provider, { + name: input.name, + config: input.config, + }); + return this.applyDefaultAgentMode(pending, input.defaultAgentMode); } const parsed = provider.configInput.safeParse(input.config); if (!parsed.success) { throw new BadRequestException(`invalid config for ${input.vendor}: ${parsed.error.message}`); } - return provider.configure({ + const configured = await provider.configure({ channelId: input.channelId, name: input.name, config: parsed.data, }); + return this.applyDefaultAgentMode(configured, input.defaultAgentMode); + } + + private async applyDefaultAgentMode( + channel: ChannelAdminDto, + defaultAgentMode: AgentMode | undefined, + ): Promise { + if (!defaultAgentMode) return channel; + const ctx = getCurrentContext(); + await ctx.db + .update(schema.convChannels) + .set({ defaultAgentMode, updatedAt: new Date() }) + .where(eq(schema.convChannels.id, channel.id)); + return { ...channel, defaultAgentMode }; } async completeSetup( diff --git a/packages/backend-core/src/modules/conv/channels/channel-admin.tools.ts b/packages/backend-core/src/modules/conv/channels/channel-admin.tools.ts index 981c023c..8c8382ad 100644 --- a/packages/backend-core/src/modules/conv/channels/channel-admin.tools.ts +++ b/packages/backend-core/src/modules/conv/channels/channel-admin.tools.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { z } from 'zod'; import { McpTool } from '@getmunin/mcp-toolkit'; -import { sensitive } from '@getmunin/types'; +import { AgentModeSchema, sensitive } from '@getmunin/types'; import { ChannelAdminService } from './channel-admin.service.ts'; import { ChannelCredentialService } from './channel-credential.service.ts'; import type { ChannelAdminDto } from './channel-admin.ts'; @@ -18,6 +18,9 @@ const ConfigureInput = z.object({ .optional() .describe('Pass an existing channel id to update; omit to create a new channel.'), name: z.string().min(1).max(120).optional().describe('Channel display name. Required on create.'), + defaultAgentMode: AgentModeSchema.optional().describe( + "How the agent handles inbound messages on this channel: 'auto' replies directly, 'draft_only' files a draft for a human, 'off' does neither. SMS channels only — an inbound call is run by the vendor's assistant. Set 'draft_only' on an outreach-only number so replies are never auto-sent.", + ), config: sensitive( z .record(z.string(), z.unknown()) @@ -84,7 +87,7 @@ export class ChannelAdminTools { name: 'conv_configure_channel', title: 'Conv: Configure a voice/SMS channel', description: - 'Create or update a voice or SMS channel for any supported vendor. Pass `vendor` + the vendor’s non-secret `config` fields (see conv_list_channel_vendors). Secret fields are rejected here: creating returns a pending channel plus a one-time link for a human to enter the secrets in the dashboard — the channel activates once they are saved and verified. Pass `channelId` to update; omit to create.', + 'Create or update a voice or SMS channel for any supported vendor. Pass `vendor` + the vendor’s non-secret `config` fields (see conv_list_channel_vendors). Secret fields are rejected here: creating returns a pending channel plus a one-time link for a human to enter the secrets in the dashboard — the channel activates once they are saved and verified. Pass `channelId` to update; omit to create. `defaultAgentMode` applies to SMS channels only.', audiences: ['admin'], scopes: ['conv:write'], input: ConfigureInput, @@ -100,6 +103,7 @@ export class ChannelAdminTools { channelId: args.channelId, name: args.name, config: args.config, + defaultAgentMode: args.defaultAgentMode, }, { rejectSecrets: true }, ); diff --git a/packages/backend-core/src/modules/conv/channels/channel-admin.ts b/packages/backend-core/src/modules/conv/channels/channel-admin.ts index 635c371c..9d3bbb85 100644 --- a/packages/backend-core/src/modules/conv/channels/channel-admin.ts +++ b/packages/backend-core/src/modules/conv/channels/channel-admin.ts @@ -17,6 +17,7 @@ export interface ChannelAdminDto { vendor: string; active: boolean; config: unknown; + defaultAgentMode?: string; } export interface ConfigureChannelInput { diff --git a/packages/backend-core/src/modules/conv/channels/channel-ingest.service.ts b/packages/backend-core/src/modules/conv/channels/channel-ingest.service.ts index be8a44e2..5e10a6c1 100644 --- a/packages/backend-core/src/modules/conv/channels/channel-ingest.service.ts +++ b/packages/backend-core/src/modules/conv/channels/channel-ingest.service.ts @@ -1,6 +1,6 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; import { schema, type Db, type Tx } from '@getmunin/db'; -import { and, eq, sql } from 'drizzle-orm'; +import { and, desc, eq, inArray, sql } from 'drizzle-orm'; import { randomUUID } from 'node:crypto'; import { ActorIdentity, @@ -11,6 +11,7 @@ import { import { DB } from '../../../common/db/db.module.ts'; import { CuratorJobsService } from '../../curator/curator-jobs.service.ts'; import { buildSetTopicAndTitleJob } from '../set-topic-job.ts'; +import { reopenClosedConversation } from '../conversation-reopen.ts'; import type { ChannelRow, InboundBatch } from './adapter.ts'; @Injectable() @@ -66,23 +67,9 @@ export class ChannelIngestService { const contact = await findOrCreateContact(tx, orgId, msg.fromIdentity); - const next = await tx.execute<{ next: number } & Record>( - sql`SELECT conv_next_display_id(${orgId}) AS next`, - ); - const displayId = next[0]!.next; - const [conversation] = await tx - .insert(schema.convConversations) - .values({ - orgId, - displayId, - channelId: channel.id, - contactId: contact.id, - endUserId: contact.endUserId, - status: 'open', - subject: null, - lastMessageAt: msg.receivedAt, - }) - .returning(); + const conversation = + (await findThreadableConversation(tx, orgId, channel.id, contact.id)) ?? + (await createConversation(tx, orgId, channel, contact, msg.receivedAt)); const metadata: Record = { providerMessageId: msg.providerMessageId, @@ -94,7 +81,7 @@ export class ChannelIngestService { .insert(schema.convMessages) .values({ orgId, - conversationId: conversation!.id, + conversationId: conversation.id, authorType: 'end_user', authorId: contact.id, body: msg.body, @@ -107,19 +94,23 @@ export class ChannelIngestService { await tx .update(schema.convConversations) .set({ lastMessageAt: msg.receivedAt, updatedAt: new Date() }) - .where(eq(schema.convConversations.id, conversation!.id)); + .where(eq(schema.convConversations.id, conversation.id)); await this.webhooks.emit({ type: 'conversation.message.received', payload: { - conversationId: conversation!.id, + conversationId: conversation.id, messageId: stored!.id, authorType: 'end_user', internal: false, }, }); + if (channel.type === 'sms' && isOptOutKeyword(msg.body)) { + await suppressContactByPhone(tx, orgId, contact.phone, channel.id); + } + await this.curatorJobs.enqueue( - buildSetTopicAndTitleJob({ conversationId: conversation!.id, channelType: channel.type }), + buildSetTopicAndTitleJob({ conversationId: conversation.id, channelType: channel.type }), ); return true; }); @@ -127,6 +118,112 @@ export class ChannelIngestService { } } +const OPT_OUT_KEYWORDS = new Set([ + 'stop', + 'stopp', + 'slutt', + 'stopall', + 'unsubscribe', + 'end', + 'quit', + 'cancel', + 'avmeld', +]); + +export function isOptOutKeyword(body: string): boolean { + const normalised = body + .trim() + .toLowerCase() + .replace(/[.!?,;:]+$/g, ''); + return OPT_OUT_KEYWORDS.has(normalised); +} + +async function suppressContactByPhone( + tx: Db | Tx, + orgId: string, + phone: string | null, + channelId: string, +): Promise { + if (!phone) return; + const rows = await tx + .select({ id: schema.crmContacts.id, unsubscribedAt: schema.crmContacts.unsubscribedAt }) + .from(schema.crmContacts) + .where(and(eq(schema.crmContacts.orgId, orgId), eq(schema.crmContacts.phone, phone))) + .limit(1); + const contact = rows[0]; + if (!contact || contact.unsubscribedAt) return; + const now = new Date(); + await tx + .update(schema.crmContacts) + .set({ unsubscribedAt: now, doNotContact: true, updatedAt: now }) + .where(eq(schema.crmContacts.id, contact.id)); + await tx.insert(schema.crmActivities).values({ + orgId, + type: 'note', + subject: 'Unsubscribed', + body: 'Replied with an opt-out keyword over SMS', + contactId: contact.id, + actorType: 'system', + actorId: 'sms-opt-out', + metadata: { optOut: { channelId, via: 'sms_keyword' } }, + }); +} + +async function findThreadableConversation( + tx: Db | Tx, + orgId: string, + channelId: string, + contactId: string, +): Promise { + const rows = await tx + .select() + .from(schema.convConversations) + .where( + and( + eq(schema.convConversations.orgId, orgId), + eq(schema.convConversations.channelId, channelId), + eq(schema.convConversations.contactId, contactId), + inArray(schema.convConversations.status, ['open', 'snoozed']), + ), + ) + .orderBy(desc(schema.convConversations.lastMessageAt)) + .limit(1); + const conversation = rows[0]; + if (!conversation) return null; + if (conversation.status === 'snoozed') { + await reopenClosedConversation(tx, conversation.id); + return { ...conversation, status: 'open' }; + } + return conversation; +} + +async function createConversation( + tx: Db | Tx, + orgId: string, + channel: ChannelRow, + contact: typeof schema.convContacts.$inferSelect, + receivedAt: Date, +): Promise { + const next = await tx.execute<{ next: number } & Record>( + sql`SELECT conv_next_display_id(${orgId}) AS next`, + ); + const [conversation] = await tx + .insert(schema.convConversations) + .values({ + orgId, + displayId: next[0]!.next, + channelId: channel.id, + contactId: contact.id, + endUserId: contact.endUserId, + status: 'open', + subject: null, + agentMode: channel.defaultAgentMode, + lastMessageAt: receivedAt, + }) + .returning(); + return conversation!; +} + async function findOrCreateContact( tx: Db | Tx, orgId: string, diff --git a/packages/backend-core/src/modules/conv/channels/channel-ingest.test.ts b/packages/backend-core/src/modules/conv/channels/channel-ingest.test.ts new file mode 100644 index 00000000..5bf9129d --- /dev/null +++ b/packages/backend-core/src/modules/conv/channels/channel-ingest.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { isOptOutKeyword } from './channel-ingest.service.ts'; + +describe('isOptOutKeyword', () => { + it('matches the English carrier keywords regardless of case or trailing punctuation', () => { + for (const body of ['STOP', 'stop', ' Stop ', 'STOP.', 'unsubscribe', 'QUIT', 'end', 'cancel']) { + expect(isOptOutKeyword(body), body).toBe(true); + } + }); + + it('matches the Norwegian keywords a Nordic recipient would actually send', () => { + for (const body of ['STOPP', 'stopp', 'SLUTT', 'slutt', 'avmeld']) { + expect(isOptOutKeyword(body), body).toBe(true); + } + }); + + it('does not match a sentence that merely contains an opt-out word', () => { + for (const body of [ + 'can you stop the renewal on my account?', + 'please cancel my order', + 'stop by the shop tomorrow', + 'I want to unsubscribe from the newsletter but keep my account', + ]) { + expect(isOptOutKeyword(body), body).toBe(false); + } + }); + + it('does not match an empty or whitespace-only body', () => { + expect(isOptOutKeyword('')).toBe(false); + expect(isOptOutKeyword(' ')).toBe(false); + }); +}); diff --git a/packages/backend-core/src/modules/conv/conv.tools.ts b/packages/backend-core/src/modules/conv/conv.tools.ts index d9d71670..5b898787 100644 --- a/packages/backend-core/src/modules/conv/conv.tools.ts +++ b/packages/backend-core/src/modules/conv/conv.tools.ts @@ -247,7 +247,7 @@ export class ConvAdminTools { name: 'conv_create_channel', title: 'Conv: Create conversation channel', description: - 'Add a new conversation channel. Currently shipping adapters: `email` and `chat` (widget). Channel-specific configuration goes in `config`. The `voice` and `sms` channel types are reserved and not yet wired to an adapter.', + 'Add a new conversation channel. Channel-specific configuration goes in `config`. Use this for `email` and `chat` (widget) channels; `voice` and `sms` channels are vendor-backed and are created with conv_configure_channel instead, which handles their credential handoff.', audiences: ['admin'], scopes: ['conv:write'], input: CreateChannelInput, diff --git a/packages/backend-core/src/modules/conv/messagebird/messagebird-sms-admin.service.ts b/packages/backend-core/src/modules/conv/messagebird/messagebird-sms-admin.service.ts index 1d92ec9a..1298fcbf 100644 --- a/packages/backend-core/src/modules/conv/messagebird/messagebird-sms-admin.service.ts +++ b/packages/backend-core/src/modules/conv/messagebird/messagebird-sms-admin.service.ts @@ -1,6 +1,6 @@ import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { z } from 'zod'; -import { sensitive } from '@getmunin/types'; +import { sensitive, AgentModeSchema } from '@getmunin/types'; import { schema } from '@getmunin/db'; import { and, eq } from 'drizzle-orm'; import { getCurrentContext } from '@getmunin/core'; @@ -17,6 +17,9 @@ export const ConfigureInput = z.object({ .optional() .describe('Pass an existing channel id to update; omit to create a new channel.'), name: z.string().min(1).max(120).optional(), + defaultAgentMode: AgentModeSchema.optional().describe( + "How the agent handles inbound texts on this channel: 'auto' replies directly, 'draft_only' files a draft for a human, 'off' does neither. Set 'draft_only' on an outreach-only number so replies are never auto-sent.", + ), accessKey: sensitive( z .string() @@ -66,6 +69,7 @@ export class MessageBirdSmsAdminService { return this.svc.updateChannel({ channelId: args.channelId, name: args.name, + defaultAgentMode: args.defaultAgentMode, config: { accessKey: args.accessKey, signingKey: args.signingKey, @@ -79,6 +83,7 @@ export class MessageBirdSmsAdminService { if (!args.originator) throw new BadRequestException('originator is required when creating'); return this.svc.createChannel({ name: args.name, + defaultAgentMode: args.defaultAgentMode, config: { accessKey: args.accessKey, signingKey: args.signingKey, diff --git a/packages/backend-core/src/modules/conv/messagebird/messagebird-sms.service.ts b/packages/backend-core/src/modules/conv/messagebird/messagebird-sms.service.ts index f5ad6c3b..82832beb 100644 --- a/packages/backend-core/src/modules/conv/messagebird/messagebird-sms.service.ts +++ b/packages/backend-core/src/modules/conv/messagebird/messagebird-sms.service.ts @@ -10,6 +10,7 @@ import { encryptSecretSql, getCurrentContext, } from '@getmunin/core'; +import type { AgentMode } from '@getmunin/types'; import { schema, type Db } from '@getmunin/db'; import { z } from 'zod'; import { DB } from '../../../common/db/db.module.ts'; @@ -46,6 +47,7 @@ export interface MessageBirdSmsChannelDto { vendor: 'messagebird'; active: boolean; config: MessageBirdSmsConfigDto; + defaultAgentMode: AgentMode; } @Injectable() @@ -55,6 +57,7 @@ export class MessageBirdSmsService { async createChannel(input: { name: string; config: MessageBirdSmsConfigInput; + defaultAgentMode?: AgentMode; }): Promise { const ctx = getCurrentContext(); const actor = ctx.actor!; @@ -67,16 +70,18 @@ export class MessageBirdSmsService { vendor: 'messagebird', name: input.name, config: storedToJsonb(stored), + ...(input.defaultAgentMode ? { defaultAgentMode: input.defaultAgentMode } : {}), }) .returning(); if (!row) throw new ConflictException('channel_create_failed'); - return this.toDto(row.id, row.name, row.active, stored); + return this.toDto(row.id, row.name, row.active, stored, row.defaultAgentMode as AgentMode); } async updateChannel(input: { channelId: string; name?: string; config?: Partial; + defaultAgentMode?: AgentMode; }): Promise { const ctx = getCurrentContext(); const actor = ctx.actor!; @@ -109,13 +114,14 @@ export class MessageBirdSmsService { .update(schema.convChannels) .set({ ...(input.name && { name: input.name }), + ...(input.defaultAgentMode ? { defaultAgentMode: input.defaultAgentMode } : {}), config: storedToJsonb(merged), updatedAt: new Date(), }) .where(eq(schema.convChannels.id, input.channelId)) .returning(); if (!row) throw new ConflictException('channel_update_failed'); - return this.toDto(row.id, row.name, row.active, merged); + return this.toDto(row.id, row.name, row.active, merged, row.defaultAgentMode as AgentMode); } async completeSetup( @@ -159,6 +165,7 @@ export class MessageBirdSmsService { name: string, active: boolean, stored: StoredMessageBirdSmsConfig, + defaultAgentMode: AgentMode, ): MessageBirdSmsChannelDto { return { id, @@ -166,6 +173,7 @@ export class MessageBirdSmsService { type: 'sms', vendor: 'messagebird', active, + defaultAgentMode, config: { accessKey: REDACTED, signingKey: REDACTED, diff --git a/packages/backend-core/src/modules/conv/skills/setup-voice-sms-channel.md b/packages/backend-core/src/modules/conv/skills/setup-voice-sms-channel.md index dd08b873..4625919b 100644 --- a/packages/backend-core/src/modules/conv/skills/setup-voice-sms-channel.md +++ b/packages/backend-core/src/modules/conv/skills/setup-voice-sms-channel.md @@ -32,6 +32,16 @@ A channel waiting on its credential link is `active: false` and every admin acti `conv_list_channel_options` lists a vendor's selectable options (Vapi assistants, Threll workers) **using a channel's stored credentials** — so it works only after the credential link is completed. For the initial create, the human reads the id from the vendor dashboard; to switch later, complete setup first, then call `conv_list_channel_options { channelId }` and update with `conv_configure_channel`. +## Inbound behaviour + +Replies thread. A second message from the same number on the same channel lands in that number's most recent open conversation rather than opening a new one; a snoozed conversation is reopened. A new conversation is started only when the previous one was closed. + +**Set `defaultAgentMode` per SMS channel.** `conv_configure_channel { channelId, vendor, defaultAgentMode }` decides what the agent does with texts arriving on that number: `auto` replies directly, `draft_only` files a draft for a human to approve, `off` does neither. Use `draft_only` on a number you only send campaigns from, so a reply is never auto-answered. It applies to SMS only — an inbound call is run by the vendor's assistant, not the Munin agent, so passing it on a voice channel is rejected. + +**SMS opt-out is automatic and irreversible from your side.** An inbound SMS whose entire body is an opt-out keyword — `STOP`, `STOPP`, `SLUTT`, `AVMELD`, `UNSUBSCRIBE`, `END`, `QUIT`, `CANCEL`, `STOPALL`, case-insensitive, trailing punctuation ignored — suppresses the CRM contact holding that phone number: `doNotContact` is set, `unsubscribedAt` is stamped, and a `crm_activities` note records it. The message is still ingested so the conversation reads truthfully. A sentence that merely contains one of those words ("can you cancel my order?") is an ordinary message and suppresses nothing. + +Suppressed contacts drop out of `crm_list_contacts_in_segment`, so they stop appearing in outreach audiences. Do not clear the flag to re-add someone — if they ask to opt back in, that is a new consent decision a human records. + ## Verify - `conv_test_channel { channelId }` — vendor-shaped credential check (Twilio account fetch, MessageBird balance, etc.), no message sent. diff --git a/packages/backend-core/src/modules/conv/twilio/twilio-sms-admin.service.ts b/packages/backend-core/src/modules/conv/twilio/twilio-sms-admin.service.ts index 2a844aea..97306916 100644 --- a/packages/backend-core/src/modules/conv/twilio/twilio-sms-admin.service.ts +++ b/packages/backend-core/src/modules/conv/twilio/twilio-sms-admin.service.ts @@ -1,6 +1,6 @@ import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { z } from 'zod'; -import { sensitive } from '@getmunin/types'; +import { sensitive, AgentModeSchema } from '@getmunin/types'; import { schema, type Db } from '@getmunin/db'; import { and, eq } from 'drizzle-orm'; import { getCurrentContext } from '@getmunin/core'; @@ -18,6 +18,9 @@ export const ConfigureInput = z.object({ .optional() .describe('Pass an existing channel id to update; omit to create a new channel.'), name: z.string().min(1).max(120).optional(), + defaultAgentMode: AgentModeSchema.optional().describe( + "How the agent handles inbound texts on this channel: 'auto' replies directly, 'draft_only' files a draft for a human, 'off' does neither. Set 'draft_only' on an outreach-only number so replies are never auto-sent.", + ), accountSid: z .string() .min(2) @@ -38,14 +41,20 @@ export const ConfigureInput = z.object({ .string() .min(2) .max(32) + .nullable() .optional() - .describe('E.164-formatted Twilio number to send from. Either this or messagingServiceSid is required.'), + .describe( + 'E.164-formatted Twilio number to send from. Either this or messagingServiceSid is required. Pass null when updating to clear it, e.g. when switching this channel to a Messaging Service.', + ), messagingServiceSid: z .string() .min(2) .max(64) + .nullable() .optional() - .describe('Twilio Messaging Service SID (starts with "MG"). Alternative to fromNumber.'), + .describe( + 'Twilio Messaging Service SID (starts with "MG"). Alternative to fromNumber, and required for US A2P 10DLC traffic. Pass null when updating to clear it.', + ), }); @Injectable() @@ -68,6 +77,7 @@ export class TwilioSmsAdminService { return this.svc.updateChannel({ channelId: args.channelId, name: args.name, + defaultAgentMode: args.defaultAgentMode, config: { accountSid: args.accountSid, authToken: args.authToken, @@ -84,11 +94,12 @@ export class TwilioSmsAdminService { } return this.svc.createChannel({ name: args.name, + defaultAgentMode: args.defaultAgentMode, config: { accountSid: args.accountSid, authToken: args.authToken, - fromNumber: args.fromNumber, - messagingServiceSid: args.messagingServiceSid, + fromNumber: args.fromNumber ?? undefined, + messagingServiceSid: args.messagingServiceSid ?? undefined, }, }); } diff --git a/packages/backend-core/src/modules/conv/twilio/twilio-sms.integration.test.ts b/packages/backend-core/src/modules/conv/twilio/twilio-sms.integration.test.ts index 97447e59..f4037e26 100644 --- a/packages/backend-core/src/modules/conv/twilio/twilio-sms.integration.test.ts +++ b/packages/backend-core/src/modules/conv/twilio/twilio-sms.integration.test.ts @@ -205,6 +205,139 @@ const skipReason = TEST_URL expect(rows.length).toBe(1); }); + it('threads a second SMS from the same number into the same conversation', async () => { + const url = `https://munin.example/v1/conversations/channels/${channelId}/webhook`; + const from = '+14155553333'; + const base = { AccountSid: ACCOUNT_SID, From: from, To: FROM_NUMBER, NumMedia: '0' }; + await postWebhook({ ...base, MessageSid: 'SM_thread_1', Body: 'first question' }, url); + await postWebhook({ ...base, MessageSid: 'SM_thread_2', Body: 'and another thing' }, url); + + const rows = await db + .select({ conversationId: schema.convMessages.conversationId }) + .from(schema.convMessages) + .where( + and( + eq(schema.convMessages.orgId, orgId), + sql`${schema.convMessages.metadata}->>'providerMessageId' IN ('SM_thread_1', 'SM_thread_2')`, + ), + ); + expect(rows.length).toBe(2); + expect(new Set(rows.map((r) => r.conversationId)).size).toBe(1); + }); + + it('an SMS reading STOP suppresses the matching CRM contact', async () => { + const url = `https://munin.example/v1/conversations/channels/${channelId}/webhook`; + const from = '+14155554444'; + const [crmContact] = await db + .insert(schema.crmContacts) + .values({ + orgId, + name: 'Opt Out', + phone: from, + consentLawfulBasis: 'consent', + consentGivenAt: new Date(), + consentSource: 'imported-test', + }) + .returning(); + + await postWebhook( + { + AccountSid: ACCOUNT_SID, + MessageSid: 'SM_optout_1', + From: from, + To: FROM_NUMBER, + Body: 'STOP', + NumMedia: '0', + }, + url, + ); + + const [after] = await db + .select({ + doNotContact: schema.crmContacts.doNotContact, + unsubscribedAt: schema.crmContacts.unsubscribedAt, + }) + .from(schema.crmContacts) + .where(eq(schema.crmContacts.id, crmContact!.id)); + expect(after!.doNotContact).toBe(true); + expect(after!.unsubscribedAt).not.toBeNull(); + + const activities = await db + .select({ subject: schema.crmActivities.subject }) + .from(schema.crmActivities) + .where(eq(schema.crmActivities.contactId, crmContact!.id)); + expect(activities.map((a) => a.subject)).toContain('Unsubscribed'); + }); + + it('an ordinary SMS that merely mentions stopping does not suppress the contact', async () => { + const url = `https://munin.example/v1/conversations/channels/${channelId}/webhook`; + const from = '+14155555555'; + const [crmContact] = await db + .insert(schema.crmContacts) + .values({ + orgId, + name: 'Still Subscribed', + phone: from, + consentLawfulBasis: 'consent', + consentGivenAt: new Date(), + consentSource: 'imported-test', + }) + .returning(); + + await postWebhook( + { + AccountSid: ACCOUNT_SID, + MessageSid: 'SM_not_optout_1', + From: from, + To: FROM_NUMBER, + Body: 'can you stop the subscription renewal on my account?', + NumMedia: '0', + }, + url, + ); + + const [after] = await db + .select({ doNotContact: schema.crmContacts.doNotContact }) + .from(schema.crmContacts) + .where(eq(schema.crmContacts.id, crmContact!.id)); + expect(after!.doNotContact).toBe(false); + }); + + it('a draft_only channel opens inbound conversations in draft_only', async () => { + await db + .update(schema.convChannels) + .set({ defaultAgentMode: 'draft_only' }) + .where(eq(schema.convChannels.id, channelId)); + + const url = `https://munin.example/v1/conversations/channels/${channelId}/webhook`; + await postWebhook( + { + AccountSid: ACCOUNT_SID, + MessageSid: 'SM_draftonly_1', + From: '+14155556666', + To: FROM_NUMBER, + Body: 'is this thing on?', + NumMedia: '0', + }, + url, + ); + + const rows = await db + .select({ agentMode: schema.convConversations.agentMode }) + .from(schema.convConversations) + .innerJoin( + schema.convMessages, + eq(schema.convMessages.conversationId, schema.convConversations.id), + ) + .where(sql`${schema.convMessages.metadata}->>'providerMessageId' = 'SM_draftonly_1'`); + expect(rows[0]!.agentMode).toBe('draft_only'); + + await db + .update(schema.convChannels) + .set({ defaultAgentMode: 'auto' }) + .where(eq(schema.convChannels.id, channelId)); + }); + it('updates conv_message_deliveries on a status callback', async () => { const conversationRows = await db .select({ id: schema.convConversations.id }) diff --git a/packages/backend-core/src/modules/conv/twilio/twilio-sms.service.ts b/packages/backend-core/src/modules/conv/twilio/twilio-sms.service.ts index 659e3061..2f301786 100644 --- a/packages/backend-core/src/modules/conv/twilio/twilio-sms.service.ts +++ b/packages/backend-core/src/modules/conv/twilio/twilio-sms.service.ts @@ -10,6 +10,7 @@ import { encryptSecretSql, getCurrentContext, } from '@getmunin/core'; +import type { AgentMode } from '@getmunin/types'; import { schema, type Db } from '@getmunin/db'; import { z } from 'zod'; import { DB } from '../../../common/db/db.module.ts'; @@ -53,13 +54,18 @@ export interface TwilioSmsChannelDto { vendor: 'twilio'; active: boolean; config: TwilioSmsConfigDto; + defaultAgentMode: AgentMode; } @Injectable() export class TwilioSmsService { constructor(@Inject(DB) private readonly _db: Db) {} - async createChannel(input: { name: string; config: TwilioSmsConfigInput }): Promise { + async createChannel(input: { + name: string; + config: TwilioSmsConfigInput; + defaultAgentMode?: AgentMode; + }): Promise { const ctx = getCurrentContext(); const actor = ctx.actor!; const stored = await this.toStored(input.config); @@ -71,16 +77,21 @@ export class TwilioSmsService { vendor: 'twilio', name: input.name, config: storedToJsonb(stored), + ...(input.defaultAgentMode ? { defaultAgentMode: input.defaultAgentMode } : {}), }) .returning(); if (!row) throw new ConflictException('channel_create_failed'); - return this.toDto(row.id, row.name, row.active, stored); + return this.toDto(row.id, row.name, row.active, stored, row.defaultAgentMode as AgentMode); } async updateChannel(input: { channelId: string; name?: string; - config?: Partial; + config?: Partial> & { + fromNumber?: string | null; + messagingServiceSid?: string | null; + }; + defaultAgentMode?: AgentMode; }): Promise { const ctx = getCurrentContext(); const actor = ctx.actor!; @@ -105,8 +116,11 @@ export class TwilioSmsService { encryptedAuthToken: input.config?.authToken ? await encryptString(input.config.authToken) : prev.encryptedAuthToken, - fromNumber: input.config?.fromNumber ?? prev.fromNumber, - messagingServiceSid: input.config?.messagingServiceSid ?? prev.messagingServiceSid, + fromNumber: resolveSender(input.config?.fromNumber, prev.fromNumber), + messagingServiceSid: resolveSender( + input.config?.messagingServiceSid, + prev.messagingServiceSid, + ), }; if (!merged.fromNumber && !merged.messagingServiceSid) { throw new BadRequestException('either fromNumber or messagingServiceSid is required'); @@ -115,13 +129,14 @@ export class TwilioSmsService { .update(schema.convChannels) .set({ ...(input.name && { name: input.name }), + ...(input.defaultAgentMode ? { defaultAgentMode: input.defaultAgentMode } : {}), config: storedToJsonb(merged), updatedAt: new Date(), }) .where(eq(schema.convChannels.id, input.channelId)) .returning(); if (!row) throw new ConflictException('channel_update_failed'); - return this.toDto(row.id, row.name, row.active, merged); + return this.toDto(row.id, row.name, row.active, merged, row.defaultAgentMode as AgentMode); } async rotateAuthToken(input: { channelId: string; authToken: string }): Promise { @@ -165,13 +180,20 @@ export class TwilioSmsService { }; } - private toDto(id: string, name: string, active: boolean, stored: StoredTwilioSmsConfig): TwilioSmsChannelDto { + private toDto( + id: string, + name: string, + active: boolean, + stored: StoredTwilioSmsConfig, + defaultAgentMode: AgentMode, + ): TwilioSmsChannelDto { return { id, name, type: 'sms', vendor: 'twilio', active, + defaultAgentMode, config: { accountSid: stored.accountSid, authToken: REDACTED, @@ -182,6 +204,14 @@ export class TwilioSmsService { } } +function resolveSender( + incoming: string | null | undefined, + previous: string | undefined, +): string | undefined { + if (incoming === null) return undefined; + return incoming ?? previous; +} + function nonSecretParts(stored: StoredTwilioSmsConfig): Record { return { accountSid: stored.accountSid, fromNumber: stored.fromNumber, messagingServiceSid: stored.messagingServiceSid }; } diff --git a/packages/dashboard-pages/src/messages/en.json b/packages/dashboard-pages/src/messages/en.json index 3a5a2508..57de75d7 100644 --- a/packages/dashboard-pages/src/messages/en.json +++ b/packages/dashboard-pages/src/messages/en.json @@ -438,6 +438,14 @@ } }, "channels": { + "agentReplies": { + "label": "Agent replies", + "hintEmail": "What the agent does when someone writes to this inbox. Outreach campaigns always draft their replies for approval, whatever you pick here.", + "hintSms": "What the agent does when someone texts this number. Outreach campaigns always draft their replies for approval, whatever you pick here.", + "auto": "Auto · reply and send", + "draftOnly": "Draft only · needs approval", + "off": "Off · no replies" + }, "eyebrow": "Workspace · Channels", "title": "Where the flock fly.", "subtitle": "Inbound surfaces where end-users reach you.", @@ -537,10 +545,6 @@ "fromAddressLabel": "From address", "fromAddressInvalid": "Enter a complete email address like name@example.com.", "fromNameLabel": "From name (optional)", - "defaultAgentModeLabel": "Default agent mode", - "agentModeAuto": "Auto · reply and send", - "agentModeDraftOnly": "Draft only · needs approval", - "agentModeOff": "Off · no AI replies", "portInvalid": "Port must be a whole number between 1 and 65535.", "hostInvalid": "Host must be a fully-qualified hostname like smtp.example.com, an IP address, or localhost — no scheme, slashes, or spaces.", "outboundLabel": "Outbound · SMTP", @@ -557,8 +561,8 @@ "create": "Create email channel", "editTitle": "Edit email channel", "editDescription": "Update the channel's name or transport. Leave a password blank to keep the stored credential — type a new one to replace it.", - "sendLimitsLabel": "Send limits", - "sendLimitsHelp": "Optional caps on outbound email volume to protect domain reputation. Messages over the limit stay queued and send automatically when capacity opens. Leave blank for no limit.", + "sendLimitsLabel": "Limit how much this channel sends", + "sendLimitsHelp": "Protects the domain reputation. Messages over the limit stay queued and send automatically when capacity opens.", "sendLimitsPlaceholder": "no limit", "perDayMax": "Max per day (rolling 24h)", "perHourMax": "Max per hour (rolling 60min)" @@ -584,10 +588,13 @@ "authTokenHintEdit": "Leave blank to keep the existing token. Paste a new value to rotate.", "fromNumberLabel": "From number", "fromNumberHint": "E.164 format (e.g. +15551234567). The Twilio number SMS will be sent from.", - "messagingServiceSidLabel": "Messaging Service SID (optional)", - "messagingServiceSidHint": "Starts with \"MG\". Use this instead of From number if you have a Messaging Service.", - "fromOrServiceHint": "Either From number or Messaging Service SID is required — providing both is allowed.", - "fromOrServiceRequired": "Provide a From number or a Messaging Service SID.", + "senderRequired": "Enter the number or Messaging Service SID this channel sends from.", + "senderHint": "A Messaging Service is required for US A2P 10DLC traffic, and is how Twilio does sender pools and sticky sender. Otherwise send from a single number.", + "senderLabel": "Send from", + "senderNumber": "Phone number", + "senderService": "Messaging Service", + "messagingServiceSidLabel": "Messaging Service SID", + "messagingServiceSidHint": "Starts with \"MG\". Find it in the Twilio console under Messaging → Services.", "sendTest": "Send test SMS", "messagingService": "messaging service · {sid}", "accountSidChip": "account · {sid}", diff --git a/packages/dashboard-pages/src/messages/nb.json b/packages/dashboard-pages/src/messages/nb.json index dbe6d700..d0692c6c 100644 --- a/packages/dashboard-pages/src/messages/nb.json +++ b/packages/dashboard-pages/src/messages/nb.json @@ -438,6 +438,14 @@ } }, "channels": { + "agentReplies": { + "label": "Agentsvar", + "hintEmail": "Hva agenten gjør når noen skriver til denne innboksen. Utgående kampanjer lager alltid utkast til godkjenning, uansett hva du velger her.", + "hintSms": "Hva agenten gjør når noen sender melding til dette nummeret. Utgående kampanjer lager alltid utkast til godkjenning, uansett hva du velger her.", + "auto": "Auto · svar og send", + "draftOnly": "Kun utkast · krever godkjenning", + "off": "Av · ingen svar" + }, "eyebrow": "Arbeidsområde · Kanaler", "title": "Der flokken flyr.", "subtitle": "Innkommende flater hvor sluttbrukere når deg.", @@ -537,10 +545,6 @@ "fromAddressLabel": "Fra-adresse", "fromAddressInvalid": "Skriv en fullstendig e-postadresse, f.eks. navn@example.com.", "fromNameLabel": "Fra-navn (valgfritt)", - "defaultAgentModeLabel": "Standard agentmodus", - "agentModeAuto": "Auto · svar og send", - "agentModeDraftOnly": "Kun utkast · krever godkjenning", - "agentModeOff": "Av · ingen KI-svar", "portInvalid": "Porten må være et heltall mellom 1 og 65535.", "hostInvalid": "Verten må være et fullt kvalifisert vertsnavn som smtp.example.com, en IP-adresse, eller localhost — ingen scheme, skråstreker eller mellomrom.", "outboundLabel": "Utgående · SMTP", @@ -557,8 +561,8 @@ "create": "Opprett e-postkanal", "editTitle": "Rediger e-postkanal", "editDescription": "Oppdater kanalens navn eller transport. La et passord stå tomt for å beholde det lagrede — skriv et nytt for å erstatte det.", - "sendLimitsLabel": "Sendegrenser", - "sendLimitsHelp": "Valgfrie tak på utgående e-post for å beskytte domenets omdømme. Meldinger over grensen forblir i køen og sendes automatisk når kapasiteten åpner seg. La feltet stå tomt for ingen grense.", + "sendLimitsLabel": "Begrens hvor mye denne kanalen sender", + "sendLimitsHelp": "Beskytter omdømmet til domenet. Meldinger over grensen blir stående i køen og sendes automatisk når det er kapasitet igjen.", "sendLimitsPlaceholder": "ingen grense", "perDayMax": "Maks per dag (rullende 24 t)", "perHourMax": "Maks per time (rullende 60 min)" @@ -584,10 +588,13 @@ "authTokenHintEdit": "La feltet stå tomt for å beholde eksisterende token. Lim inn ny verdi for å rotere.", "fromNumberLabel": "Avsendernummer", "fromNumberHint": "E.164-format (f.eks. +4712345678). Twilio-nummeret SMS sendes fra.", - "messagingServiceSidLabel": "Messaging Service SID (valgfritt)", - "messagingServiceSidHint": "Starter med «MG». Bruk denne i stedet for avsendernummer hvis du har en Messaging Service.", - "fromOrServiceHint": "Enten avsendernummer eller Messaging Service SID er påkrevd — begge er også OK.", - "fromOrServiceRequired": "Oppgi et avsendernummer eller en Messaging Service SID.", + "senderRequired": "Fyll inn nummeret eller Messaging Service-SID-en kanalen sender fra.", + "senderHint": "En Messaging Service er påkrevd for A2P 10DLC-trafikk i USA, og er måten Twilio gjør avsenderpuljer og fast avsender på. Ellers sender du fra ett enkelt nummer.", + "senderLabel": "Send fra", + "senderNumber": "Telefonnummer", + "senderService": "Messaging Service", + "messagingServiceSidLabel": "Messaging Service SID", + "messagingServiceSidHint": "Starter med «MG». Finnes i Twilio-konsollen under Messaging → Services.", "sendTest": "Send test-SMS", "messagingService": "messaging service · {sid}", "accountSidChip": "account · {sid}", diff --git a/packages/dashboard-pages/src/pages/channels.tsx b/packages/dashboard-pages/src/pages/channels.tsx index e39f40f9..6425efed 100644 --- a/packages/dashboard-pages/src/pages/channels.tsx +++ b/packages/dashboard-pages/src/pages/channels.tsx @@ -957,6 +957,8 @@ function VendorPicker({ ); } +type TwilioSender = 'number' | 'service'; + function shortenId(id: string): string { if (id.length <= 14) return id; return `${id.slice(0, 6)}…${id.slice(-4)}`; @@ -1220,6 +1222,7 @@ function EmailChannelDialog({ const [imapUsername, setImapUsername] = useState(''); const [imapPassword, setImapPassword] = useState(''); const [imapMailbox, setImapMailbox] = useState(''); + const [limitSending, setLimitSending] = useState(false); const [perDayMax, setPerDayMax] = useState(''); const [perHourMax, setPerHourMax] = useState(''); const [creating, setCreating] = useState(false); @@ -1255,6 +1258,7 @@ function EmailChannelDialog({ setImapUsername(cfg?.inbound?.username ?? ''); setImapPassword(''); setImapMailbox(cfg?.inbound?.mailbox ?? ''); + setLimitSending(cfg?.sendLimits?.perDayMax != null || cfg?.sendLimits?.perHourMax != null); setPerDayMax( cfg?.sendLimits?.perDayMax != null ? String(cfg.sendLimits.perDayMax) : '', ); @@ -1301,6 +1305,7 @@ function EmailChannelDialog({ } : {}), ...(() => { + if (!limitSending) return {}; const sendLimits: { perDayMax?: number; perHourMax?: number } = {}; const day = parsePositiveInt(perDayMax); const hour = parsePositiveInt(perHourMax); @@ -1411,6 +1416,14 @@ function EmailChannelDialog({ aria-invalid={fieldErrors.smtpPort ? true : undefined} /> + -
-
{t('email.sendLimitsLabel')}
-

{t('email.sendLimitsHelp')}

-
+ + {limitSending && ( +
+ )}
@@ -1516,6 +1535,14 @@ function EmailChannelDialog({ aria-invalid={fieldErrors.imapPort ? true : undefined} /> + - - - setDefaultAgentMode(e.target.value as 'auto' | 'draft_only' | 'off') - } - > - - - - - - +
+ + + setDefaultAgentMode(e.target.value as 'auto' | 'draft_only' | 'off') + } + > + + + + + +
)} @@ -2095,6 +2116,12 @@ function TwilioSmsChannelDialog({ const [messagingServiceSid, setMessagingServiceSid] = useState( editChannel.config?.messagingServiceSid ?? '', ); + const [defaultAgentMode, setDefaultAgentMode] = useState<'auto' | 'draft_only' | 'off'>( + editChannel.defaultAgentMode ?? 'auto', + ); + const [sender, setSender] = useState( + editChannel.config?.messagingServiceSid ? 'service' : 'number', + ); const [saving, setSaving] = useState(false); const [submitError, setSubmitError] = useState(null); const [fieldErrors, setFieldErrors] = useState>({}); @@ -2106,6 +2133,8 @@ function TwilioSmsChannelDialog({ setAuthToken(''); setFromNumber(editChannel.config?.fromNumber ?? ''); setMessagingServiceSid(editChannel.config?.messagingServiceSid ?? ''); + setSender(editChannel.config?.messagingServiceSid ? 'service' : 'number'); + setDefaultAgentMode(editChannel.defaultAgentMode ?? 'auto'); setSubmitError(null); setFieldErrors({}); setSaving(false); @@ -2117,8 +2146,10 @@ function TwilioSmsChannelDialog({ ...(name.trim() ? { name: name.trim() } : {}), ...(accountSid.trim() ? { accountSid: accountSid.trim() } : {}), ...(authToken ? { authToken } : {}), - ...(fromNumber.trim() ? { fromNumber: fromNumber.trim() } : {}), - ...(messagingServiceSid.trim() ? { messagingServiceSid: messagingServiceSid.trim() } : {}), + ...(sender === 'service' + ? { messagingServiceSid: messagingServiceSid.trim(), fromNumber: null } + : { fromNumber: fromNumber.trim(), messagingServiceSid: null }), + defaultAgentMode, }; const parsed = ConfigureTwilioSmsBody.safeParse(payload); if (!parsed.success) { @@ -2188,31 +2219,51 @@ function TwilioSmsChannelDialog({ autoComplete="off" /> - - setFromNumber(e.target.value)} - placeholder="+15551234567" - maxLength={32} - /> + + setSender(e.target.value as TwilioSender)}> + + + - - setMessagingServiceSid(e.target.value)} - placeholder="MG…" - maxLength={64} - /> + {sender === 'number' ? ( + + setFromNumber(e.target.value)} + placeholder="+15551234567" + maxLength={32} + /> + + ) : ( + + setMessagingServiceSid(e.target.value)} + placeholder="MG…" + maxLength={64} + /> + + )} + + + setDefaultAgentMode(e.target.value as 'auto' | 'draft_only' | 'off') + } + > + + + + -

{t('twilioSms.fromOrServiceHint')}

{submitError && }