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:
co-authored by
Claude Fable 5
parent
1fa34aa7ca
commit
c121d27996
@@ -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 () => {
|
||||
|
||||
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user