feat: opt-out handling with confirmation flow#10
Open
pdsullivan wants to merge 1 commit into
Open
Conversation
Wire up Linq's new message.opt_out / message.opt_in webhook events and
add a 2-turn confirmation state machine so a single STOP signal doesn't
silently mute someone.
State machine (one path, two triggers):
- message.opt_out webhook fires (currently for literal STOP/UNSUBSCRIBE/etc)
-> idempotent setPending(handle, source=webhook) (24h TTL)
-> bot sends Claude-voiced confirmation question
- Soft-intent layer (PR B) will plug into the same setPending() with
source=soft_intent
- Next message from that handle runs through getOptOutConfirmationAction
(Haiku classifier: confirms | denies | unrelated)
-> confirms: confirmOptOut() persists OPT#<handle> with no TTL,
Claude-voiced ack including required "text START" phrase
-> denies | unrelated: clearOpt(), fall through to normal flow
- No reply within 24h: pending record TTLs out silently, default state
preserved
Race protection:
- Linq fires both message.received and message.opt_out for keyword STOPs
~7ms apart. The message.received handler now skips Claude inference for
any of the standard opt-out keywords (STOP/STOPALL/UNSUBSCRIBE/CANCEL/
END/QUIT) so the webhook drives the response. setPending() also uses a
conditional put (attribute_not_exists) so duplicate signals collapse to
a single record.
Ingress check at the top of message.received: if OPT#<handle> exists with
status=opted_out, return early — no Claude call, no reply. Honors the
opted-out state across all chats (per-handle scope, not per-chat).
Opt-in handler clears the OPT record entirely and sends a brief
welcome-back text.
Opt-out event payload shape is currently undocumented; types are
permissive and the handler logs the full payload so we can refine once
Linq publishes the schema.
Soft-intent classifier (run on every inbound message) lands in a
follow-up PR — this PR is just the webhook wiring + state machine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
Summary
Wire up Linq's new
message.opt_out/message.opt_inwebhook events with a 2-turn confirmation flow so a single STOP doesn't silently mute someone.State machine:
message.opt_outwebhook (or soft-intent in PR B)setPending(24h TTL) + Claude-voiced confirmation questionconfirmsconfirmOptOut(no TTL) + Claude-voiced ack with required "text START" phrasedenies/unrelatedOR 24h passesmessage.opt_inwebhookRace protection. From earlier prod logs we confirmed Linq fires both
message.receivedandmessage.opt_outfor keyword STOPs ~7ms apart. Two layers of dedup:message.receivedhandler skips Claude inference for any of the standard opt-out keywords (STOP, STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT) — lets the webhook drive the response.setPending()uses a DynamoDB conditional put (attribute_not_exists) so duplicate signals collapse to a single record. First writer wins.Ingress check at the top of
message.received: ifOPT#<handle>isopted_out, return early — no Claude call, no reply. Honors per-handle (not per-chat) opt-out, matching carrier semantics.Schema. New unified record
OPT#<handle>withstatus: 'pending' | 'opted_out'and an optionalttl(set on pending, removed on confirmation).Notes
apidocs.linqapp.com. Types are permissive andhandler.tslogs the full payload so we can tighten them once Linq publishes the schema.setPending()withsource: 'soft_intent'.🤖 Generated with Claude Code