diff --git a/.changeset/rotten-melons-look.md b/.changeset/rotten-melons-look.md new file mode 100644 index 00000000..e19c5c3e --- /dev/null +++ b/.changeset/rotten-melons-look.md @@ -0,0 +1,15 @@ +--- +'@getmunin/backend-core': minor +--- + +SMS outreach campaigns + +An outreach campaign can now run on an SMS channel. `outreach_propose_initial` drafts a text against a contact's phone number, and a person approving it in the dashboard creates the outbound conversation and sends the message. As with voice, an agent cannot approve one. + +Drafts are capped at 480 characters — roughly three billable segments — and rejected above it, with the limit stated in the tool schema so a curator can write to it rather than discover it. Composition is SMS-shaped: the campaign CTA is appended as a bare URL and the opt-out line as `Reply STOP to opt out.`, instead of the markdown link footer an email gets. `skill://outreach/draft-initial-sms` covers the writing rules, including why an emoji triples the cost of a message. + +**Outbound SMS was never actually delivered.** `ConvService` enqueued a `conv_message_deliveries` row only for `channelType === 'email'`, so an agent or operator replying on an SMS conversation stored a message that silently never sent — the `OutboundDeliveryWorker` and both SMS adapters were fine, nothing was ever handed to them. The gate now covers email and SMS. + +Follow-up sequences stay email-only: an SMS campaign rejects `sequenceSteps`, and `outreach_propose_followup` still refuses a text conversation. One touch, then the reply flow, which works because inbound texts thread into the outreach conversation. + +`findOrCreateContactByPhone` was duplicated in the Vapi and Threll adapters and inlined a third time in the outreach voice path; it is now one shared helper taking the source as a parameter. diff --git a/packages/backend-core/docs-fixtures/mcp-tools.json b/packages/backend-core/docs-fixtures/mcp-tools.json index a5b9dc89..1849301d 100644 --- a/packages/backend-core/docs-fixtures/mcp-tools.json +++ b/packages/backend-core/docs-fixtures/mcp-tools.json @@ -6797,7 +6797,7 @@ { "name": "outreach_create_campaign", "title": "Outreach: Create campaign", - "description": "Create an outbound-campaign definition. Operators write `brief` as a one-paragraph human description of intent (the curator personalises per contact from this). `segmentId` chooses the audience; the curator calls `crm_list_contacts_in_segment` (which always enforces suppression+consent floor) to materialize it. `channelId` must reference an email channel. New campaigns default `enabled: false` so nothing sends until you flip it on. Automation is opt-in per behavior: `autoDraftInitial` defaults false (the weekly curator does not draft first-touch emails until you set it true — draft manually otherwise), while `autoDraftReplies` defaults true (replies to inbound prospect messages are auto-drafted for review). Optional `sequenceSteps` (email campaigns only) defines a follow-up sequence — each step is a wait period plus a drafting brief; defining steps on an enabled campaign opts it into daily follow-up drafting for threads with no reply.", + "description": "Create an outbound-campaign definition. Operators write `brief` as a one-paragraph human description of intent (the curator personalises per contact from this). `segmentId` chooses the audience; the curator calls `crm_list_contacts_in_segment` (which always enforces suppression+consent floor) to materialize it. `channelId` must reference an email, SMS, or voice channel; approving a proposal on an SMS or voice campaign is restricted to a signed-in person in the Munin dashboard. New campaigns default `enabled: false` so nothing sends until you flip it on. Automation is opt-in per behavior: `autoDraftInitial` defaults false (the weekly curator does not draft first-touch emails until you set it true — draft manually otherwise), while `autoDraftReplies` defaults true (replies to inbound prospect messages are auto-drafted for review). Auto-sending a reply is not an option on any campaign: conversations created by an approved proposal are always set to `draft_only`, whatever the channel default says, so a prospect never receives an unreviewed reply. Optional `sequenceSteps` (email campaigns only) defines a follow-up sequence — each step is a wait period plus a drafting brief; defining steps on an enabled campaign opts it into daily follow-up drafting for threads with no reply.", "audiences": [ "admin" ], @@ -7581,7 +7581,7 @@ "maxLength": 64 }, "draftSubject": { - "description": "Required for email campaigns; omit for voice campaigns where the call has no subject.", + "description": "Required for email campaigns; omit for SMS and voice campaigns, which have no subject.", "type": "string", "maxLength": 300 }, @@ -7589,7 +7589,7 @@ "type": "string", "minLength": 1, "maxLength": 20000, - "description": "For email campaigns: the email body. For voice campaigns: the opening line / talking-points the AI agent should use when the call connects." + "description": "For email campaigns: the email body. For SMS campaigns: the text message, capped at 480 characters, plain text — no markdown, and no opt-out line, which Munin appends. For voice campaigns: the opening line / talking-points the AI agent should use when the call connects." }, "evidence": { "type": "object", diff --git a/packages/backend-core/docs-fixtures/skills.json b/packages/backend-core/docs-fixtures/skills.json index e8e88438..8eca1651 100644 --- a/packages/backend-core/docs-fixtures/skills.json +++ b/packages/backend-core/docs-fixtures/skills.json @@ -305,6 +305,15 @@ "mimeType": "text/markdown", "content": "# Draft an initial outreach email\nOperators set up campaigns (`outreach_create_campaign`) with a one-paragraph **brief** and a target **CRM segment**. Your job in a pass is to materialise the segment, draft a personalised first-touch email per contact, and file each draft as a pending **proposal** for human review. **You never send anything.** The operator approves each proposal one by one (or a trusted admin agent does on their behalf), at which point the system sends via the campaign's email channel and threads any reply back into the same conversation.\n\nThis pass is symmetric with `skill://kb/review-content` (drafted candidates) but for outreach instead of KB. Always-propose is non-negotiable: an LLM-drafted cold email going straight to a prospect is exactly how you ship a tone-deaf message you can't take back. Human approval is the system invariant.\n\nA separate `skill://crm/clean-contact-data` runs weekly to merge any duplicate contacts this and other curators leave behind. Don't try to do hygiene's job here — keep the per-campaign pass narrow.\n\n## TL;DR\n\n1. **List campaigns** with `outreach_list_campaigns`. Skip rows where `enabled = false` or `autoDraftInitial = false` (the latter are drafted manually on demand, not by this weekly pass).\n2. **For each campaign**, materialise the audience with `crm_list_contacts_in_segment(campaign.segmentId)`. The list is *already* filtered for suppression (`do_not_contact`, `unsubscribed_at`) and lawful basis (`consent_lawful_basis IS NOT NULL`) — that floor is non-overridable in the service. Treat what comes back as the eligible set.\n3. **For each contact in the audience**, dedupe via `outreach_list_proposals({ kind: \"initial\", campaignId, contactId })`. Skip if any proposal is `pending`, `approved`, or `sent` (already drafted or already reached). Only `dismissed`/`withdrawn`/`failed` allow a re-draft.\n4. **Pull product context** with `kb_search` against the brief — find 1–3 relevant KB snippets to ground the email in real facts (don't fabricate features).\n5. **Draft** an 80–200-word email, personalised to the contact's name + company. Plain prose, no headings, sparing bold/italic, no JSON-escaping. The unsubscribe footer is appended **at approve-time** by the system — do not include one in your draft.\n6. **File** with `outreach_propose_initial({ campaignId, contactId, draftSubject, draftBody, evidence })`. The `evidence` JSONB carries the (KB doc ids, contact-tag matches, reasoning summary) you'd want a human reviewer to see — keep it short and structured.\n7. **Stop.** No further calls. The operator's approval flow does the sending.\n\n## Step 1 — list enabled campaigns\n\n```jsonc\n{ \"name\": \"outreach_list_campaigns\", \"arguments\": {} }\n```\n\nEach row carries `id`, `name`, `brief`, `segmentId`, `channelId`, `cadenceRules`, `ctaUrl`, `enabled`, `autoDraftInitial`, `autoDraftReplies`, `unsubscribeRequired`. Filter to `enabled = true` AND `autoDraftInitial = true` (a campaign with `autoDraftInitial = false` is live but the operator drafts first-touch by hand — leave it alone). Skim `cadenceRules.maxPerWeekPerContact` for sanity (it doesn't gate you here — it's enforced at send-time — but if you see `1` you should be especially conservative about re-running too often).\n\n## Step 2 — materialise the audience\n\n```jsonc\n{ \"name\": \"crm_list_contacts_in_segment\", \"arguments\": { \"id\": \"\", \"limit\": 200 } }\n```\n\nYou get `ContactDto[]` already pre-filtered. Each contact has `id`, `name`, `email`, `companyId`, `tags`, `consentLawfulBasis`, `lastContactedAt`, etc. **Do not** call `crm_list_contacts` directly — that surface bypasses the suppression+consent floor.\n\nIf the segment returns 0 contacts, skip this campaign entirely.\n\n## Step 3 — dedupe before drafting\n\n```jsonc\n{\n \"name\": \"outreach_list_proposals\",\n \"arguments\": { \"kind\": \"initial\", \"campaignId\": \"\", \"contactId\": \"\" }\n}\n```\n\nIf any returned proposal is `pending`, `approved`, or `sent`, skip the contact — they already have a draft in flight or were already reached. Don't re-propose; the service will reject you anyway (the pending unique index for a pending draft, an `outreach_conflict` for a sent/approved first-touch), and you'll waste an LLM call. Three statuses leave the contact eligible for a fresh draft: `dismissed` (the operator rejected a prior draft), `withdrawn` (a curator retracted its own draft), and `failed` (a send that didn't land).\n\nYou may also want to skip when the contact's `lastContactedAt` was within `cadenceRules.maxPerWeekPerContact / 7` days — but for the initial pass, skipping based on an existing non-dismissed proposal is the only hard rule.\n\n**If you catch your own mistake after filing**, don't leave it in the queue for a human to clean up. `outreach_withdraw_proposal({ id, reason })` retracts a pending draft neutrally — the case this exists for is exactly the one this pass produces: two drafts for the same person, a contact who turns out not to fit the segment's intent, an address you later learn bounces. It doesn't suppress the contact or stop anything; it just takes the draft back. If the recipient is right and only the wording is wrong, revise in place instead — see `skill://outreach/review-proposals`.\n\n## Step 4 — pull product context\n\nThe campaign's brief is operator-written intent (\"we just shipped a feature for X-shaped customers\"). Don't paraphrase claims you can't ground. Use `kb_search` to pull supporting docs:\n\n```jsonc\n{ \"name\": \"kb_search\", \"arguments\": { \"query\": \"\", \"limit\": 3 } }\n```\n\nIf `kb_search` returns nothing relevant, your draft must rely strictly on the brief — don't invent features or numbers. If the brief itself is a thin prompt and there's no KB grounding, write the email at a higher level (\"we'd like to learn how you're approaching X\" rather than \"we ship X feature with Y latency\").\n\n## Step 5 — draft\n\nStrict rules:\n\n- **Subject** — concrete and specific. 6–12 words. No clickbait, no all-caps. Avoid generic openers (\"Quick question?\"); reference the brief or the contact's company.\n- **Body** — 80–200 words. Personalisation is one short sentence at most (\"saw you're at Acme — congrats on the recent funding\" only if you can ground it in evidence; otherwise drop it). The rest is brief, the value prop, one direct ask.\n- **Format** — plain prose. Bold/italic sparingly for one or two key terms. Bullets are OK for a list of 2–3 short items. **No `#`/`##`/`###` headings.** No tables, no images.\n- **JSON literals** — pass real strings with real newlines. Do not stringify the body so it ends up containing `\\n` characters.\n- **Voice** — second person, plain language, the way an operator would write if they had time.\n- **Unsubscribe footer** — do **NOT** include one. The system appends a signed unsubscribe link at approve-time so it can't be tampered with at draft-time.\n\n## Step 6 — file the proposal\n\n```jsonc\n{\n \"name\": \"outreach_propose_initial\",\n \"arguments\": {\n \"campaignId\": \"ocmp_…\",\n \"contactId\": \"cct_…\",\n \"draftSubject\": \"Quick thought on Acme's onboarding loop\",\n \"draftBody\": \"Hi Jane,\\n\\nI noticed Acme just shipped self-serve onboarding — congrats. We help similar B2B teams cut time-to-first-value by ~40% by …\",\n \"evidence\": {\n \"kbDocIds\": [\"kdoc_abc\", \"kdoc_def\"],\n \"contactSignals\": [\"title=Head of Ops\", \"tag=enterprise\"],\n \"reasoning\": \"Brief targets ops leaders; contact title matches; one KB doc on onboarding loops.\"\n }\n }\n}\n```\n\nBehavior:\n\n- The proposal lands in `pending` status, visible to the operator on `/dashboard/inbox` (Outreach drafts tab).\n- An `outreach.proposal.created` realtime event fires.\n- Re-running this skill on the same (campaign, contact) while a pending draft exists, or after a first-touch was already sent/approved, will reject with a conflict — that's the dedup signal.\n\n## Step 7 — review and approve (the operator's loop)\n\nOut of scope for this skill — see `skill://outreach/review-proposals`. The operator (or a trusted admin agent acting on their authority) calls `outreach_list_proposals({ status: \"pending\" })`, reviews each draft (MCP App hosts render the Munin Inspector review panel inline), then either approves via `outreach_approve_proposal` (which sends via the campaign's email channel and creates an outbound conversation) or dismisses via `outreach_dismiss_proposal` with a reason.\n\n## What NOT to do\n\n- **Don't auto-approve.** The plan-level invariant: every outreach email ships through a human-approved gate. If you're tempted to call `outreach_propose_initial` followed by `outreach_approve_proposal`, stop. The approve surface belongs to the operator's review pass (`skill://outreach/review-proposals`); a curator never decides its own drafts.\n- **Don't bypass `crm_list_contacts_in_segment`.** Calling `crm_list_contacts` directly bypasses the suppression+consent floor and will eventually file proposals for someone who already unsubscribed — even if the operator catches it at approve-time, the audit trail looks bad.\n- **Don't fabricate facts.** If the brief says \"we shipped feature X\" and KB has no doc on X, write at a higher level. Better to send a vaguer email than a confidently wrong one.\n- **Don't write headings or pseudo-templates.** No `# Hello {name}` or `## About us`. Real emails are plain prose.\n- **Don't include an unsubscribe link in the draft body.** The system appends one. If you write your own, the operator will see two and the system one is the only signed/verifiable one.\n- **Don't propose a reply.** PR3 ships `outreach_propose_reply` and a separate skill (`skill://outreach/draft-reply-email`). For now, you only file `kind: \"initial\"`.\n\n## Related\n\n- `skill://outreach/draft-followup-email` — drafts the next sequence step when an initial filed by this pass sits unanswered past a campaign's `sequenceSteps` wait period.\n- `skill://kb/review-content` — symmetric pattern (per-conversation curator that proposes, human approves) for KB instead of outreach.\n- `skill://crm/clean-contact-data` — population-level dedup that catches duplicates this and other curators create.\n- `skill://crm/extract-contact-from-message` — auto-applied (NOT propose-and-review) per-conversation contact creation. The asymmetry vs this skill: extracting what the user typed is faithful transcription; drafting outreach is generative — different risk profiles.\n" }, + { + "uri": "skill://outreach/draft-initial-sms", + "module": "outreach", + "slug": "draft-initial-sms", + "title": "Outreach: Draft an initial text message", + "description": "Draft first-touch SMS outreach for a campaign running on an SMS channel. One pending proposal per (campaign, contact), capped at 480 characters, plain text. Only a signed-in person in the Munin dashboard can approve a text — an agent never sends one.", + "mimeType": "text/markdown", + "content": "# Draft an initial text message\n\nSame shape as `skill://outreach/draft-initial-email`, with three differences that matter enough to be their own skill: a text is far shorter, it costs money per segment, and **you cannot send one**.\n\n**Approving a text is a dashboard-only action.** `outreach_approve_proposal` refuses any caller that is not a signed-in dashboard user — agents, admin API keys, the Slack button. Draft, file the proposal, tell the operator it is waiting in the dashboard inbox, and stop. Don't retry, don't look for another tool, and don't ask for a credential that would work. `outreach_revise_proposal`, `outreach_withdraw_proposal` and `outreach_dismiss_proposal` all still work on these — none of them send anything.\n\n## The pass\n\n1. **List campaigns** with `outreach_list_campaigns` and keep the enabled ones. Check the campaign's channel is SMS before drafting text-shaped copy — `outreach_list_proposals` on an existing proposal shows `delivery.channelType`, or read the channel off `conv_list_channels`.\n2. **Materialise the audience** with `crm_list_contacts_in_segment(campaign.segmentId)`. Already filtered for suppression and lawful basis. Anyone who ever replied `STOP` to a text is suppressed automatically and will not appear.\n3. **Skip contacts with no `phone`.** `outreach_propose_initial` rejects them, and a rejection you could have predicted is a wasted call.\n4. **Dedupe** via `outreach_list_proposals({ kind: \"initial\", campaignId, contactId })` exactly as the email pass does.\n5. **Draft** (rules below) and file with `outreach_propose_initial({ campaignId, contactId, draftBody, evidence })`. No `draftSubject` — a text has no subject.\n6. **Stop.**\n\n## Writing the text\n\n- **480 characters hard cap**, enforced by the service. That is roughly three billable segments; a single segment is 160 characters of GSM-7 (70 if you use emoji or characters outside the GSM alphabet, which silently switches the whole message to UCS-2 and triples the cost). Aim for one segment. Every character is someone's money.\n- **Plain text only.** No markdown — `**bold**` and `[link](url)` arrive literally as asterisks and brackets. Write the URL bare if you need one.\n- **Do not write an opt-out line.** Munin appends `Reply STOP to opt out.` at approve time when the campaign requires it, and appends the campaign CTA URL if one is set. Both are counted against the recipient's screen, not yours, so leave room.\n- **Say who you are in the first clause.** A text from an unknown number with no sender is indistinguishable from spam, and unlike email there is no From line to check.\n- **One ask.** There is no room for context-setting, a value proposition and a call to action. Pick the ask.\n- **Match the recipient's language.** A Norwegian contact gets Norwegian; don't send translated English.\n\nGood: `Hei Jane — Kjell fra Munin. Du ba om beskjed når vi lanserte selvbetjent onboarding. Den er live nå. Vil du ha en rask demo?`\n\nBad: `Hi Jane! 👋 **Great news** from the team at Munin — we've *just* shipped our new self-serve onboarding flow, which we think you'll love based on our last conversation. Check it out here: [Munin onboarding](https://…) and let us know what you think! Reply STOP to unsubscribe.` — emoji forces UCS-2, markdown arrives raw, and it hand-writes an opt-out line the system will append again.\n\n## What happens on approval\n\nA person approves in the dashboard. Munin composes the final body (draft + CTA + opt-out line), creates an outbound conversation on the campaign's SMS channel in `draft_only` mode, and queues the message for delivery. A reply from the prospect threads into that same conversation, so `outreach_propose_reply` works there — see `skill://outreach/draft-reply-email`, which applies to texts too.\n\nFollow-up sequences are email-only. An SMS campaign cannot carry `sequenceSteps`, and `outreach_propose_followup` rejects a text conversation. One touch, then the reply flow.\n\n## Related\n\n- `skill://outreach/draft-initial-email` — the same pass for email, where you may draft at length.\n- `skill://outreach/review-proposals` — what the operator does with what you filed, and why you cannot do it for them.\n- `skill://conv/setup-voice-sms-channel` — configuring the number, including `defaultAgentMode` and how STOP suppression works.\n" + }, { "uri": "skill://outreach/draft-reply-email", "module": "outreach", diff --git a/packages/backend-core/src/modules/conv/contact-by-phone.ts b/packages/backend-core/src/modules/conv/contact-by-phone.ts new file mode 100644 index 00000000..77fee645 --- /dev/null +++ b/packages/backend-core/src/modules/conv/contact-by-phone.ts @@ -0,0 +1,59 @@ +import { and, eq } from 'drizzle-orm'; +import { schema, type Db, type Tx } from '@getmunin/db'; + +export async function findOrCreateContactByPhone( + tx: Db | Tx, + orgId: string, + phone: string | undefined, + name: string | undefined, + source: string, +): Promise { + if (!phone) return null; + const existing = await tx + .select() + .from(schema.convContacts) + .where(and(eq(schema.convContacts.orgId, orgId), eq(schema.convContacts.phone, phone))) + .limit(1); + if (existing[0]) return existing[0]; + + const externalId = `phone:${phone}`; + const eu = await tx + .select() + .from(schema.endUsers) + .where(and(eq(schema.endUsers.orgId, orgId), eq(schema.endUsers.externalId, externalId))) + .limit(1); + let endUserId: string | null = eu[0]?.id ?? null; + if (!endUserId) { + try { + const [createdEu] = await tx + .insert(schema.endUsers) + .values({ + orgId, + externalId, + phone, + name: name ?? null, + metadata: { source }, + }) + .returning(); + endUserId = createdEu?.id ?? null; + } catch { + const reread = await tx + .select() + .from(schema.endUsers) + .where(and(eq(schema.endUsers.orgId, orgId), eq(schema.endUsers.externalId, externalId))) + .limit(1); + endUserId = reread[0]?.id ?? null; + } + } + const [contact] = await tx + .insert(schema.convContacts) + .values({ + orgId, + phone, + name: name ?? null, + endUserId, + metadata: {}, + }) + .returning(); + return contact ?? null; +} diff --git a/packages/backend-core/src/modules/conv/conv.service.ts b/packages/backend-core/src/modules/conv/conv.service.ts index 6b402ce6..9ba0de15 100644 --- a/packages/backend-core/src/modules/conv/conv.service.ts +++ b/packages/backend-core/src/modules/conv/conv.service.ts @@ -49,6 +49,8 @@ export class AgentReplyRaceError extends Error { export const CHANNEL_TYPES = ['email', 'voice', 'chat', 'sms'] as const; export const STATUSES = ['open', 'snoozed', 'closed', 'spam'] as const; export const AGENT_MODES = ['auto', 'draft_only', 'off'] as const; + +const DELIVERABLE_CHANNEL_TYPES: readonly string[] = ['email', 'sms']; export type ChannelType = (typeof CHANNEL_TYPES)[number]; export type ConversationStatus = (typeof STATUSES)[number]; export type AgentMode = (typeof AGENT_MODES)[number]; @@ -693,11 +695,11 @@ export class ConvService { }); if ( - channelType === 'email' && + DELIVERABLE_CHANNEL_TYPES.includes(channelType) && input.authorType !== 'end_user' && input.authorType !== 'system' ) { - await this.enqueueEmailOutbound(firstMsg!.id, conv.id, conv.channelId); + await this.enqueueOutboundDelivery(firstMsg!.id, conv.id, conv.channelId); } if (input.authorType === 'end_user' && !input.topicId) { @@ -899,17 +901,17 @@ export class ConvService { } if ( - conv.channelType === 'email' && + DELIVERABLE_CHANNEL_TYPES.includes(conv.channelType) && input.authorType !== 'end_user' && input.authorType !== 'system' ) { - await this.enqueueEmailOutbound(row!.id, conv.id, conv.channelId); + await this.enqueueOutboundDelivery(row!.id, conv.id, conv.channelId); } } return toMessageDto(row!); } - private async enqueueEmailOutbound( + private async enqueueOutboundDelivery( messageId: string, conversationId: string, channelId: string, diff --git a/packages/backend-core/src/modules/conv/threll/threll-adapter.ts b/packages/backend-core/src/modules/conv/threll/threll-adapter.ts index 4758ee9d..e5a6d2ae 100644 --- a/packages/backend-core/src/modules/conv/threll/threll-adapter.ts +++ b/packages/backend-core/src/modules/conv/threll/threll-adapter.ts @@ -37,6 +37,7 @@ import { buildWebhookUrl, verifyThrellSignature, } from './threll-client.service.ts'; +import { findOrCreateContactByPhone } from '../contact-by-phone.ts'; import { jsonbToStored } from './threll.service.ts'; import { ThrellToolBridge } from './threll-tool-bridge.ts'; @@ -435,6 +436,7 @@ export class ThrellAdapter implements ChannelAdapter { channel.orgId, customer?.number, customer?.name ?? undefined, + 'threll-webhook', ); const next = await tx.execute<{ next: number } & Record>( @@ -488,49 +490,6 @@ export class ThrellAdapter implements ChannelAdapter { } } -async function findOrCreateContactByPhone( - tx: Db | Tx, - orgId: string, - phone: string | undefined, - name: string | undefined, -): Promise { - if (!phone) return null; - const existing = await tx - .select() - .from(schema.convContacts) - .where(and(eq(schema.convContacts.orgId, orgId), eq(schema.convContacts.phone, phone))) - .limit(1); - if (existing[0]) return existing[0]; - - const externalId = `phone:${phone}`; - const eu = await tx - .select() - .from(schema.endUsers) - .where(and(eq(schema.endUsers.orgId, orgId), eq(schema.endUsers.externalId, externalId))) - .limit(1); - let endUserId: string | null = eu[0]?.id ?? null; - if (!endUserId) { - try { - const [createdEu] = await tx - .insert(schema.endUsers) - .values({ orgId, externalId, phone, name: name ?? null, metadata: { source: 'threll-webhook' } }) - .returning(); - endUserId = createdEu?.id ?? null; - } catch { - const reread = await tx - .select() - .from(schema.endUsers) - .where(and(eq(schema.endUsers.orgId, orgId), eq(schema.endUsers.externalId, externalId))) - .limit(1); - endUserId = reread[0]?.id ?? null; - } - } - const [contact] = await tx - .insert(schema.convContacts) - .values({ orgId, phone, name: name ?? null, endUserId, metadata: {} }) - .returning(); - return contact ?? null; -} function headerOne( headers: Record, diff --git a/packages/backend-core/src/modules/conv/vapi/vapi-adapter.ts b/packages/backend-core/src/modules/conv/vapi/vapi-adapter.ts index da608e3b..5bee99c8 100644 --- a/packages/backend-core/src/modules/conv/vapi/vapi-adapter.ts +++ b/packages/backend-core/src/modules/conv/vapi/vapi-adapter.ts @@ -32,6 +32,7 @@ import { VapiClientService, verifyVapiWebhookSecret, } from './vapi-client.service.ts'; +import { findOrCreateContactByPhone } from '../contact-by-phone.ts'; import { jsonbToStored } from './vapi.service.ts'; import { VapiToolBridge, type VapiToolCall } from './vapi-tool-bridge.ts'; import { @@ -554,7 +555,7 @@ export class VapiAdapter implements ChannelAdapter { if (existing[0]) return existing[0]; const phone = customer?.number; - const contact = await findOrCreateContactByPhone(tx, channel.orgId, phone, customer?.name); + const contact = await findOrCreateContactByPhone(tx, channel.orgId, phone, customer?.name, 'vapi-webhook'); const next = await tx.execute<{ next: number } & Record>( sql`SELECT conv_next_display_id(${channel.orgId}) AS next`, @@ -607,61 +608,6 @@ export class VapiAdapter implements ChannelAdapter { } } -async function findOrCreateContactByPhone( - tx: Db | Tx, - orgId: string, - phone: string | undefined, - name: string | undefined, -): Promise { - if (!phone) return null; - const existing = await tx - .select() - .from(schema.convContacts) - .where(and(eq(schema.convContacts.orgId, orgId), eq(schema.convContacts.phone, phone))) - .limit(1); - if (existing[0]) return existing[0]; - - const externalId = `phone:${phone}`; - const eu = await tx - .select() - .from(schema.endUsers) - .where(and(eq(schema.endUsers.orgId, orgId), eq(schema.endUsers.externalId, externalId))) - .limit(1); - let endUserId: string | null = eu[0]?.id ?? null; - if (!endUserId) { - try { - const [createdEu] = await tx - .insert(schema.endUsers) - .values({ - orgId, - externalId, - phone, - name: name ?? null, - metadata: { source: 'vapi-webhook' }, - }) - .returning(); - endUserId = createdEu?.id ?? null; - } catch { - const reread = await tx - .select() - .from(schema.endUsers) - .where(and(eq(schema.endUsers.orgId, orgId), eq(schema.endUsers.externalId, externalId))) - .limit(1); - endUserId = reread[0]?.id ?? null; - } - } - const [contact] = await tx - .insert(schema.convContacts) - .values({ - orgId, - phone, - name: name ?? null, - endUserId, - metadata: {}, - }) - .returning(); - return contact ?? null; -} function headerOne( headers: Record, diff --git a/packages/backend-core/src/modules/outreach/outreach.service.test.ts b/packages/backend-core/src/modules/outreach/outreach.service.test.ts index cc9f3681..36f4c6be 100644 --- a/packages/backend-core/src/modules/outreach/outreach.service.test.ts +++ b/packages/backend-core/src/modules/outreach/outreach.service.test.ts @@ -9,7 +9,7 @@ import { createDb, runMigrations, schema } from '@getmunin/db'; import { eq, sql } from 'drizzle-orm'; import { randomUUID } from 'node:crypto'; import { ConflictException } from '@nestjs/common'; -import { OutreachService, OutreachInvalidError } from './outreach.service.ts'; +import { OutreachService, OutreachInvalidError, SMS_DRAFT_MAX_CHARS } from './outreach.service.ts'; import { CrmService } from '../crm/crm.service.ts'; import { DefaultQuotasService } from '../../common/quotas/quotas.service.ts'; import { ConvService } from '../conv/conv.service.ts'; @@ -1576,4 +1576,191 @@ const skipReason = TEST_URL expect(approved.status).toBe('sent'); }); }); + + describe('sms campaigns', () => { + let smsChannelId: string; + let smsContactId: string; + + beforeEach(async () => { + const [channel] = await db + .insert(schema.convChannels) + .values({ + orgId, + type: 'sms', + vendor: 'twilio', + name: 'outreach-sms', + active: true, + config: { accountSid: 'AC_test', encryptedAuthToken: 'fake', fromNumber: '+15550001111' }, + }) + .returning(); + smsChannelId = channel!.id; + + const [crm] = await db + .insert(schema.crmContacts) + .values({ + orgId, + name: 'Text Me', + email: 'text@example.com', + phone: '+14155558888', + consentLawfulBasis: 'consent', + doNotContact: false, + }) + .returning(); + smsContactId = crm!.id; + }); + + function humanActor(): ActorIdentity { + return new ActorIdentity( + 'user', + 'usr_outreach_sms_test', + orgId, + ['*'], + ['admin'], + undefined, + undefined, + undefined, + 'usr_outreach_sms_test', + ); + } + + async function campaign(overrides: { unsubscribeRequired?: boolean; ctaUrl?: string } = {}) { + return run(() => + svc.createCampaign({ + name: `sms-${Math.round(overrides.unsubscribeRequired === false ? 1 : 0)}-${smsChannelId.slice(-6)}`, + brief: 'b', + segmentId, + channelId: smsChannelId, + enabled: true, + ...overrides, + }), + ); + } + + it('accepts an sms channel and reports the phone as the delivery destination', async () => { + const c = await campaign(); + const p = await run(() => + svc.proposeInitial({ + campaignId: c.id, + contactId: smsContactId, + draftBody: 'Hei! Vi har lansert noe du ba om.', + }), + ); + expect(p.draftSubject).toBeNull(); + expect(p.delivery?.channelType).toBe('sms'); + expect(p.delivery?.destination).toBe('+14155558888'); + expect(p.delivery?.appendsUnsubscribe).toBe(false); + }); + + it('rejects a draft longer than the SMS cap', async () => { + const c = await campaign(); + await expect( + run(() => + svc.proposeInitial({ + campaignId: c.id, + contactId: smsContactId, + draftBody: 'x'.repeat(SMS_DRAFT_MAX_CHARS + 1), + }), + ), + ).rejects.toThrow(/SMS drafts are capped/); + }); + + it('rejects a contact with no phone number', async () => { + const [phoneless] = await db + .insert(schema.crmContacts) + .values({ + orgId, + name: 'No Phone', + email: 'nophone-sms@example.com', + consentLawfulBasis: 'consent', + doNotContact: false, + }) + .returning(); + const c = await campaign(); + await expect( + run(() => + svc.proposeInitial({ campaignId: c.id, contactId: phoneless!.id, draftBody: 'Hi.' }), + ), + ).rejects.toBeInstanceOf(OutreachInvalidError); + }); + + it('an agent cannot approve an sms proposal', async () => { + const c = await campaign(); + const p = await run(() => + svc.proposeInitial({ campaignId: c.id, contactId: smsContactId, draftBody: 'Hi.' }), + ); + await expect( + run(() => svc.approveProposal(p.id, { publicBaseUrl: 'https://test.local' })), + ).rejects.toThrow(/signed-in person in the Munin dashboard/); + }); + + it('a human approval sends the text, appends the opt-out line, and queues a delivery', async () => { + const c = await campaign(); + const p = await run(() => + svc.proposeInitial({ + campaignId: c.id, + contactId: smsContactId, + draftBody: 'Hei! Vi har lansert noe du ba om.', + }), + ); + const approved = await runAs(humanActor(), () => + svc.approveProposal(p.id, { publicBaseUrl: 'https://test.local' }), + ); + expect(approved.status).toBe('sent'); + expect(approved.conversationId).toBeTruthy(); + + const messages = await db + .select({ id: schema.convMessages.id, body: schema.convMessages.body }) + .from(schema.convMessages) + .where(eq(schema.convMessages.conversationId, approved.conversationId!)); + expect(messages).toHaveLength(1); + expect(messages[0]!.body).toBe('Hei! Vi har lansert noe du ba om. Reply STOP to opt out.'); + expect(messages[0]!.body).not.toContain(']('); + + const deliveries = await db + .select({ status: schema.convMessageDeliveries.status }) + .from(schema.convMessageDeliveries) + .where(eq(schema.convMessageDeliveries.messageId, messages[0]!.id)); + expect(deliveries).toHaveLength(1); + expect(deliveries[0]!.status).toBe('queued'); + + const convs = await db + .select({ + agentMode: schema.convConversations.agentMode, + outreachCampaignId: schema.convConversations.outreachCampaignId, + }) + .from(schema.convConversations) + .where(eq(schema.convConversations.id, approved.conversationId!)); + expect(convs[0]!.agentMode).toBe('draft_only'); + expect(convs[0]!.outreachCampaignId).toBe(c.id); + }); + + it('omits the opt-out line when the campaign does not require unsubscribe', async () => { + const c = await campaign({ unsubscribeRequired: false }); + const p = await run(() => + svc.proposeInitial({ campaignId: c.id, contactId: smsContactId, draftBody: 'Kort melding.' }), + ); + const approved = await runAs(humanActor(), () => + svc.approveProposal(p.id, { publicBaseUrl: 'https://test.local' }), + ); + const messages = await db + .select({ body: schema.convMessages.body }) + .from(schema.convMessages) + .where(eq(schema.convMessages.conversationId, approved.conversationId!)); + expect(messages[0]!.body).toBe('Kort melding.'); + }); + + it('rejects sequence steps on an sms campaign', async () => { + await expect( + run(() => + svc.createCampaign({ + name: 'sms-with-steps', + brief: 'b', + segmentId, + channelId: smsChannelId, + sequenceSteps: [{ waitDays: 3, brief: 'bump' }], + }), + ), + ).rejects.toBeInstanceOf(OutreachInvalidError); + }); + }); }); diff --git a/packages/backend-core/src/modules/outreach/outreach.service.ts b/packages/backend-core/src/modules/outreach/outreach.service.ts index f61f17aa..b4eeba0f 100644 --- a/packages/backend-core/src/modules/outreach/outreach.service.ts +++ b/packages/backend-core/src/modules/outreach/outreach.service.ts @@ -16,6 +16,7 @@ import { randomUUID } from 'node:crypto'; import { ConvService } from '../conv/conv.service.ts'; import { CrmService, CrmInvalidError } from '../crm/crm.service.ts'; import { EmailService } from '../conv/email/email.service.ts'; +import { findOrCreateContactByPhone } from '../conv/contact-by-phone.ts'; import { VapiClientService } from '../conv/vapi/vapi-client.service.ts'; import { jsonbToStored as vapiJsonbToStored } from '../conv/vapi/vapi.service.ts'; @@ -31,6 +32,8 @@ export type ProposalKind = (typeof PROPOSAL_KINDS)[number]; export const CHANNELS_REQUIRING_HUMAN_APPROVAL: readonly string[] = ['voice', 'sms']; +export const SMS_DRAFT_MAX_CHARS = 480; + export const PROPOSAL_STATUSES = [ 'pending', 'approved', @@ -426,9 +429,14 @@ export class OutreachService { `contact ${input.contactId} is suppressed or has no recorded lawful basis`, ); } - if (channel.type === 'voice' && !contact.phone) { + if ((channel.type === 'voice' || channel.type === 'sms') && !contact.phone) { throw new OutreachInvalidError( - `contact ${input.contactId} has no phone number — required for voice campaigns`, + `contact ${input.contactId} has no phone number — required for ${channel.type} campaigns`, + ); + } + if (channel.type === 'sms' && input.draftBody.length > SMS_DRAFT_MAX_CHARS) { + throw new OutreachInvalidError( + `draftBody is ${input.draftBody.length} characters — SMS drafts are capped at ${SMS_DRAFT_MAX_CHARS} so a message stays within a few billable segments`, ); } const [contacted] = await ctx.db @@ -787,6 +795,10 @@ export class OutreachService { return this.approveInitialVoice(proposal, campaign, contact, channel, actor); } + if (channel.type === 'sms') { + return this.approveInitialSms(proposal, campaign, contact, actor); + } + if (!contact.email) { throw new OutreachInvalidError(`contact ${contact.id} has no email — cannot send`); } @@ -856,6 +868,82 @@ export class OutreachService { return toProposalDto(updated!, proposal.contact, proposal.campaign, proposal.delivery); } + private async approveInitialSms( + proposal: ProposalDto, + campaign: CampaignDto, + contact: { id: string; name: string | null; phone: string | null; endUserId?: string | null }, + actor: NonNullable['actor']>, + ): Promise { + const ctx = getCurrentContext(); + if (!contact.phone) { + throw new OutreachInvalidError(`contact ${contact.id} has no phone — cannot send`); + } + + const convContact = await ctx.db.transaction(async (tx) => { + const found = await findOrCreateContactByPhone( + tx, + actor.orgId, + contact.phone!, + contact.name ?? undefined, + 'outreach-sms', + ); + if (!found) throw new OutreachInvalidError(`could not resolve a contact for ${contact.phone}`); + return found; + }); + + const body = composeSmsOutreachBody({ + draftBody: proposal.draftBody, + ctaUrl: campaign.ctaUrl, + unsubscribeRequired: campaign.unsubscribeRequired, + }); + + const conversation = await this.conv.createConversation({ + channelId: campaign.channelId, + body, + contactId: convContact.id, + endUserId: contact.endUserId ?? undefined, + outreachCampaignId: campaign.id, + agentMode: 'draft_only', + authorType: 'agent', + authorId: actor.id, + }); + + const firstMessageId = conversation.messages[0]?.id ?? null; + + const [updated] = await ctx.db + .update(schema.outreachProposals) + .set({ + status: 'sent', + conversationId: conversation.id, + sentMessageId: firstMessageId, + sentAt: new Date(), + decidedByActorType: actor.type, + decidedByActorId: actor.id, + decidedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(schema.outreachProposals.id, proposal.id)) + .returning(); + + await ctx.db + .update(schema.crmContacts) + .set({ lastContactedAt: new Date(), updatedAt: new Date() }) + .where(eq(schema.crmContacts.id, contact.id)); + + await this.webhooks.emit({ + type: 'outreach.proposal.sent', + payload: { + proposalId: proposal.id, + campaignId: campaign.id, + contactId: contact.id, + conversationId: conversation.id, + messageId: firstMessageId, + }, + }); + + return toProposalDto(updated!, proposal.contact, proposal.campaign, proposal.delivery); + } + private async approveInitialVoice( proposal: ProposalDto, campaign: CampaignDto, @@ -1502,9 +1590,10 @@ export class OutreachService { const channel = rows[0]; if (!channel) throw new OutreachInvalidError(`channel ${channelId} does not exist`); if (channel.type === 'email') return channel; + if (channel.type === 'sms') return channel; if (channel.type === 'voice' && channel.vendor === 'vapi') return channel; throw new OutreachInvalidError( - `channel ${channelId} is ${channel.type}:${channel.vendor}; outreach campaigns require an email or voice:vapi channel`, + `channel ${channelId} is ${channel.type}:${channel.vendor}; outreach campaigns require an email, sms, or voice:vapi channel`, ); } @@ -1851,6 +1940,17 @@ function buildUnsubscribeUrl(input: { return `${base}/v1/outreach/unsubscribe?token=${encodeURIComponent(token)}`; } +function composeSmsOutreachBody(input: { + draftBody: string; + ctaUrl: string | null; + unsubscribeRequired: boolean; +}): string { + let body = input.draftBody.trim(); + if (input.ctaUrl) body += ` ${input.ctaUrl}`; + if (input.unsubscribeRequired) body += ' Reply STOP to opt out.'; + return body; +} + function composeOutreachBody(input: { draftBody: string; ctaUrl: string | null; diff --git a/packages/backend-core/src/modules/outreach/outreach.tools.ts b/packages/backend-core/src/modules/outreach/outreach.tools.ts index 6fd0af28..e5dea8e9 100644 --- a/packages/backend-core/src/modules/outreach/outreach.tools.ts +++ b/packages/backend-core/src/modules/outreach/outreach.tools.ts @@ -102,13 +102,15 @@ const ProposeInitialInput = z.object({ .string() .max(300) .optional() - .describe('Required for email campaigns; omit for voice campaigns where the call has no subject.'), + .describe( + 'Required for email campaigns; omit for SMS and voice campaigns, which have no subject.', + ), draftBody: z .string() .min(1) .max(20_000) .describe( - 'For email campaigns: the email body. For voice campaigns: the opening line / talking-points the AI agent should use when the call connects.', + 'For email campaigns: the email body. For SMS campaigns: the text message, capped at 480 characters, plain text — no markdown, and no opt-out line, which Munin appends. For voice campaigns: the opening line / talking-points the AI agent should use when the call connects.', ), evidence: z.record(z.string(), z.unknown()).optional(), proposedSendAt: z.string().datetime().optional(), @@ -246,7 +248,7 @@ export class OutreachAdminTools { name: 'outreach_create_campaign', title: 'Outreach: Create campaign', description: - 'Create an outbound-campaign definition. Operators write `brief` as a one-paragraph human description of intent (the curator personalises per contact from this). `segmentId` chooses the audience; the curator calls `crm_list_contacts_in_segment` (which always enforces suppression+consent floor) to materialize it. `channelId` must reference an email channel. New campaigns default `enabled: false` so nothing sends until you flip it on. Automation is opt-in per behavior: `autoDraftInitial` defaults false (the weekly curator does not draft first-touch emails until you set it true — draft manually otherwise), while `autoDraftReplies` defaults true (replies to inbound prospect messages are auto-drafted for review). Optional `sequenceSteps` (email campaigns only) defines a follow-up sequence — each step is a wait period plus a drafting brief; defining steps on an enabled campaign opts it into daily follow-up drafting for threads with no reply.', + 'Create an outbound-campaign definition. Operators write `brief` as a one-paragraph human description of intent (the curator personalises per contact from this). `segmentId` chooses the audience; the curator calls `crm_list_contacts_in_segment` (which always enforces suppression+consent floor) to materialize it. `channelId` must reference an email, SMS, or voice channel; approving a proposal on an SMS or voice campaign is restricted to a signed-in person in the Munin dashboard. New campaigns default `enabled: false` so nothing sends until you flip it on. Automation is opt-in per behavior: `autoDraftInitial` defaults false (the weekly curator does not draft first-touch emails until you set it true — draft manually otherwise), while `autoDraftReplies` defaults true (replies to inbound prospect messages are auto-drafted for review). Auto-sending a reply is not an option on any campaign: conversations created by an approved proposal are always set to `draft_only`, whatever the channel default says, so a prospect never receives an unreviewed reply. Optional `sequenceSteps` (email campaigns only) defines a follow-up sequence — each step is a wait period plus a drafting brief; defining steps on an enabled campaign opts it into daily follow-up drafting for threads with no reply.', audiences: ['admin'], scopes: ['outreach:write'], input: CreateCampaignInput, diff --git a/packages/backend-core/src/modules/outreach/skills/draft-initial-sms.md b/packages/backend-core/src/modules/outreach/skills/draft-initial-sms.md new file mode 100644 index 00000000..97b62037 --- /dev/null +++ b/packages/backend-core/src/modules/outreach/skills/draft-initial-sms.md @@ -0,0 +1,45 @@ +--- +title: "Outreach: Draft an initial text message" +description: Draft first-touch SMS outreach for a campaign running on an SMS channel. One pending proposal per (campaign, contact), capped at 480 characters, plain text. Only a signed-in person in the Munin dashboard can approve a text — an agent never sends one. +audiences: [admin] +--- + +# Draft an initial text message + +Same shape as `skill://outreach/draft-initial-email`, with three differences that matter enough to be their own skill: a text is far shorter, it costs money per segment, and **you cannot send one**. + +**Approving a text is a dashboard-only action.** `outreach_approve_proposal` refuses any caller that is not a signed-in dashboard user — agents, admin API keys, the Slack button. Draft, file the proposal, tell the operator it is waiting in the dashboard inbox, and stop. Don't retry, don't look for another tool, and don't ask for a credential that would work. `outreach_revise_proposal`, `outreach_withdraw_proposal` and `outreach_dismiss_proposal` all still work on these — none of them send anything. + +## The pass + +1. **List campaigns** with `outreach_list_campaigns` and keep the enabled ones. Check the campaign's channel is SMS before drafting text-shaped copy — `outreach_list_proposals` on an existing proposal shows `delivery.channelType`, or read the channel off `conv_list_channels`. +2. **Materialise the audience** with `crm_list_contacts_in_segment(campaign.segmentId)`. Already filtered for suppression and lawful basis. Anyone who ever replied `STOP` to a text is suppressed automatically and will not appear. +3. **Skip contacts with no `phone`.** `outreach_propose_initial` rejects them, and a rejection you could have predicted is a wasted call. +4. **Dedupe** via `outreach_list_proposals({ kind: "initial", campaignId, contactId })` exactly as the email pass does. +5. **Draft** (rules below) and file with `outreach_propose_initial({ campaignId, contactId, draftBody, evidence })`. No `draftSubject` — a text has no subject. +6. **Stop.** + +## Writing the text + +- **480 characters hard cap**, enforced by the service. That is roughly three billable segments; a single segment is 160 characters of GSM-7 (70 if you use emoji or characters outside the GSM alphabet, which silently switches the whole message to UCS-2 and triples the cost). Aim for one segment. Every character is someone's money. +- **Plain text only.** No markdown — `**bold**` and `[link](url)` arrive literally as asterisks and brackets. Write the URL bare if you need one. +- **Do not write an opt-out line.** Munin appends `Reply STOP to opt out.` at approve time when the campaign requires it, and appends the campaign CTA URL if one is set. Both are counted against the recipient's screen, not yours, so leave room. +- **Say who you are in the first clause.** A text from an unknown number with no sender is indistinguishable from spam, and unlike email there is no From line to check. +- **One ask.** There is no room for context-setting, a value proposition and a call to action. Pick the ask. +- **Match the recipient's language.** A Norwegian contact gets Norwegian; don't send translated English. + +Good: `Hei Jane — Kjell fra Munin. Du ba om beskjed når vi lanserte selvbetjent onboarding. Den er live nå. Vil du ha en rask demo?` + +Bad: `Hi Jane! 👋 **Great news** from the team at Munin — we've *just* shipped our new self-serve onboarding flow, which we think you'll love based on our last conversation. Check it out here: [Munin onboarding](https://…) and let us know what you think! Reply STOP to unsubscribe.` — emoji forces UCS-2, markdown arrives raw, and it hand-writes an opt-out line the system will append again. + +## What happens on approval + +A person approves in the dashboard. Munin composes the final body (draft + CTA + opt-out line), creates an outbound conversation on the campaign's SMS channel in `draft_only` mode, and queues the message for delivery. A reply from the prospect threads into that same conversation, so `outreach_propose_reply` works there — see `skill://outreach/draft-reply-email`, which applies to texts too. + +Follow-up sequences are email-only. An SMS campaign cannot carry `sequenceSteps`, and `outreach_propose_followup` rejects a text conversation. One touch, then the reply flow. + +## Related + +- `skill://outreach/draft-initial-email` — the same pass for email, where you may draft at length. +- `skill://outreach/review-proposals` — what the operator does with what you filed, and why you cannot do it for them. +- `skill://conv/setup-voice-sms-channel` — configuring the number, including `defaultAgentMode` and how STOP suppression works.