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 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-25 12:18:12 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 1fa34aa7ca
commit c121d27996
7 changed files with 199 additions and 12 deletions
+1
View File
@@ -1209,5 +1209,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
@@ -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<string, string>).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])
@@ -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 () => {
+15 -1
View File
@@ -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 ?? []) : []
@@ -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<string> = 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()
}
@@ -328,6 +328,53 @@ export async function markReadWithTyping(wamid: string): Promise<void> {
}
}
/** 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<void> {
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
@@ -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<string> = 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', {