From c8e5f5fc239a9b083546e8642b4ed9bc4ddce945 Mon Sep 17 00:00:00 2001 From: Patrick Sullivan Date: Mon, 27 Apr 2026 10:34:48 -0500 Subject: [PATCH] feat: opt-out handling with confirmation flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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# 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# 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) --- src/claude/client.ts | 87 +++++++++++++++++++++++++++++++++++ src/index.ts | 56 ++++++++++++++++++++++- src/state/optOut.ts | 100 +++++++++++++++++++++++++++++++++++++++++ src/webhook/handler.ts | 39 +++++++++++++++- src/webhook/types.ts | 28 ++++++++++++ 5 files changed, 307 insertions(+), 3 deletions(-) create mode 100644 src/state/optOut.ts diff --git a/src/claude/client.ts b/src/claude/client.ts index 8e16a9a..d940378 100644 --- a/src/claude/client.ts +++ b/src/claude/client.ts @@ -802,3 +802,90 @@ Examples: return { action: 'ignore' }; } } + +export type OptOutConfirmationAction = 'confirms' | 'denies' | 'unrelated'; + +/** + * Classify a reply to a pending opt-out confirmation question. + * Bias is `unrelated` on ambiguity — we'd rather keep the user subscribed + * than mistakenly opt them out. + */ +export async function getOptOutConfirmationAction(message: string): Promise { + const start = Date.now(); + try { + const response = await client.messages.create({ + model: 'claude-haiku-4-5', + max_tokens: 10, + system: `The user was just asked: "do you want me to stop messaging you?" + +Classify their reply as ONE of: +- "confirms" - they want to stop (yes, yeah, please do, stop, correct, that's right, etc.) +- "denies" - they want to keep going (no, nvm, keep going, ignore that, sorry false alarm, etc.) +- "unrelated" - they changed topic or didn't address it + +When unsure, answer "unrelated" — we'd rather keep messaging than wrongly opt someone out.`, + messages: [{ role: 'user', content: message }], + }); + + const answer = response.content[0].type === 'text' + ? response.content[0].text.toLowerCase().trim() + : 'unrelated'; + + let action: OptOutConfirmationAction = 'unrelated'; + if (answer.includes('confirms')) action = 'confirms'; + else if (answer.includes('denies')) action = 'denies'; + + console.log(`[claude] optOutConfirmation (${Date.now() - start}ms): "${message.substring(0, 40)}..." -> ${action}`); + return action; + } catch (error) { + console.error('[claude] optOutConfirmation error:', error); + return 'unrelated'; + } +} + +/** + * Generate a casual, on-brand "do you want me to stop?" question. + * Uses Haiku for speed/cost — this fires on every opt-out trigger. + */ +export async function getOptOutConfirmationPrompt(): Promise { + try { + const response = await client.messages.create({ + model: 'claude-haiku-4-5', + max_tokens: 60, + system: `Write a short, casual one-line text asking the user to confirm they want to stop receiving messages. Match this voice: casual, lowercase, gen-z texting style, NO emojis. Examples: "wait u want me to dip? lmk if so", "u trying to opt out? just lmk for sure". Just the message text, nothing else.`, + messages: [{ role: 'user', content: 'Generate the confirmation question.' }], + }); + if (response.content[0].type === 'text') { + return response.content[0].text.trim(); + } + } catch (error) { + console.error('[claude] optOutConfirmationPrompt error:', error); + } + return "wait u want me to stop messaging u? just lmk for sure"; +} + +/** + * Generate the final opt-out acknowledgment. Always includes the START + * resubscribe phrase since that's compliance-adjacent. + */ +export async function getOptOutAck(): Promise { + try { + const response = await client.messages.create({ + model: 'claude-haiku-4-5', + max_tokens: 60, + system: `Write a short, casual one-line text acknowledging that the user has opted out of receiving messages. The text MUST include the phrase "text START" (or "reply START") so they know how to come back. Match this voice: casual, lowercase, gen-z texting style, NO emojis. Examples: "got it, ill dip. text START anytime to come back", "ok no worries, im out. reply START whenever". Just the message, nothing else.`, + messages: [{ role: 'user', content: 'Generate the acknowledgment.' }], + }); + if (response.content[0].type === 'text') { + const text = response.content[0].text.trim(); + // Defensive: enforce the START phrase if Claude forgot it + if (!/start/i.test(text)) { + return `${text} text START anytime to come back`; + } + return text; + } + } catch (error) { + console.error('[claude] optOutAck error:', error); + } + return "ok ill dip. text START anytime to come back"; +} diff --git a/src/index.ts b/src/index.ts index bcdc0e7..4b8cc0d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,8 +2,9 @@ import 'dotenv/config'; import express from 'express'; import { createWebhookHandler } from './webhook/handler.js'; import { sendMessage, markAsRead, startTyping, sendReaction, shareContactCard, getChat, renameGroupChat, setGroupChatIcon, removeParticipant } from './linq/client.js'; -import { chat, getGroupChatAction, getTextForEffect, generateImage } from './claude/client.js'; +import { chat, getGroupChatAction, getTextForEffect, generateImage, getOptOutConfirmationAction, getOptOutConfirmationPrompt, getOptOutAck } from './claude/client.js'; import { getUserProfile, addMessage } from './state/conversation.js'; +import { getOptStatus, setPending, confirmOptOut, clearOpt, isOptOutKeyword } from './state/optOut.js'; // Clean up LLM response formatting quirks before sending function cleanResponse(text: string): string { @@ -41,10 +42,60 @@ app.get('/health', (_req, res) => { // Webhook endpoint for Linq Blue app.post( '/webhook', - createWebhookHandler(async (chatId, from, text, messageId, images, audio, incomingEffect, incomingReplyTo, service) => { + createWebhookHandler({ + onOptOut: async (handle, chatId) => { + // Idempotent — first writer wins, duplicate signals (e.g. webhook + soft-intent + // for the same STOP message) collapse into a single pending record. + const created = await setPending(handle, 'webhook'); + if (!created) { + console.log(`[main] opt-out: pending/opted_out already set for ${handle}, skipping confirmation`); + return; + } + if (!chatId) { + console.log(`[main] opt-out: no chat_id in payload, can't send confirmation`); + return; + } + const prompt = await getOptOutConfirmationPrompt(); + await sendMessage(chatId, prompt); + console.log(`[main] opt-out: sent confirmation to ${handle} in chat ${chatId}`); + }, + onOptIn: async (handle, chatId) => { + await clearOpt(handle); + if (chatId) { + await sendMessage(chatId, "ayy welcome back. lmk what's up"); + } + }, + onMessage: async (chatId, from, text, messageId, images, audio, incomingEffect, incomingReplyTo, service) => { const start = Date.now(); console.log(`[main] Processing message from ${from}`); + // Opt-out state machine — runs before any other processing. + const optStatus = await getOptStatus(from); + if (optStatus?.status === 'opted_out') { + console.log(`[main] ${from} is opted out — skipping`); + return; + } + if (optStatus?.status === 'pending') { + const action = await getOptOutConfirmationAction(text); + if (action === 'confirms') { + await confirmOptOut(from); + const ack = await getOptOutAck(); + await sendMessage(chatId, ack); + console.log(`[main] opt-out confirmed for ${from} via reply classifier`); + return; + } + // denies | unrelated → clear pending and fall through to normal flow + await clearOpt(from); + console.log(`[main] opt-out pending cleared for ${from} (reply: ${action})`); + } + + // Linq fires `message.opt_out` for these literal keywords; let that + // webhook drive the response so we don't double-process. + if (isOptOutKeyword(text)) { + console.log(`[main] Opt-out keyword "${text}" — deferring to message.opt_out webhook`); + return; + } + // Track message count for this chat const count = (chatMessageCount.get(chatId) || 0) + 1; chatMessageCount.set(chatId, count); @@ -229,6 +280,7 @@ app.post( } console.log(`[main] Reply sent to ${from}`); + }, }) ); diff --git a/src/state/optOut.ts b/src/state/optOut.ts new file mode 100644 index 0000000..b928e87 --- /dev/null +++ b/src/state/optOut.ts @@ -0,0 +1,100 @@ +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient, GetCommand, PutCommand, DeleteCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; + +const client = new DynamoDBClient({ region: 'us-east-1' }); +const docClient = DynamoDBDocumentClient.from(client); +const TABLE_NAME = process.env.DYNAMODB_TABLE_NAME || 'linq-blue-agent-example'; + +const PENDING_TTL_SECONDS = 24 * 60 * 60; + +// Standard SMS keyword set — Linq fires `message.opt_out` for these too, so +// we use this list to skip Claude inference and let the webhook drive state. +const OPT_OUT_KEYWORDS = new Set(['stop', 'stopall', 'unsubscribe', 'cancel', 'end', 'quit']); + +export type OptOutSource = 'webhook' | 'soft_intent'; +export type OptStatus = 'pending' | 'opted_out'; + +export interface OptRecord { + status: OptStatus; + source?: OptOutSource; + createdAt: number; + ttl?: number; +} + +export function isOptOutKeyword(text: string): boolean { + return OPT_OUT_KEYWORDS.has(text.trim().toLowerCase()); +} + +export async function getOptStatus(handle: string): Promise { + try { + const result = await docClient.send(new GetCommand({ + TableName: TABLE_NAME, + Key: { pk: `OPT#${handle}` }, + })); + if (!result.Item) return null; + return { + status: result.Item.status, + source: result.Item.source, + createdAt: result.Item.createdAt, + ttl: result.Item.ttl, + }; + } catch (error) { + console.error('[optOut] Error getting opt status:', error); + return null; + } +} + +// Returns true if a new pending record was created, false if one already existed +// (idempotent — first writer wins). +export async function setPending(handle: string, source: OptOutSource): Promise { + const now = Math.floor(Date.now() / 1000); + try { + await docClient.send(new PutCommand({ + TableName: TABLE_NAME, + Item: { + pk: `OPT#${handle}`, + status: 'pending', + source, + createdAt: now, + ttl: now + PENDING_TTL_SECONDS, + }, + ConditionExpression: 'attribute_not_exists(pk)', + })); + console.log(`[optOut] Pending set for ${handle} (source=${source})`); + return true; + } catch (error: unknown) { + if (error && typeof error === 'object' && 'name' in error && error.name === 'ConditionalCheckFailedException') { + return false; + } + console.error('[optOut] Error setting pending:', error); + return false; + } +} + +export async function confirmOptOut(handle: string): Promise { + const now = Math.floor(Date.now() / 1000); + try { + await docClient.send(new UpdateCommand({ + TableName: TABLE_NAME, + Key: { pk: `OPT#${handle}` }, + UpdateExpression: 'SET #s = :s, createdAt = :n REMOVE #t', + ExpressionAttributeNames: { '#s': 'status', '#t': 'ttl' }, + ExpressionAttributeValues: { ':s': 'opted_out', ':n': now }, + })); + console.log(`[optOut] Confirmed opt-out for ${handle}`); + } catch (error) { + console.error('[optOut] Error confirming opt-out:', error); + } +} + +export async function clearOpt(handle: string): Promise { + try { + await docClient.send(new DeleteCommand({ + TableName: TABLE_NAME, + Key: { pk: `OPT#${handle}` }, + })); + console.log(`[optOut] Cleared opt record for ${handle}`); + } catch (error) { + console.error('[optOut] Error clearing opt:', error); + } +} diff --git a/src/webhook/handler.ts b/src/webhook/handler.ts index e922a1f..0fbc5df 100644 --- a/src/webhook/handler.ts +++ b/src/webhook/handler.ts @@ -2,6 +2,8 @@ import { Request, Response } from 'express'; import { WebhookEvent, isMessageReceivedEvent, + isMessageOptOutEvent, + isMessageOptInEvent, extractTextContent, extractImageUrls, extractAudioUrls, @@ -16,7 +18,20 @@ export interface MessageHandler { (chatId: string, from: string, text: string, messageId: string, images: ExtractedMedia[], audio: ExtractedMedia[], incomingEffect?: MessageEffect, incomingReplyTo?: ReplyTo, service?: MessageService): Promise; } -export function createWebhookHandler(onMessage: MessageHandler) { +export interface OptHandler { + (handle: string, chatId: string | undefined, recipientPhone: string | undefined): Promise; +} + +export interface WebhookHandlers { + onMessage: MessageHandler; + onOptOut?: OptHandler; + onOptIn?: OptHandler; +} + +export function createWebhookHandler(handlers: WebhookHandlers | MessageHandler) { + const { onMessage, onOptOut, onOptIn } = typeof handlers === 'function' + ? { onMessage: handlers, onOptOut: undefined, onOptIn: undefined } + : handlers; // Bot numbers this agent runs on (comma-separated, supports multiple) // If not set, responds to messages to any number const botNumbers = process.env.LINQ_AGENT_BOT_NUMBERS?.split(',').map(p => p.trim()).filter(Boolean) || []; @@ -34,6 +49,28 @@ export function createWebhookHandler(onMessage: MessageHandler) { // Acknowledge receipt immediately res.status(200).json({ received: true }); + // Opt-out / opt-in events fire alongside `message.received` for keyword + // matches (e.g. STOP), and on their own for any future signals. + if (isMessageOptOutEvent(event) || isMessageOptInEvent(event)) { + console.log(`[webhook] Opt event payload:`, JSON.stringify(event.data)); + const data = event.data; + const handle = data?.from; + if (!handle) { + console.log(`[webhook] Opt event missing 'from' field — ignoring`); + return; + } + try { + if (isMessageOptOutEvent(event) && onOptOut) { + await onOptOut(handle, data.chat_id, data.recipient_phone); + } else if (isMessageOptInEvent(event) && onOptIn) { + await onOptIn(handle, data.chat_id, data.recipient_phone); + } + } catch (error) { + console.error(`[webhook] Error handling opt event:`, error); + } + return; + } + // Process message.received events if (isMessageReceivedEvent(event)) { // Debug: log full webhook payload (only in development) diff --git a/src/webhook/types.ts b/src/webhook/types.ts index 6b50a3c..eaed411 100644 --- a/src/webhook/types.ts +++ b/src/webhook/types.ts @@ -63,6 +63,34 @@ export function isMessageReceivedEvent(event: WebhookEvent): event is MessageRec return event.event_type === 'message.received'; } +// Opt-out / opt-in events. Payload shape is provisional — Linq's docs don't +// describe these yet (feature is in development). We accept any data shape +// and pull `from` / `recipient_phone` defensively in the handler. +export interface MessageOptOutEvent extends WebhookEvent { + event_type: 'message.opt_out'; + data: OptOutData; +} + +export interface MessageOptInEvent extends WebhookEvent { + event_type: 'message.opt_in'; + data: OptOutData; +} + +export interface OptOutData { + from?: string; + recipient_phone?: string; + chat_id?: string; + [key: string]: unknown; +} + +export function isMessageOptOutEvent(event: WebhookEvent): event is MessageOptOutEvent { + return event.event_type === 'message.opt_out'; +} + +export function isMessageOptInEvent(event: WebhookEvent): event is MessageOptInEvent { + return event.event_type === 'message.opt_in'; +} + export function extractTextContent(parts: MessagePart[]): string { return parts .filter((part): part is TextPart => part.type === 'text')