Skip to content

feat(conv): thread inbound messages and honour SMS opt-out keywords - #700

Merged
kmonsoe merged 4 commits into
mainfrom
feat/outreach-sms-channel
Jul 30, 2026
Merged

feat(conv): thread inbound messages and honour SMS opt-out keywords#700
kmonsoe merged 4 commits into
mainfrom
feat/outreach-sms-channel

Conversation

@kmonsoe

@kmonsoe kmonsoe commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

PR 4a of the outbound-calling series. Stacked on #699.

I split PR 4 in two. This one makes the SMS channel behave correctly; the follow-up builds SMS outreach campaigns on top. Everything here is a prerequisite for outreach and stands on its own, so it's better reviewed apart from campaign plumbing.

Inbound messages didn't thread

ChannelIngestService.ingestOne inserted a new conv_conversations row for every inbound message. Two texts from the same person were two unrelated conversations. The existing tests didn't catch it because they only ever post one message per number, or post the same MessageSid twice and assert the dedupe.

That's wrong for a conversation product generally, and it's a hard blocker for SMS outreach specifically: a prospect's reply would land on a conversation with no outreachCampaignId, so autoDraftReplies never fires and outreach_propose_reply rejects it.

Inbound messages now land in that contact's most recent open conversation on the same channel, reopening it when it was snoozed. A closed conversation still starts a fresh thread. Threading is scoped by channelId, not channel type — an SMS never joins an email or widget conversation, and two Twilio numbers stay separate, because from the customer's side those are different places they wrote to.

STOP didn't do anything

There was no opt-out handling anywhere in the codebase. An inbound SMS whose entire body is an opt-out keyword now suppresses the CRM contact holding that number: doNotContact set, unsubscribedAt stamped, and a crm_activities note recording why. Keywords: STOP, STOPP, SLUTT, AVMELD, UNSUBSCRIBE, END, QUIT, CANCEL, STOPALL — case-insensitive, trailing punctuation ignored.

The message is still ingested, so the conversation reads truthfully rather than silently swallowing what they sent. Whole-body only: "can you cancel my order?" is an ordinary message, because the false-positive cost is silently dropping a customer you're mid-conversation with.

Suppressed contacts already fall out of crm_list_contacts_in_segment, so they leave every outreach audience with no further wiring. Honouring STOP is a legal requirement in the US and expected in the EU, so it lands before SMS outreach rather than alongside it.

defaultAgentMode was unreachable for SMS

Threading the channel's defaultAgentMode onto new conversations exposed a gap: it's a shared conv_channels column, but only the email path could ever write it. conv_configure_channel, the vendor tools, the /v1 SMS endpoints and the dashboard dialogs had no reference to it, so every SMS number sat on the auto default and auto-replied to inbound texts — exactly wrong for a number you only run campaigns from.

It's now settable end to end: conv_configure_channel, the Twilio and MessageBird tools and endpoints, and a select in both dialogs matching the one email already had, in both locales. 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's no Munin reply for the mode to govern.

While in there: new conversations from the generic ingest path now inherit defaultAgentMode at all. It was being dropped in favour of the column default, where the email adapter has always honoured it.

Also fixed

conv_create_channel's description 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, registered adapters. That's a wrong statement in a published tool description, so it now points at conv_configure_channel instead.

Tests

Integration, through the real signed Twilio webhook: two messages from one number share a conversation; STOP suppresses the matching CRM contact and writes the activity row; a sentence containing "stop" leaves them subscribed; a draft_only channel opens inbound conversations in draft_only.

Unit coverage on the keyword matcher for case, padding, trailing punctuation, the Norwegian variants a Nordic recipient would actually send, and four sentences that must not match.

Full @getmunin/backend-core suite green at 1312 tests; pnpm typecheck clean across 31 tasks; en/nb key parity checked. skill://conv/setup-voice-sms-channel documents threading, opt-out, and the new setting, since none of it is discoverable from the tool list.

🤖 Generated with Claude Code

https://claude.ai/code/session_011HwhYb3yU3qnGjtFRodQxm

@kmonsoe
kmonsoe force-pushed the feat/outreach-voice-approval-gate branch from f1670a5 to d879259 Compare July 30, 2026 10:36
kmonsoe and others added 4 commits July 30, 2026 12:37
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HwhYb3yU3qnGjtFRodQxm
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HwhYb3yU3qnGjtFRodQxm
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HwhYb3yU3qnGjtFRodQxm
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HwhYb3yU3qnGjtFRodQxm
@kmonsoe
kmonsoe force-pushed the feat/outreach-sms-channel branch from 94dfff1 to 81c6a87 Compare July 30, 2026 10:38
@kmonsoe
kmonsoe changed the base branch from feat/outreach-voice-approval-gate to main July 30, 2026 10:38
@kmonsoe
kmonsoe merged commit c5a05c5 into main Jul 30, 2026
3 checks passed
@kmonsoe
kmonsoe deleted the feat/outreach-sms-channel branch July 31, 2026 10:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant