From c121d27996673aeb7abe332124af85507d3322bf Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:18:12 +0200 Subject: [PATCH] feat(whatsapp-inbox): instant checkmark reaction when a receipt lands (#1893) Users standing at a register saw nothing until the detailed ack, which waits on extraction (10-60s) and reads as a black hole; failures could take minutes longer via the sweep. Now the webhook reacts with a U+2705 checkmark on the sender's own media bubble right after the durable row is persisted, so the 'correctly received' signal lands within seconds. - sendReaction in graph-api: best-effort like mark-read, never throws, no outbound row (a reaction is not a message in the conversation model) - gated on the chat MIME allowlist (moved to lib/chat-mime.ts so tests mocking process-inbound cannot lose it): junk earns M15, no checkmark - no reaction for unknown, muted, or redelivered (23505) messages - detailed M4/M5 combined ack and the one-message-per-burst design are unchanged Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + .../__tests__/graph-api.test.ts | 41 +++++++++++ .../__tests__/webhook-post.test.ts | 73 ++++++++++++++++++- extensions/general/whatsapp-inbox/index.ts | 16 +++- .../general/whatsapp-inbox/lib/chat-mime.ts | 20 +++++ .../general/whatsapp-inbox/lib/graph-api.ts | 47 ++++++++++++ .../whatsapp-inbox/lib/process-inbound.ts | 13 +--- 7 files changed, 199 insertions(+), 12 deletions(-) create mode 100644 extensions/general/whatsapp-inbox/lib/chat-mime.ts diff --git a/DECISIONS.md b/DECISIONS.md index 5d2391c9..cd3aaa80 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1209,5 +1209,6 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-24] Keys minted from the OAuth popup before the first company exists get company_id NULL and are bound lazily in validateApiKey (first validation after a company exists) instead of at company creation: creation happens in a Server Action that knows nothing about keys, and one chokepoint covers every creation path. [2026-08-24] /api/mcp-oauth/authorize now forces TOTP enrollment (not just verification) for password accounts with no factor: the middleware skips enrollment for zero-company users, so a popup signup would otherwise mint an MFA-exempt key for an account with no second factor. BankID-linked accounts stay exempt. [2026-08-24] /auth/callback honours next only when it targets /api/mcp-oauth/authorize (via safeReturnTo): consent handles the zero-company state, an arbitrary deep link would not. +[2026-08-25] WhatsApp instant received-signal = emoji reaction (U+2705) sent from the webhook, not an extra text message: reactions add no chat bubble so the one-combined-ack-per-burst design survives; best-effort and not persisted as an outbound row (cosmetic, like mark-read), gated on the CHAT_ALLOWED_MIME_TYPES allowlist so junk never earns a checkmark. [2026-08-24] MCP lazy authentication (#1814 PR 2) lists the FULL default tool catalog to anonymous clients and only gates tools/call: the agent has to be able to name a protected tool to trigger the 401 challenge that opens the Connect (and signup) prompt; listing only public tools would hide the trigger. Descriptions are public documentation anyway. [2026-08-24] Public (pre-auth) MCP tools are the three documentation tools only (search_tools, list_skills, load_skill); org-number lookup stays behind the challenge for now because the TIC lookup lives in another extension and cross-extension imports are forbidden. diff --git a/extensions/general/whatsapp-inbox/__tests__/graph-api.test.ts b/extensions/general/whatsapp-inbox/__tests__/graph-api.test.ts index 45f5a8b4..0a2449d8 100644 --- a/extensions/general/whatsapp-inbox/__tests__/graph-api.test.ts +++ b/extensions/general/whatsapp-inbox/__tests__/graph-api.test.ts @@ -3,6 +3,8 @@ import { createQueuedMockSupabase } from '@/tests/helpers' import { TimeoutError } from '@/lib/http/fetch-with-timeout' import { sendText, + sendReaction, + RECEIVED_REACTION_EMOJI, downloadMedia, getDisplayPhoneNumber, resetDisplayNumberCacheForTests, @@ -96,6 +98,45 @@ describe('graph-api', () => { }) }) + describe('sendReaction', () => { + it('posts a reaction payload targeting the inbound wamid', async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ messages: [{ id: 'wamid.REACT' }] }), { status: 200 }), + ) + + await sendReaction('46701234567', 'wamid.IN1') + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toContain('/111222333/messages') + expect((init.headers as Record).Authorization).toBe('Bearer token-1') + const body = JSON.parse(init.body as string) + expect(body.type).toBe('reaction') + expect(body.to).toBe('46701234567') + expect(body.reaction).toEqual({ + message_id: 'wamid.IN1', + emoji: RECEIVED_REACTION_EMOJI, + }) + // U+2705 exactly: the checkmark must never silently become another char. + expect(RECEIVED_REACTION_EMOJI.codePointAt(0)).toBe(0x2705) + }) + + it('never throws on non-2xx (best-effort, like mark-read)', async () => { + fetchMock.mockResolvedValueOnce(new Response('{"error":{}}', { status: 500 })) + await expect(sendReaction('46701234567', 'wamid.IN1')).resolves.toBeUndefined() + }) + + it('never throws on a network error', async () => { + fetchMock.mockRejectedValueOnce(new Error('ECONNRESET')) + await expect(sendReaction('46701234567', 'wamid.IN1')).resolves.toBeUndefined() + }) + + it('never throws when the access token is missing', async () => { + delete process.env.WHATSAPP_ACCESS_TOKEN + await expect(sendReaction('46701234567', 'wamid.IN1')).resolves.toBeUndefined() + expect(fetchMock).not.toHaveBeenCalled() + }) + }) + describe('downloadMedia', () => { it('resolves the media id, downloads with Bearer auth, and returns the bytes', async () => { const bytes = new Uint8Array([1, 2, 3, 4]) diff --git a/extensions/general/whatsapp-inbox/__tests__/webhook-post.test.ts b/extensions/general/whatsapp-inbox/__tests__/webhook-post.test.ts index 72deb25d..5682a64d 100644 --- a/extensions/general/whatsapp-inbox/__tests__/webhook-post.test.ts +++ b/extensions/general/whatsapp-inbox/__tests__/webhook-post.test.ts @@ -13,6 +13,7 @@ vi.mock('@/extensions/general/whatsapp-inbox/lib/graph-api', async () => { return { ...actual, sendText: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }), + sendReaction: vi.fn().mockResolvedValue(undefined), markReadWithTyping: vi.fn().mockResolvedValue(undefined), downloadMedia: vi.fn(), getDisplayPhoneNumber: vi.fn().mockResolvedValue(null), @@ -25,7 +26,11 @@ vi.mock('@/extensions/general/whatsapp-inbox/lib/process-inbound', () => ({ import { createClient } from '@supabase/supabase-js' import { whatsappInboxExtension } from '@/extensions/general/whatsapp-inbox' -import { sendText, downloadMedia } from '@/extensions/general/whatsapp-inbox/lib/graph-api' +import { + sendText, + sendReaction, + downloadMedia, +} from '@/extensions/general/whatsapp-inbox/lib/graph-api' import { kickInboundProcessing } from '@/extensions/general/whatsapp-inbox/lib/process-inbound' import { TEMPLATE } from '@/extensions/general/whatsapp-inbox/lib/messages' import { hashLinkCode } from '@/extensions/general/whatsapp-inbox/lib/linking' @@ -33,6 +38,7 @@ import { hashLinkCode } from '@/extensions/general/whatsapp-inbox/lib/linking' const SECRET = 'meta-app-secret' const createClientMock = vi.mocked(createClient) const sendTextMock = vi.mocked(sendText) +const sendReactionMock = vi.mocked(sendReaction) const kickMock = vi.mocked(kickInboundProcessing) function findRoute(method: string, path: string) { @@ -210,6 +216,65 @@ describe('POST /webhook', () => { expect(kickMock).toHaveBeenCalledWith(['msg-row-1']) expect(sendTextMock).not.toHaveBeenCalled() // the combined ack comes from the worker + // The instant "received" signal: a checkmark reaction on the sender's own + // message, sent before the webhook 200s (the detailed ack waits on + // extraction, which is exactly the latency users complained about). + expect(sendReactionMock).toHaveBeenCalledWith('46701234567', 'wamid.IN1') + }) + + it('reacts to a supported document (PDF) as well', async () => { + const { enqueue } = mockSupabase() + enqueue({ data: makeLink() }) + enqueue({ data: { id: 'conv-1' } }) + enqueue({ data: { id: 'msg-row-1' } }) + enqueue({ data: null }) + enqueue({ data: null }) + + await route.handler( + signedRequest( + envelope({ + messages: [ + { + from: '46701234567', + id: 'wamid.IN1', + timestamp: '1754000000', + type: 'document', + document: { id: 'media-1', mime_type: 'application/pdf', filename: 'kvitto.pdf' }, + }, + ], + }), + ), + ) + expect(sendReactionMock).toHaveBeenCalledWith('46701234567', 'wamid.IN1') + }) + + it('does not react to media the pipeline will reject (unsupported mime)', async () => { + const { enqueue } = mockSupabase() + enqueue({ data: makeLink() }) + enqueue({ data: { id: 'conv-1' } }) + enqueue({ data: { id: 'msg-row-1' } }) + enqueue({ data: null }) + enqueue({ data: null }) + + await route.handler( + signedRequest( + envelope({ + messages: [ + { + from: '46701234567', + id: 'wamid.IN1', + timestamp: '1754000000', + type: 'document', + document: { id: 'media-1', mime_type: 'application/zip', filename: 'arkiv.zip' }, + }, + ], + }), + ), + ) + // The row still becomes a job (the worker owns the M15 rejection reply), + // but junk never earns the checkmark. + expect(kickMock).toHaveBeenCalledWith(['msg-row-1']) + expect(sendReactionMock).not.toHaveBeenCalled() }) it('dedupes a redelivered wamid via the unique index (23505): no reply, no processing', async () => { @@ -224,6 +289,8 @@ describe('POST /webhook', () => { expect(response.status).toBe(200) expect(kickMock).toHaveBeenCalledWith([]) expect(sendTextMock).not.toHaveBeenCalled() + // A Meta redelivery of an already-handled message must not re-react. + expect(sendReactionMock).not.toHaveBeenCalled() }) describe('unknown senders', () => { @@ -241,6 +308,8 @@ describe('POST /webhook', () => { expect(sendTextMock).toHaveBeenCalledTimes(1) expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m1Unlinked) expect(vi.mocked(downloadMedia)).not.toHaveBeenCalled() + // No checkmark for unlinked senders: nothing was received into any inbox. + expect(sendReactionMock).not.toHaveBeenCalled() // #1552: a metadata-only trace row IS persisted, but it must carry no // content: no body, no media reference, no raw payload, no link. const inserts = findCalls('whatsapp_messages', 'insert') @@ -549,6 +618,8 @@ describe('POST /webhook', () => { expect(sendTextMock).not.toHaveBeenCalled() expect(kickMock).toHaveBeenCalledWith([]) + // Muted means the channel is paused: no checkmark either. + expect(sendReactionMock).not.toHaveBeenCalled() }) it('start unmutes and welcomes back with M12', async () => { diff --git a/extensions/general/whatsapp-inbox/index.ts b/extensions/general/whatsapp-inbox/index.ts index 8b2c6c46..6dd1ec4f 100644 --- a/extensions/general/whatsapp-inbox/index.ts +++ b/extensions/general/whatsapp-inbox/index.ts @@ -45,9 +45,10 @@ import { lookupActiveLink, mintLinkCode, } from './lib/linking' -import { sendText, getDisplayPhoneNumber, MAX_REPLY_BUTTONS } from './lib/graph-api' +import { sendText, sendReaction, getDisplayPhoneNumber, MAX_REPLY_BUTTONS } from './lib/graph-api' import { botCopy, TEMPLATE } from './lib/messages' import { kickInboundProcessing } from './lib/process-inbound' +import { CHAT_ALLOWED_MIME_TYPES, normalizeChatMime } from './lib/chat-mime' import { DEBOUNCE_WINDOW_MS, getContext, @@ -627,6 +628,19 @@ async function handleLinkedSender( // Media intake and answer interpretation both run deferred (the answer // path may call the LLM; the webhook must 200 in seconds). if (messageId) deferredMessageIds.push(messageId) + // Instant "received" signal: the detailed ack waits on extraction + // (10-60s), which read as a black hole to someone standing at a + // register. A reaction on the sender's own bubble lands within + // seconds, adds no chat noise, and the row just persisted plus the + // sweep guarantee (ingest or M18) make it honest. Only for files the + // pipeline will accept: junk earns M15, not a checkmark. One Graph + // call (~10s timeout worst case) stays inside Meta's webhook budget, + // and a redelivered wamid returns on the 23505 above, so no re-react. + if (disposition.kind === 'media' && msg.wamid) { + if (CHAT_ALLOWED_MIME_TYPES.has(normalizeChatMime(msg.media?.mime))) { + await sendReaction(msg.from, msg.wamid) + } + } return case 'company_retry': { const options = conversation ? (getContext(conversation).company_options ?? []) : [] diff --git a/extensions/general/whatsapp-inbox/lib/chat-mime.ts b/extensions/general/whatsapp-inbox/lib/chat-mime.ts new file mode 100644 index 00000000..390aa32c --- /dev/null +++ b/extensions/general/whatsapp-inbox/lib/chat-mime.ts @@ -0,0 +1,20 @@ +/** + * Chat intake accepts what phones actually produce. Narrower than the upload + * allowlist on purpose: WhatsApp transcodes photos to JPEG, so HEIC never + * arrives, and everything else gets the M15 nudge. + * + * Lives in its own dependency-free module because both the deferred worker + * (the M15 rejection) and the webhook (the instant checkmark reaction) gate + * on it, and tests that mock process-inbound must not lose the constant. + */ +export const CHAT_ALLOWED_MIME_TYPES: ReadonlySet = new Set([ + 'image/jpeg', + 'image/png', + 'image/webp', + 'application/pdf', +]) + +/** Normalize a raw MIME header value to what the allowlist stores. */ +export function normalizeChatMime(mime: string | null | undefined): string { + return (mime ?? '').split(';')[0].trim().toLowerCase() +} diff --git a/extensions/general/whatsapp-inbox/lib/graph-api.ts b/extensions/general/whatsapp-inbox/lib/graph-api.ts index 204fe094..8477aa9a 100644 --- a/extensions/general/whatsapp-inbox/lib/graph-api.ts +++ b/extensions/general/whatsapp-inbox/lib/graph-api.ts @@ -328,6 +328,53 @@ export async function markReadWithTyping(wamid: string): Promise { } } +/** The instant "received" signal on accepted media (U+2705 check mark). + * Built via fromCharCode so no literal emoji byte can be mangled in transit. */ +export const RECEIVED_REACTION_EMOJI = String.fromCharCode(0x2705) + +/** + * React to an inbound message with an emoji. This is the instant "your + * receipt reached us" signal, sent from the webhook itself: reactions attach + * to the sender's own bubble and add no message of their own, so the + * debounced combined ack (M4/M5) stays the ONE message per burst. Best-effort + * and cosmetic exactly like markReadWithTyping: a failure is logged and + * swallowed, no outbound whatsapp_messages row is persisted (a reaction is + * not a message in the conversation model), and intake is unaffected. + */ +export async function sendReaction( + to: string, + wamid: string, + emoji: string = RECEIVED_REACTION_EMOJI, +): Promise { + try { + const response = await fetchWithTimeout( + `${GRAPH_BASE}/${getPhoneNumberId()}/messages`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${getAccessToken()}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + messaging_product: 'whatsapp', + recipient_type: 'individual', + to, + type: 'reaction', + reaction: { message_id: wamid, emoji }, + }), + }, + { timeoutMs: SEND_TIMEOUT_MS, description: 'WhatsApp reaction' }, + ) + if (!response.ok) { + log.warn('WhatsApp reaction failed', { status: response.status }) + } + } catch (err) { + log.warn('WhatsApp reaction errored', { + error: err instanceof Error ? err.message : String(err), + }) + } +} + export interface DownloadedMedia { buffer: ArrayBuffer mime: string | null diff --git a/extensions/general/whatsapp-inbox/lib/process-inbound.ts b/extensions/general/whatsapp-inbox/lib/process-inbound.ts index 5e13b72d..2741ccb7 100644 --- a/extensions/general/whatsapp-inbox/lib/process-inbound.ts +++ b/extensions/general/whatsapp-inbox/lib/process-inbound.ts @@ -69,15 +69,8 @@ import { appendQuestionHistory, updateItemContext } from './item-context' const log = createLogger('whatsapp-inbox/process-inbound') -/** Chat intake accepts what phones actually produce. Narrower than the upload - * allowlist on purpose: WhatsApp transcodes photos to JPEG, so HEIC never - * arrives, and everything else gets the M15 nudge. */ -export const CHAT_ALLOWED_MIME_TYPES: ReadonlySet = new Set([ - 'image/jpeg', - 'image/png', - 'image/webp', - 'application/pdf', -]) +export { CHAT_ALLOWED_MIME_TYPES } from './chat-mime' +import { CHAT_ALLOWED_MIME_TYPES, normalizeChatMime } from './chat-mime' /** M17 is sent at most once per this window per sender, not once per file. */ const RATE_LIMIT_NOTICE_WINDOW_MS = 10 * 60 * 1000 @@ -457,7 +450,7 @@ async function processMediaMessage( : await getOrCreateConversation(supabase, link.id) // ── MIME allowlist (before staging: never park junk) ─── - const mime = (row.media_mime ?? '').split(';')[0].trim().toLowerCase() + const mime = normalizeChatMime(row.media_mime) if (!CHAT_ALLOWED_MIME_TYPES.has(mime)) { await sendText(supabase, { to, body: copy.m15Unsupported(), template: TEMPLATE.m15Unsupported, ...replyBase }) await markStatus(supabase, row.id, 'skipped', {