Skip to content

feat: opt-out handling with confirmation flow#10

Open
pdsullivan wants to merge 1 commit into
mainfrom
feat/opt-out-handling
Open

feat: opt-out handling with confirmation flow#10
pdsullivan wants to merge 1 commit into
mainfrom
feat/opt-out-handling

Conversation

@pdsullivan

Copy link
Copy Markdown
Contributor

Summary

Wire up Linq's new message.opt_out / message.opt_in webhook events with a 2-turn confirmation flow so a single STOP doesn't silently mute someone.

State machine:

State Trigger Action
Default Process normally
→ Pending message.opt_out webhook (or soft-intent in PR B) Idempotent setPending (24h TTL) + Claude-voiced confirmation question
Pending → Opted-out Next reply classifies as confirms confirmOptOut (no TTL) + Claude-voiced ack with required "text START" phrase
Pending → Default Reply is denies / unrelated OR 24h passes Clear pending, resume normal
Opted-out → Default message.opt_in webhook Clear record + welcome-back text

Race protection. From earlier prod logs we confirmed Linq fires both message.received and message.opt_out for keyword STOPs ~7ms apart. Two layers of dedup:

  1. The message.received handler skips Claude inference for any of the standard opt-out keywords (STOP, STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT) — lets the webhook drive the response.
  2. 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: if OPT#<handle> is opted_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> with status: 'pending' | 'opted_out' and an optional ttl (set on pending, removed on confirmation).

Notes

  • Opt-out webhook payload shape is currently undocumented in apidocs.linqapp.com. Types are permissive and handler.ts logs the full payload so we can tighten them once Linq publishes the schema.
  • Soft-intent classifier (catches phrases like "please stop messaging me" that Linq's keyword detection misses) lands in a follow-up PR. It plugs into the same setPending() with source: 'soft_intent'.

🤖 Generated with Claude Code

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>
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