From b4317a6190bbb950ef487a52fe2d8037bb3942ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kjell=20Rune=20Mons=C3=B8?= Date: Thu, 30 Jul 2026 08:51:25 +0200 Subject: [PATCH 1/4] feat(conv): thread inbound messages and honour SMS opt-out keywords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Webhook-mode channels opened a new conversation for every inbound message, so two texts from the same person were two unrelated conversations and no reply could be attributed to what it answered. Inbound messages now land in that contact's most recent open conversation on the channel, reopening a snoozed one; a closed conversation still starts a fresh thread. New conversations inherit the channel's defaultAgentMode, which this path dropped while the email path honoured it. An inbound SMS whose whole body is an opt-out keyword now suppresses the CRM contact on that number — doNotContact, unsubscribedAt, and an activity note. The message is still ingested so the conversation reads truthfully. A message that merely contains the word is an ordinary message. Honouring STOP is a legal requirement in the US and expected in the EU, so it lands before SMS outreach rather than with it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011HwhYb3yU3qnGjtFRodQxm --- .changeset/khaki-eagles-shave.md | 11 ++ .../backend-core/docs-fixtures/skills.json | 2 +- .../conv/channels/channel-ingest.service.ts | 141 +++++++++++++++--- .../conv/channels/channel-ingest.test.ts | 32 ++++ .../conv/skills/setup-voice-sms-channel.md | 8 + .../twilio/twilio-sms.integration.test.ts | 98 ++++++++++++ 6 files changed, 269 insertions(+), 23 deletions(-) create mode 100644 .changeset/khaki-eagles-shave.md create mode 100644 packages/backend-core/src/modules/conv/channels/channel-ingest.test.ts 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/packages/backend-core/docs-fixtures/skills.json b/packages/backend-core/docs-fixtures/skills.json index 2520da43..c0185fb2 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**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-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/skills/setup-voice-sms-channel.md b/packages/backend-core/src/modules/conv/skills/setup-voice-sms-channel.md index dd08b873..1efa8dd1 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,14 @@ 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. + +**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.integration.test.ts b/packages/backend-core/src/modules/conv/twilio/twilio-sms.integration.test.ts index 97447e59..c3b8eb19 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,104 @@ 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('updates conv_message_deliveries on a status callback', async () => { const conversationRows = await db .select({ id: schema.convConversations.id }) From 711eea80b831435acca9d35ff23b8e4e896adb17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kjell=20Rune=20Mons=C3=B8?= Date: Thu, 30 Jul 2026 09:10:43 +0200 Subject: [PATCH 2/4] feat(conv): let SMS channels set how the agent handles inbound texts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, so every SMS number sat on the 'auto' default — which is exactly wrong for a number you only run campaigns from. Threading defaultAgentMode into inbound conversations in the previous commit made that gap load-bearing. It is now settable through conv_configure_channel, the vendor tools, the /v1 SMS endpoints, and a select in the Twilio and MessageBird dialogs matching the one email already had. Voice rejects it with an explanation rather than accepting a value that would do nothing: an inbound call is run by the vendor's assistant, so there is no Munin reply for the mode to govern. conv_create_channel's description claimed voice and sms were "reserved and not yet wired to an adapter". Both SMS vendors and both voice vendors have shipped adapters and are created with conv_configure_channel. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011HwhYb3yU3qnGjtFRodQxm --- .changeset/tall-pianos-repeat.md | 15 ++++++++ .../backend-core/docs-fixtures/mcp-tools.json | 13 +++++-- .../backend-core/docs-fixtures/skills.json | 2 +- .../conv/channels/channel-admin.service.ts | 29 +++++++++++++-- .../conv/channels/channel-admin.tools.ts | 8 +++-- .../modules/conv/channels/channel-admin.ts | 1 + .../src/modules/conv/conv.tools.ts | 2 +- .../messagebird-sms-admin.service.ts | 7 +++- .../messagebird/messagebird-sms.service.ts | 12 +++++-- .../conv/skills/setup-voice-sms-channel.md | 2 ++ .../conv/twilio/twilio-sms-admin.service.ts | 7 +++- .../twilio/twilio-sms.integration.test.ts | 35 +++++++++++++++++++ .../modules/conv/twilio/twilio-sms.service.ts | 24 ++++++++++--- packages/dashboard-pages/src/messages/en.json | 4 +++ packages/dashboard-pages/src/messages/nb.json | 4 +++ .../dashboard-pages/src/pages/channels.tsx | 34 ++++++++++++++++++ packages/types/src/channels.ts | 2 ++ 17 files changed, 185 insertions(+), 16 deletions(-) create mode 100644 .changeset/tall-pianos-repeat.md 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/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 c0185fb2..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## 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**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" + "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/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 1efa8dd1..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 @@ -36,6 +36,8 @@ A channel waiting on its credential link is `active: false` and every admin acti 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. 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..1ce80b00 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) @@ -68,6 +71,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,6 +88,7 @@ export class TwilioSmsAdminService { } return this.svc.createChannel({ name: args.name, + defaultAgentMode: args.defaultAgentMode, config: { accountSid: args.accountSid, authToken: args.authToken, 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 c3b8eb19..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 @@ -303,6 +303,41 @@ const skipReason = TEST_URL 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..1bff07b6 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,18 @@ 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; + defaultAgentMode?: AgentMode; }): Promise { const ctx = getCurrentContext(); const actor = ctx.actor!; @@ -115,13 +123,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 +174,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, diff --git a/packages/dashboard-pages/src/messages/en.json b/packages/dashboard-pages/src/messages/en.json index 3a5a2508..6e0b7284 100644 --- a/packages/dashboard-pages/src/messages/en.json +++ b/packages/dashboard-pages/src/messages/en.json @@ -573,6 +573,10 @@ "description": "Pick a voice AI vendor and connect credentials. The vendor runs the call end-to-end (STT, LLM, TTS); Munin receives transcripts and call artifacts via the shared webhook.", "create": "Create voice channel" }, + "sms": { + "defaultAgentModeLabel": "Inbound replies", + "defaultAgentModeHint": "What the agent does with texts arriving on this number. Use \"Draft only\" on an outreach-only number so nothing is auto-sent." + }, "twilioSms": { "editTitle": "Edit Twilio SMS channel", "editDescription": "Update name, Account SID, or sender. Leave Auth Token blank to keep the stored credential — type a new one to rotate.", diff --git a/packages/dashboard-pages/src/messages/nb.json b/packages/dashboard-pages/src/messages/nb.json index dbe6d700..21ea0be6 100644 --- a/packages/dashboard-pages/src/messages/nb.json +++ b/packages/dashboard-pages/src/messages/nb.json @@ -573,6 +573,10 @@ "description": "Velg en stemme-AI-leverandør og koble til påloggingsinformasjon. Leverandøren kjører hele samtalen (STT, LLM, TTS); Munin mottar transkripsjoner og samtaledata via samme webhook.", "create": "Opprett stemmekanal" }, + "sms": { + "defaultAgentModeLabel": "Innkommende svar", + "defaultAgentModeHint": "Hva agenten gjør med meldinger som kommer inn på dette nummeret. Bruk «Kun utkast» på et nummer som kun brukes til utgående kampanjer, så sendes ingenting automatisk." + }, "twilioSms": { "editTitle": "Rediger Twilio SMS-kanal", "editDescription": "Oppdater navn, Account SID eller avsender. La Auth Token stå tom for å beholde lagret verdi — skriv en ny for å rotere.", diff --git a/packages/dashboard-pages/src/pages/channels.tsx b/packages/dashboard-pages/src/pages/channels.tsx index e39f40f9..04c8d99b 100644 --- a/packages/dashboard-pages/src/pages/channels.tsx +++ b/packages/dashboard-pages/src/pages/channels.tsx @@ -2095,6 +2095,9 @@ function TwilioSmsChannelDialog({ const [messagingServiceSid, setMessagingServiceSid] = useState( editChannel.config?.messagingServiceSid ?? '', ); + const [defaultAgentMode, setDefaultAgentMode] = useState<'auto' | 'draft_only' | 'off'>( + editChannel.defaultAgentMode ?? 'auto', + ); const [saving, setSaving] = useState(false); const [submitError, setSubmitError] = useState(null); const [fieldErrors, setFieldErrors] = useState>({}); @@ -2106,6 +2109,7 @@ function TwilioSmsChannelDialog({ setAuthToken(''); setFromNumber(editChannel.config?.fromNumber ?? ''); setMessagingServiceSid(editChannel.config?.messagingServiceSid ?? ''); + setDefaultAgentMode(editChannel.defaultAgentMode ?? 'auto'); setSubmitError(null); setFieldErrors({}); setSaving(false); @@ -2119,6 +2123,7 @@ function TwilioSmsChannelDialog({ ...(authToken ? { authToken } : {}), ...(fromNumber.trim() ? { fromNumber: fromNumber.trim() } : {}), ...(messagingServiceSid.trim() ? { messagingServiceSid: messagingServiceSid.trim() } : {}), + defaultAgentMode, }; const parsed = ConfigureTwilioSmsBody.safeParse(payload); if (!parsed.success) { @@ -2164,6 +2169,18 @@ function TwilioSmsChannelDialog({ maxLength={120} /> + + + setDefaultAgentMode(e.target.value as 'auto' | 'draft_only' | 'off') + } + > + + + + + ( + editChannel.defaultAgentMode ?? 'auto', + ); const [saving, setSaving] = useState(false); const [submitError, setSubmitError] = useState(null); const [fieldErrors, setFieldErrors] = useState>({}); @@ -2383,6 +2403,7 @@ function MessageBirdSmsChannelDialog({ setAccessKey(''); setSigningKey(''); setOriginator(editChannel.config?.originator ?? ''); + setDefaultAgentMode(editChannel.defaultAgentMode ?? 'auto'); setSubmitError(null); setFieldErrors({}); setSaving(false); @@ -2395,6 +2416,7 @@ function MessageBirdSmsChannelDialog({ ...(accessKey ? { accessKey } : {}), ...(signingKey ? { signingKey } : {}), ...(originator.trim() ? { originator: originator.trim() } : {}), + defaultAgentMode, }; const parsed = ConfigureMessageBirdSmsBody.safeParse(payload); if (!parsed.success) { @@ -2440,6 +2462,18 @@ function MessageBirdSmsChannelDialog({ maxLength={120} /> + + + setDefaultAgentMode(e.target.value as 'auto' | 'draft_only' | 'off') + } + > + + + + + @@ -132,6 +133,7 @@ export const ConfigureMessageBirdSmsBody = z accessKey: sensitive(z.string().min(1).max(256).optional()), signingKey: sensitive(z.string().min(1).max(256).optional()), originator: z.string().min(1).max(32).optional(), + defaultAgentMode: AgentModeSchema.optional(), }) .refine( (v) => From 4a8bc225b00b910f29270755af19af445aff89ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kjell=20Rune=20Mons=C3=B8?= Date: Thu, 30 Jul 2026 11:31:04 +0200 Subject: [PATCH 3/4] feat(conv): finish the SMS channel config dialogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent-reply select landed in the edit dialogs only; the create dialog had no way to set it, so a channel always started on 'auto' until someone went back and changed it. Added to AddSmsDialog for both vendors. Switching a Twilio channel between From number and Messaging Service could never clear the field being switched away from: updateChannel merged with `?? prev`, so the old value survived and both got sent on every send, which is wrong once a channel is meant to use only one. ConfigureTwilioSmsBody and the admin tool schema now accept null to mean "clear this", and resolveSender treats null and undefined differently. The two fields are now a single 'Send from' select instead of two always-visible inputs with an "either, both is also OK" paragraph explaining the interaction. Renamed the shared setting from 'Default agent mode' / 'Inbound replies' to 'Agent replies' in both locales, since 'agent mode' is the conv_channels column name, not a term an operator configuring a phone number has a model for, and the SMS dialogs were reaching into the email.* namespace for it. Hoisted to a shared channels.agentReplies.* key used by all three dialogs. The hint now states the fact worth knowing — outreach always drafts for approval regardless of this setting — instead of a per-conversation-override claim the dashboard has no UI for. While comparing the SMS and email dialogs side by side: moved 'Bruk TLS' to sit directly under Host/Port in both the SMTP and IMAP sections of the email dialog, for consistency with itself; turned 'Send limits' into a checkbox that reveals the two cap fields, matching the pattern 'Enable inbound' already uses, and reworded the label and hint now that the fields aren't sitting empty by default. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011HwhYb3yU3qnGjtFRodQxm --- .../conv/twilio/twilio-sms-admin.service.ts | 14 +- .../modules/conv/twilio/twilio-sms.service.ts | 20 +- packages/dashboard-pages/src/messages/en.json | 31 +- packages/dashboard-pages/src/messages/nb.json | 31 +- .../dashboard-pages/src/pages/channels.tsx | 282 +++++++++++------- packages/types/src/channels.ts | 4 +- 6 files changed, 236 insertions(+), 146 deletions(-) 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 1ce80b00..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 @@ -41,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() @@ -92,8 +98,8 @@ export class TwilioSmsAdminService { 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.service.ts b/packages/backend-core/src/modules/conv/twilio/twilio-sms.service.ts index 1bff07b6..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 @@ -87,7 +87,10 @@ export class TwilioSmsService { async updateChannel(input: { channelId: string; name?: string; - config?: Partial; + config?: Partial> & { + fromNumber?: string | null; + messagingServiceSid?: string | null; + }; defaultAgentMode?: AgentMode; }): Promise { const ctx = getCurrentContext(); @@ -113,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'); @@ -198,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 6e0b7284..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)" @@ -573,10 +577,6 @@ "description": "Pick a voice AI vendor and connect credentials. The vendor runs the call end-to-end (STT, LLM, TTS); Munin receives transcripts and call artifacts via the shared webhook.", "create": "Create voice channel" }, - "sms": { - "defaultAgentModeLabel": "Inbound replies", - "defaultAgentModeHint": "What the agent does with texts arriving on this number. Use \"Draft only\" on an outreach-only number so nothing is auto-sent." - }, "twilioSms": { "editTitle": "Edit Twilio SMS channel", "editDescription": "Update name, Account SID, or sender. Leave Auth Token blank to keep the stored credential — type a new one to rotate.", @@ -588,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 21ea0be6..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)" @@ -573,10 +577,6 @@ "description": "Velg en stemme-AI-leverandør og koble til påloggingsinformasjon. Leverandøren kjører hele samtalen (STT, LLM, TTS); Munin mottar transkripsjoner og samtaledata via samme webhook.", "create": "Opprett stemmekanal" }, - "sms": { - "defaultAgentModeLabel": "Innkommende svar", - "defaultAgentModeHint": "Hva agenten gjør med meldinger som kommer inn på dette nummeret. Bruk «Kun utkast» på et nummer som kun brukes til utgående kampanjer, så sendes ingenting automatisk." - }, "twilioSms": { "editTitle": "Rediger Twilio SMS-kanal", "editDescription": "Oppdater navn, Account SID eller avsender. La Auth Token stå tom for å beholde lagret verdi — skriv en ny for å rotere.", @@ -588,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 04c8d99b..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') + } + > + + + + + +
)} @@ -2098,6 +2119,9 @@ function TwilioSmsChannelDialog({ 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>({}); @@ -2109,6 +2133,7 @@ function TwilioSmsChannelDialog({ setAuthToken(''); setFromNumber(editChannel.config?.fromNumber ?? ''); setMessagingServiceSid(editChannel.config?.messagingServiceSid ?? ''); + setSender(editChannel.config?.messagingServiceSid ? 'service' : 'number'); setDefaultAgentMode(editChannel.defaultAgentMode ?? 'auto'); setSubmitError(null); setFieldErrors({}); @@ -2121,8 +2146,9 @@ 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); @@ -2169,18 +2195,6 @@ function TwilioSmsChannelDialog({ maxLength={120} /> - - - setDefaultAgentMode(e.target.value as 'auto' | 'draft_only' | '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 && }