feat(conv): thread inbound messages and honour SMS opt-out keywords - #700
Merged
Conversation
kmonsoe
force-pushed
the
feat/outreach-voice-approval-gate
branch
from
July 30, 2026 10:36
f1670a5 to
d879259
Compare
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
force-pushed
the
feat/outreach-sms-channel
branch
from
July 30, 2026 10:38
94dfff1 to
81c6a87
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.ingestOneinserted a newconv_conversationsrow 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 sameMessageSidtwice 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, soautoDraftRepliesnever fires andoutreach_propose_replyrejects it.Inbound messages now land in that contact's most recent
openconversation on the same channel, reopening it when it wassnoozed. Aclosedconversation still starts a fresh thread. Threading is scoped bychannelId, 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:
doNotContactset,unsubscribedAtstamped, and acrm_activitiesnote 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.defaultAgentModewas unreachable for SMSThreading the channel's
defaultAgentModeonto new conversations exposed a gap: it's a sharedconv_channelscolumn, but only the email path could ever write it.conv_configure_channel, the vendor tools, the/v1SMS endpoints and the dashboard dialogs had no reference to it, so every SMS number sat on theautodefault 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
defaultAgentModeat 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 "thevoiceandsmschannel 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 atconv_configure_channelinstead.Tests
Integration, through the real signed Twilio webhook: two messages from one number share a conversation;
STOPsuppresses the matching CRM contact and writes the activity row; a sentence containing "stop" leaves them subscribed; adraft_onlychannel opens inbound conversations indraft_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-coresuite green at 1312 tests;pnpm typecheckclean across 31 tasks; en/nb key parity checked.skill://conv/setup-voice-sms-channeldocuments 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