From 6c64dd2312719d02609e07f086687a596ab5a39b Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Thu, 13 Aug 2026 16:02:16 +0200 Subject: [PATCH] fix(whatsapp): make every silent disposition observable, stop pure silence for linked senders (#1563) * fix(whatsapp): make every silent disposition observable, stop pure silence for linked senders (#1552) Silence was a legitimate outcome in seven places and none left a trace a support question could be answered from. Now: - Unknown-sender declines (over quota, quota RPC failure, greeting throttle) persist content-free trace rows: wamid, phone hash, type, disposition. No body, media, raw payload, or profile name; capped at 20 rows per hash and day; deleted by the existing 30-day retention. The wamid dedupe also stops redelivered bad-code/greeting messages from earning a second reply. - Linked-sender deliberate silences (muted, stale tap, ignorable type) record their reason on the skipped row. - Non-policy silences reply: a row missing its media reference sends M18 through the link's reply address, a link revoked between arrival and processing sends the M1 unlinked copy (greeting-throttled). - Outbound rows keep WHY a send failed (Graph error detail), and Meta 'failed' delivery statuses store their error code and title. - The WhatsApp settings panel shows the last inbound event (closed enum, server-derived) and warns when the latest reply never left. Co-Authored-By: Claude Fable 5 * test(whatsapp): include errorDetail in typed sendText mock results SendTextResult gained errorDetail; vi.mocked call sites must match the widened type or they raise fresh tsc errors over the repo baseline. Co-Authored-By: Claude Fable 5 * fix(whatsapp): review fixes: fail-closed greeting throttle, cap only declined traces From CodeRabbit's pass on #1563: - greetingThrottled fails closed when the throttle window cannot be read, matching the unknown-sender quota's stance. - The decline-trace day cap applies only to 'skipped' rows (the one unbounded path); 'done' traces always insert so the wamid dedupe keeps preventing duplicate M1/M2 replies even past the cap. Their volume is already bounded upstream by the greeting throttle and the pre-binding quota. - company-question test mocks match the widened SendTextResult. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + .../extensions/general/WhatsAppLinkPanel.tsx | 41 ++++ .../__tests__/answer-flow.test.ts | 4 +- .../__tests__/burst-ack.test.ts | 4 +- .../__tests__/company-question.test.ts | 6 +- .../__tests__/graph-api.test.ts | 8 +- .../__tests__/hardening.test.ts | 14 +- .../__tests__/last-event.test.ts | 39 ++++ .../__tests__/process-inbound.test.ts | 59 +++++- .../__tests__/webhook-post.test.ts | 88 +++++++- extensions/general/whatsapp-inbox/index.ts | 197 +++++++++++++++--- .../whatsapp-inbox/lib/conversation.ts | 35 ++++ .../general/whatsapp-inbox/lib/graph-api.ts | 16 +- .../general/whatsapp-inbox/lib/last-event.ts | 51 +++++ .../whatsapp-inbox/lib/process-inbound.ts | 28 +++ .../whatsapp-inbox/lib/webhook-parse.ts | 23 +- messages/en.json | 14 +- messages/sv.json | 14 +- 18 files changed, 573 insertions(+), 69 deletions(-) create mode 100644 extensions/general/whatsapp-inbox/__tests__/last-event.test.ts create mode 100644 extensions/general/whatsapp-inbox/lib/last-event.ts diff --git a/DECISIONS.md b/DECISIONS.md index bd098934..972026f8 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -919,3 +919,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-11] Self-hosted: the 30 s poll in lib/hooks/use-document-extraction.ts left alone even though #1406 names it. It is a symptom of missing credentials rather than a bug in itself: with AI configured, extraction finishes in 2-8 s and the wait disappears. The real fix is for the extraction-status endpoint to report "not configured" instead of leaving the column NULL forever, which is a separate change. [2026-08-13] Tier 1 self-hosting packages the `document-extraction` extension in the stock image: a plain `ANTHROPIC_API_KEY` must cover documents uploaded in the app as well as emailed invoices and the assistant to satisfy issue #1406; this does not add provider abstraction or other Tier 2 work. [2026-08-13] document-extraction's manifest no longer lists AWS_REGION under requiredEnvVars: the extension accepts either AWS static keys or ANTHROPIC_API_KEY, which the manifest schema cannot express as alternatives, and requiredEnvVars only drives a build-time warning rather than gating execution. +[2026-08-13] WhatsApp decline observability (#1552) reuses whatsapp_messages with content-free rows for unknown-sender declines instead of a new table or aggregate RPC: no migration (no orphan risk), the wamid unique index gives redelivery dedupe for free (a redelivered bad-code or greeted message no longer earns a second reply), and the existing 30-day unknown-sender retention pass already deletes the rows. Write amplification from an over-quota flood is bounded by a 20-rows-per-hash-per-day trace cap, not by dropping the trail entirely. The settings panel gets a closed event enum derived server-side (lib/last-event.ts), never raw error_message text, so internal errors cannot leak to the client. diff --git a/components/extensions/general/WhatsAppLinkPanel.tsx b/components/extensions/general/WhatsAppLinkPanel.tsx index f5bf1268..8d384cdb 100644 --- a/components/extensions/general/WhatsAppLinkPanel.tsx +++ b/components/extensions/general/WhatsAppLinkPanel.tsx @@ -14,9 +14,11 @@ import { useCallback, useEffect, useState } from 'react' import { useTranslations } from 'next-intl' import { Loader2, ExternalLink } from 'lucide-react' import { WhatsAppMark } from '@/components/extensions/general/WhatsAppMark' +import { AttnLine } from '@/components/ui/attn-line' import { Button } from '@/components/ui/button' import { useToast } from '@/components/ui/use-toast' import { useCompany } from '@/contexts/CompanyContext' +import { useFormat } from '@/lib/hooks/use-format' import { SettingsGroup, SettingsRow, @@ -26,11 +28,28 @@ import { const BASE = '/api/extensions/ext/whatsapp-inbox' +/** Closed enum from the server (lib/last-event.ts); translated here. */ +const LAST_EVENT_KINDS = new Set([ + 'filed', + 'handled', + 'processing', + 'awaiting_company', + 'duplicate', + 'rate_limited', + 'unsupported', + 'muted', + 'declined', + 'failed', +]) + interface LinkStatus { linked: boolean phoneMasked?: string defaultCompanyId?: string | null muted?: boolean + lastInboundAt?: string | null + lastInboundEvent?: string | null + lastReplyFailed?: boolean } interface MintedCode { @@ -43,6 +62,7 @@ export function WhatsAppLinkPanel() { const t = useTranslations('settings_whatsapp') const { toast } = useToast() const { companies } = useCompany() + const { locale, formatDateLong } = useFormat() const [isLoading, setIsLoading] = useState(true) const [loadFailed, setLoadFailed] = useState(false) @@ -167,6 +187,27 @@ export function WhatsAppLinkPanel() { ) : null} + {status.lastInboundAt ? ( + +
+ + {formatDateLong(status.lastInboundAt)}{' '} + {new Date(status.lastInboundAt).toLocaleTimeString( + locale === 'en' ? 'en-GB' : 'sv-SE', + { hour: '2-digit', minute: '2-digit' }, + )} + {' · '} + {t( + status.lastInboundEvent && LAST_EVENT_KINDS.has(status.lastInboundEvent) + ? `last_event_${status.lastInboundEvent}` + : 'last_event_handled', + )} + + {status.lastReplyFailed ? {t('last_reply_failed')} : null} +
+
+ ) : null} + { >('@/extensions/general/whatsapp-inbox/lib/graph-api') return { ...actual, - sendText: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT' }), + sendText: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }), markReadWithTyping: vi.fn().mockResolvedValue(undefined), downloadMedia: vi.fn(), } @@ -121,7 +121,7 @@ const openItemContext = (type: 'representation' | 'context') => ({ describe('answer flow (text rows through processInboundMessage)', () => { beforeEach(() => { vi.clearAllMocks() - sendTextMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT' }) + sendTextMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }) agentRateMock.mockResolvedValue({ ok: true }) interpretMock.mockResolvedValue({ ok: false }) }) diff --git a/extensions/general/whatsapp-inbox/__tests__/burst-ack.test.ts b/extensions/general/whatsapp-inbox/__tests__/burst-ack.test.ts index 0f6d8bbe..1f4aa85b 100644 --- a/extensions/general/whatsapp-inbox/__tests__/burst-ack.test.ts +++ b/extensions/general/whatsapp-inbox/__tests__/burst-ack.test.ts @@ -8,7 +8,7 @@ vi.mock('@/extensions/general/whatsapp-inbox/lib/graph-api', async () => { >('@/extensions/general/whatsapp-inbox/lib/graph-api') return { ...actual, - sendText: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT' }), + sendText: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }), markReadWithTyping: vi.fn().mockResolvedValue(undefined), downloadMedia: vi.fn(), } @@ -105,7 +105,7 @@ const RESTAURANT_RECEIPT = { describe('finalizeBurst', () => { beforeEach(() => { vi.clearAllMocks() - sendTextMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT' }) + sendTextMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }) }) it('a lost pending_ack claim sends nothing (single-winner semantics)', async () => { diff --git a/extensions/general/whatsapp-inbox/__tests__/company-question.test.ts b/extensions/general/whatsapp-inbox/__tests__/company-question.test.ts index e1fd77d6..854c6b49 100644 --- a/extensions/general/whatsapp-inbox/__tests__/company-question.test.ts +++ b/extensions/general/whatsapp-inbox/__tests__/company-question.test.ts @@ -8,9 +8,9 @@ vi.mock('@/extensions/general/whatsapp-inbox/lib/graph-api', async () => { >('@/extensions/general/whatsapp-inbox/lib/graph-api') return { ...actual, - sendText: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT' }), - sendReplyButtons: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT' }), - sendList: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT' }), + sendText: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }), + sendReplyButtons: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }), + sendList: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }), } }) diff --git a/extensions/general/whatsapp-inbox/__tests__/graph-api.test.ts b/extensions/general/whatsapp-inbox/__tests__/graph-api.test.ts index 10908625..45f5a8b4 100644 --- a/extensions/general/whatsapp-inbox/__tests__/graph-api.test.ts +++ b/extensions/general/whatsapp-inbox/__tests__/graph-api.test.ts @@ -45,11 +45,12 @@ describe('graph-api', () => { senderPhoneHash: 'hash-1', }) - expect(result).toEqual({ ok: true, wamid: 'wamid.OUT1' }) + expect(result).toEqual({ ok: true, wamid: 'wamid.OUT1', errorDetail: null }) const [row] = findCall('whatsapp_messages', 'insert') as [Record] expect(row.direction).toBe('outbound') expect(row.wamid).toBe('wamid.OUT1') expect(row.delivery_status).toBe('sent') + expect(row.error_message).toBeNull() expect(row.processing_status).toBe('done') expect(row.raw_payload).toEqual({ template: TEMPLATE.m16Fallback }) expect(row.sender_phone_hash).toBe('hash-1') @@ -72,10 +73,13 @@ describe('graph-api', () => { template: TEMPLATE.m18Error, }) - expect(result).toEqual({ ok: false, wamid: null }) + expect(result.ok).toBe(false) + expect(result.wamid).toBeNull() const [row] = findCall('whatsapp_messages', 'insert') as [Record] expect(row.delivery_status).toBe('failed') expect(row.wamid).toBeNull() + // #1552: the row keeps WHY the send failed, not just that it failed. + expect(row.error_message).toContain('HTTP 500') }) it('never throws on a network error', async () => { diff --git a/extensions/general/whatsapp-inbox/__tests__/hardening.test.ts b/extensions/general/whatsapp-inbox/__tests__/hardening.test.ts index 32eea118..db7f8734 100644 --- a/extensions/general/whatsapp-inbox/__tests__/hardening.test.ts +++ b/extensions/general/whatsapp-inbox/__tests__/hardening.test.ts @@ -18,9 +18,9 @@ vi.mock('@/extensions/general/whatsapp-inbox/lib/graph-api', async () => { >('@/extensions/general/whatsapp-inbox/lib/graph-api') return { ...actual, - sendText: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT' }), - sendReplyButtons: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT' }), - sendList: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT' }), + sendText: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }), + sendReplyButtons: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }), + sendList: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }), markReadWithTyping: vi.fn().mockResolvedValue(undefined), downloadMedia: vi.fn(), } @@ -123,8 +123,8 @@ beforeEach(() => { vi.clearAllMocks() process.env.WHATSAPP_PHONE_HASH_KEY = 'test-pepper' process.env.WHATSAPP_PHONE_ENCRYPTION_KEY = 'a'.repeat(64) - sendTextMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT' }) - sendButtonsMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT' }) + sendTextMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }) + sendButtonsMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }) }) describe('company question is not one-shot when the send fails', () => { @@ -140,7 +140,7 @@ describe('company question is not one-shot when the send fails', () => { } it('rolls the question back so the next receipt re-asks', async () => { - sendButtonsMock.mockResolvedValue({ ok: false, wamid: null }) + sendButtonsMock.mockResolvedValue({ ok: false, wamid: null, errorDetail: 'Send failed (HTTP 500)' }) const mock = createQueuedMockSupabase() enqueueAsk(mock) mock.enqueue({ @@ -254,7 +254,7 @@ describe('combined ack', () => { }) it('leaves the rows unacked when the send failed so the sweep retries', async () => { - sendTextMock.mockResolvedValue({ ok: false, wamid: null }) + sendTextMock.mockResolvedValue({ ok: false, wamid: null, errorDetail: 'Send failed (HTTP 500)' }) const mock = createQueuedMockSupabase() enqueueBurst(mock, { documentKind: 'receipt', diff --git a/extensions/general/whatsapp-inbox/__tests__/last-event.test.ts b/extensions/general/whatsapp-inbox/__tests__/last-event.test.ts new file mode 100644 index 00000000..2ee95eb6 --- /dev/null +++ b/extensions/general/whatsapp-inbox/__tests__/last-event.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest' +import { summarizeInboundEvent } from '@/extensions/general/whatsapp-inbox/lib/last-event' +import { + COMPANY_CHOICE_EXPIRED, + STAGED_AWAITING_COMPANY, +} from '@/extensions/general/whatsapp-inbox/lib/conversation' + +function row( + processing_status: string, + error_message: string | null = null, + inbox_item_id: string | null = null, +) { + return { processing_status, error_message, inbox_item_id } +} + +describe('summarizeInboundEvent', () => { + it('maps terminal statuses to the closed panel enum', () => { + expect(summarizeInboundEvent(row('done', null, 'item-1'))).toBe('filed') + expect(summarizeInboundEvent(row('done'))).toBe('handled') + expect(summarizeInboundEvent(row('received'))).toBe('processing') + expect(summarizeInboundEvent(row('processing'))).toBe('processing') + expect(summarizeInboundEvent(row('error', 'Media exceeds the size limit'))).toBe('failed') + }) + + it('splits skipped rows by their recorded reason', () => { + expect(summarizeInboundEvent(row('skipped', STAGED_AWAITING_COMPANY))).toBe('awaiting_company') + expect(summarizeInboundEvent(row('skipped', COMPANY_CHOICE_EXPIRED))).toBe('failed') + expect(summarizeInboundEvent(row('skipped', 'Duplicate document (sha256)'))).toBe('duplicate') + expect(summarizeInboundEvent(row('skipped', 'Rate limited'))).toBe('rate_limited') + expect(summarizeInboundEvent(row('skipped', 'Unsupported media type: video/mp4'))).toBe( + 'unsupported', + ) + expect(summarizeInboundEvent(row('skipped', 'Muted by stopp: message not read'))).toBe('muted') + expect( + summarizeInboundEvent(row('skipped', 'Stale interactive tap after the question closed')), + ).toBe('declined') + expect(summarizeInboundEvent(row('skipped', null))).toBe('declined') + }) +}) diff --git a/extensions/general/whatsapp-inbox/__tests__/process-inbound.test.ts b/extensions/general/whatsapp-inbox/__tests__/process-inbound.test.ts index c2b109e6..b62bc3eb 100644 --- a/extensions/general/whatsapp-inbox/__tests__/process-inbound.test.ts +++ b/extensions/general/whatsapp-inbox/__tests__/process-inbound.test.ts @@ -8,7 +8,7 @@ vi.mock('@/extensions/general/whatsapp-inbox/lib/graph-api', async () => { >('@/extensions/general/whatsapp-inbox/lib/graph-api') return { ...actual, - sendText: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT' }), + sendText: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }), markReadWithTyping: vi.fn().mockResolvedValue(undefined), downloadMedia: vi.fn(), } @@ -141,7 +141,7 @@ function lastUpdate(findCalls: (table: string, method: string) => unknown[][]): describe('processInboundMessage (media intake)', () => { beforeEach(() => { vi.clearAllMocks() - sendTextMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT' }) + sendTextMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }) askCompanyQuestionMock.mockResolvedValue(true) rateLimitMock.mockResolvedValue({ ok: true }) downloadMediaMock.mockResolvedValue({ @@ -565,3 +565,58 @@ describe('processInboundMessage (media intake)', () => { expect(finalUpdate.inbox_item_id).toBe('item-winner') }) }) + +describe('processInboundMessage: linked-sender silences become replies (#1552)', () => { + beforeEach(() => { + vi.clearAllMocks() + sendTextMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }) + }) + + it('missing media reference: marks error AND owns the failure with M18 via the link', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ data: makeRow({ media_id: null }) }) // load row + enqueue({ data: { id: 'msg-1' } }) // claim + enqueue({ data: null }) // markStatus error + enqueue({ data: makeLink() }) // load link for the reply address + enqueue({ data: null }) // errorNoticeAlreadySent: none + + await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1') + + expect(lastUpdate(findCalls).processing_status).toBe('error') + expect(sendTextMock).toHaveBeenCalledTimes(1) + const args = sendTextMock.mock.calls[0][1] + expect(args.template).toBe(TEMPLATE.m18Error) + expect(args.to).toBe('46701234567') + }) + + it('link revoked before processing: marks skipped AND replies with the M1 unlinked copy', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ data: makeRow() }) // load row + enqueue({ data: { id: 'msg-1' } }) // claim + enqueue({ data: makeLink({ revoked_at: '2026-08-01T09:30:00Z' }) }) // revoked link + enqueue({ data: null }) // markStatus skipped + enqueue({ data: [] }) // greeting throttle window: clear + + await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1') + + expect(lastUpdate(findCalls).processing_status).toBe('skipped') + expect(sendTextMock).toHaveBeenCalledTimes(1) + const args = sendTextMock.mock.calls[0][1] + expect(args.template).toBe(TEMPLATE.m1Unlinked) + expect(args.to).toBe('46701234567') + }) + + it('link revoked before processing: stays quiet when the M1 throttle is exhausted', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ data: makeRow() }) + enqueue({ data: { id: 'msg-1' } }) + enqueue({ data: makeLink({ revoked_at: '2026-08-01T09:30:00Z' }) }) + enqueue({ data: null }) // markStatus skipped + enqueue({ data: [{ created_at: new Date().toISOString() }] }) // greeted within the hour + + await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1') + + expect(lastUpdate(findCalls).processing_status).toBe('skipped') + expect(sendTextMock).not.toHaveBeenCalled() + }) +}) diff --git a/extensions/general/whatsapp-inbox/__tests__/webhook-post.test.ts b/extensions/general/whatsapp-inbox/__tests__/webhook-post.test.ts index 92a49316..1b3b0728 100644 --- a/extensions/general/whatsapp-inbox/__tests__/webhook-post.test.ts +++ b/extensions/general/whatsapp-inbox/__tests__/webhook-post.test.ts @@ -12,7 +12,7 @@ vi.mock('@/extensions/general/whatsapp-inbox/lib/graph-api', async () => { >('@/extensions/general/whatsapp-inbox/lib/graph-api') return { ...actual, - sendText: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT' }), + sendText: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }), markReadWithTyping: vi.fn().mockResolvedValue(undefined), downloadMedia: vi.fn(), getDisplayPhoneNumber: vi.fn().mockResolvedValue(null), @@ -109,7 +109,7 @@ describe('POST /webhook', () => { beforeEach(() => { vi.clearAllMocks() - sendTextMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT' }) + sendTextMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }) process.env.WHATSAPP_APP_SECRET = SECRET process.env.WHATSAPP_PHONE_HASH_KEY = 'test-pepper' process.env.WHATSAPP_PHONE_ENCRYPTION_KEY = 'a'.repeat(64) @@ -155,6 +155,30 @@ describe('POST /webhook', () => { expect(updateArgs[0]).toEqual({ delivery_status: 'delivered' }) }) + it('keeps the Meta error detail on a failed delivery status (#1552)', async () => { + const { enqueue, findCall } = mockSupabase() + enqueue({ data: null }) // update chain + + await route.handler( + signedRequest( + envelope({ + statuses: [ + { + id: 'wamid.OUT9', + status: 'failed', + errors: [{ code: 131026, title: 'Message undeliverable' }], + }, + ], + }), + ), + ) + const updateArgs = findCall('whatsapp_messages', 'update') as [Record] + expect(updateArgs[0]).toEqual({ + delivery_status: 'failed', + error_message: '131026: Message undeliverable', + }) + }) + it('persists a linked media message, arms the burst debounce and defers processing', async () => { const { enqueue, findCall } = mockSupabase() enqueue({ data: makeLink() }) // active link lookup @@ -203,11 +227,12 @@ describe('POST /webhook', () => { }) describe('unknown senders', () => { - it('greets once with M1: no media download, no message persistence', async () => { + it('greets once with M1: no media download, no CONTENT persistence', async () => { const { enqueue, findCalls } = mockSupabase() enqueue({ data: null }) // no active link enqueue({ data: { ok: true } }) // sender quota RPC enqueue({ data: [] }) // greeting throttle: nothing sent before + enqueue({ data: null, error: null }) // trace row insert ('done': no cap query) const response = await route.handler( signedRequest(envelope({ messages: [imageMessage()] })), @@ -216,24 +241,55 @@ describe('POST /webhook', () => { expect(sendTextMock).toHaveBeenCalledTimes(1) expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m1Unlinked) expect(vi.mocked(downloadMedia)).not.toHaveBeenCalled() - expect(findCalls('whatsapp_messages', 'insert')).toHaveLength(0) + // #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') + expect(inserts).toHaveLength(1) + const [row] = inserts[0] as [Record] + expect(row.processing_status).toBe('done') + expect(row.error_message).toContain('greeted') + expect(row.phone_link_id).toBeNull() + expect(row.body_text).toBeUndefined() + expect(row.media_id).toBeUndefined() + expect(row.raw_payload).toBeUndefined() expect(kickMock).toHaveBeenCalledWith([]) }) - it('stays silent when the M1 throttle window is exhausted', async () => { + it('does not re-greet a redelivered wamid (trace row dedupe)', async () => { const { enqueue } = mockSupabase() enqueue({ data: null }) enqueue({ data: { ok: true } }) - enqueue({ data: [{ created_at: new Date().toISOString() }] }) // greeted within the hour + enqueue({ data: [] }) // throttle window clear + enqueue({ data: null, error: { code: '23505', message: 'duplicate key' } }) // trace insert - await route.handler(signedRequest(envelope({ messages: [textMessage('hej')] }))) + await route.handler(signedRequest(envelope({ messages: [imageMessage()] }))) expect(sendTextMock).not.toHaveBeenCalled() }) - it('stays silent when the pre-binding sender quota is exhausted', async () => { + it('records a declined trace row when the M1 throttle window is exhausted', async () => { + const { enqueue, findCalls } = mockSupabase() + enqueue({ data: null }) + enqueue({ data: { ok: true } }) + enqueue({ data: [{ created_at: new Date().toISOString() }] }) // greeted within the hour + enqueue({ count: 0 }) // decline-trace day cap + enqueue({ data: null, error: null }) // trace row insert + + await route.handler(signedRequest(envelope({ messages: [textMessage('hej')] }))) + expect(sendTextMock).not.toHaveBeenCalled() + const inserts = findCalls('whatsapp_messages', 'insert') + expect(inserts).toHaveLength(1) + const [row] = inserts[0] as [Record] + expect(row.processing_status).toBe('skipped') + expect(row.error_message).toContain('greeting throttled') + expect(row.body_text).toBeUndefined() + }) + + it('stays silent but records the decline when the pre-binding quota is exhausted', async () => { const mock = mockSupabase() mock.enqueue({ data: null }) mock.enqueue({ data: { ok: false, scope: 'minute', retry_after_sec: 60 } }) + mock.enqueue({ count: 0 }) // decline-trace day cap + mock.enqueue({ data: null, error: null }) // trace row insert await route.handler(signedRequest(envelope({ messages: [textMessage('hej')] }))) expect(sendTextMock).not.toHaveBeenCalled() @@ -241,9 +297,21 @@ describe('POST /webhook', () => { 'check_and_increment_whatsapp_sender_quota', expect.objectContaining({ p_phone_hash: expect.any(String) }), ) - // Only the link lookup ever touched a table. + // The link lookup plus the metadata-only decline trace (#1552). const tables = [...new Set(mock.calls.map((c) => c.table))] - expect(tables).toEqual(['whatsapp_phone_links']) + expect(tables).toEqual(['whatsapp_phone_links', 'whatsapp_messages']) + const [row] = mock.findCall('whatsapp_messages', 'insert') as [Record] + expect(row.error_message).toContain('over pre-binding quota') + }) + + it('stops recording decline traces past the per-day cap', async () => { + const { enqueue, findCalls } = mockSupabase() + enqueue({ data: null }) + enqueue({ data: { ok: false } }) + enqueue({ count: 20 }) // cap reached + + await route.handler(signedRequest(envelope({ messages: [textMessage('hej')] }))) + expect(findCalls('whatsapp_messages', 'insert')).toHaveLength(0) }) }) diff --git a/extensions/general/whatsapp-inbox/index.ts b/extensions/general/whatsapp-inbox/index.ts index 57eb260c..732c0e33 100644 --- a/extensions/general/whatsapp-inbox/index.ts +++ b/extensions/general/whatsapp-inbox/index.ts @@ -52,11 +52,13 @@ import { DEBOUNCE_WINDOW_MS, getContext, getOrCreateConversation, + greetingThrottled, redactRawPayload, resolveAnswerTarget, updateConversation, type ConversationContext, } from './lib/conversation' +import { summarizeInboundEvent } from './lib/last-event' import { applyCompanyChoice, type CompanyChoiceVia } from './lib/company-question' const log = createLogger('whatsapp-inbox') @@ -66,10 +68,9 @@ const log = createLogger('whatsapp-inbox') // much handling an unbound phone can consume at all. Beyond it: silence. const UNKNOWN_SENDER_MINUTE_MAX = 15 const UNKNOWN_SENDER_DAY_MAX = 200 -// The M1 greeting itself is throttled much harder: 1/hour, 3/day, then silence. -const GREETING_HOUR_MS = 60 * 60 * 1000 -const GREETING_DAY_MS = 24 * 60 * 60 * 1000 -const GREETING_DAY_MAX = 3 +// Declined-message trace rows (issue #1552) are capped per hash and day so an +// over-quota flood cannot turn the observability trail into a write amplifier. +const DECLINE_TRACE_DAY_MAX = 20 const SERVICE_WINDOW_MS = 24 * 60 * 60 * 1000 @@ -97,24 +98,65 @@ function buildServiceClient(): SupabaseClient { // ── Unknown senders ────────────────────────────────────────── -async function greetingThrottled( +/** + * Metadata-only trace row for an inbound message from an unknown sender + * (issue #1552): direction, wamid, phone hash, type, disposition. NO body, + * NO media reference, NO raw payload, NO profile name: the trace must never + * become content persistence for someone who has not linked. phone_link_id + * stays null, so the 30-day unknown-sender retention pass deletes these + * wholesale. + * + * Returns 'recorded', 'duplicate' (wamid already persisted: a Meta + * redelivery of a message we already handled), or 'skipped' (trace cap or + * insert failure: observability must never break the webhook). + */ +async function recordUnknownSenderMessage( supabase: SupabaseClient, + msg: ParsedInboundMessage, phoneHash: string, -): Promise { - const since = new Date(Date.now() - GREETING_DAY_MS).toISOString() - const { data } = await supabase - .from('whatsapp_messages') - .select('created_at') - .eq('direction', 'outbound') - .eq('sender_phone_hash', phoneHash) - .eq('raw_payload->>template', TEMPLATE.m1Unlinked) - .gte('created_at', since) - .order('created_at', { ascending: false }) - .limit(GREETING_DAY_MAX) - const rows = (data ?? []) as Array<{ created_at: string }> - if (rows.length >= GREETING_DAY_MAX) return true - const hourAgo = Date.now() - GREETING_HOUR_MS - return rows.some((r) => new Date(r.created_at).getTime() > hourAgo) + status: 'skipped' | 'done', + disposition: string, +): Promise<'recorded' | 'duplicate' | 'skipped'> { + try { + // The day cap guards the one unbounded path: 'skipped' declines from an + // over-quota flood. 'done' traces (a reply went out) skip it: they are + // already bounded upstream (M1 by the greeting throttle, M2 by the + // pre-binding quota), and capping them would let a redelivered wamid + // past the dedupe below into a second reply. Best-effort under + // concurrency: racing webhooks can overshoot by a few rows, which is + // fine for a metadata trail. + if (status === 'skipped') { + const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString() + const { count } = await supabase + .from('whatsapp_messages') + .select('id', { count: 'exact', head: true }) + .eq('direction', 'inbound') + .eq('sender_phone_hash', phoneHash) + .is('phone_link_id', null) + .gte('created_at', since) + if ((count ?? 0) >= DECLINE_TRACE_DAY_MAX) return 'skipped' + } + + const { error } = await supabase.from('whatsapp_messages').insert({ + direction: 'inbound', + wamid: msg.wamid, + sender_phone_hash: phoneHash, + phone_link_id: null, + conversation_id: null, + message_type: msg.type, + processing_status: status, + error_message: disposition, + }) + if (!error) return 'recorded' + if (error.code === '23505') return 'duplicate' + log.warn('Failed to record unknown-sender trace row', { error: error.message, disposition }) + return 'skipped' + } catch (err) { + log.warn('Unknown-sender trace write errored', { + error: err instanceof Error ? err.message : String(err), + }) + return 'skipped' + } } /** @@ -137,16 +179,33 @@ async function handleUnknownSender( ) if (quotaError) { // Fail closed for unknown senders: without the limiter we send nothing. + // The decline itself is still recorded (#1552): support must be able to + // answer "what happened to my message" even for this path. log.warn('sender quota RPC failed; staying silent', { error: quotaError.message }) + await recordUnknownSenderMessage( + supabase, msg, phoneHash, 'skipped', 'Unknown sender: quota check failed, declined fail-closed', + ) + return + } + if ((quota as { ok?: boolean } | null)?.ok === false) { + await recordUnknownSenderMessage( + supabase, msg, phoneHash, 'skipped', 'Unknown sender: over pre-binding quota, declined', + ) return } - if ((quota as { ok?: boolean } | null)?.ok === false) return const copy = botCopy('sv') if (msg.type === 'text' && looksLikeLinkCode(msg.text)) { const consumed = await consumeLinkCode(supabase, msg.text ?? '') if (!consumed) { + // Trace row first: a Meta redelivery of a message already answered + // (including a redelivered CONSUMED code, whose row the success path + // wrote) dedupes on the wamid instead of earning a second reply. + const traced = await recordUnknownSenderMessage( + supabase, msg, phoneHash, 'done', 'Unknown sender: invalid link code, M2 sent', + ) + if (traced === 'duplicate') return await sendText(supabase, { to: msg.from, body: copy.m2BadCode(), @@ -203,7 +262,16 @@ async function handleUnknownSender( // Anything else from an unknown number: the AI-disclosure greeting, hard // throttled per phone hash (EU AI Act Art 50 disclosure lives in M1). - if (await greetingThrottled(supabase, phoneHash)) return + if (await greetingThrottled(supabase, phoneHash)) { + await recordUnknownSenderMessage( + supabase, msg, phoneHash, 'skipped', 'Unknown sender: greeting throttled, declined', + ) + return + } + const traced = await recordUnknownSenderMessage( + supabase, msg, phoneHash, 'done', 'Unknown sender: greeted, M1 sent', + ) + if (traced === 'duplicate') return await sendText(supabase, { to: msg.from, body: copy.m1Unlinked(), @@ -214,6 +282,16 @@ async function handleUnknownSender( // ── Linked senders ─────────────────────────────────────────── +/** Why a message earns deliberate silence. Persisted on the skipped row + * (issue #1552) so support can answer "what happened" per message. */ +type SilenceReason = 'muted' | 'stale_interactive' | 'ignorable_type' + +const SILENCE_REASON_TEXT: Record = { + muted: 'Muted by stopp: message not read', + stale_interactive: 'Stale interactive tap after the question closed', + ignorable_type: 'Ignorable message type, no reply expected', +} + type Disposition = | { kind: 'media' } | { kind: 'stop' } @@ -228,7 +306,7 @@ type Disposition = | { kind: 'voice' } | { kind: 'unsupported' } | { kind: 'fallback' } - | { kind: 'silence' } + | { kind: 'silence'; reason: SilenceReason } /** Dispositions with a durable side effect (mute, pin, company routing). * Their effect runs BEFORE the terminal row is written: see @@ -256,7 +334,9 @@ function classify( const normalized = (msg.text ?? '').trim().toLowerCase() if (muted) { // While muted only `start` is recognized; everything else is silence. - return normalized === START_KEYWORD ? { kind: 'start' } : { kind: 'silence' } + return normalized === START_KEYWORD + ? { kind: 'start' } + : { kind: 'silence', reason: 'muted' } } if (STOP_KEYWORDS.has(normalized)) return { kind: 'stop' } if (HELP_KEYWORDS.has(normalized)) return { kind: 'help' } @@ -284,13 +364,13 @@ function classify( // reply or the most recent open one); resolved below. return { kind: 'text_open' } } - if (muted) return { kind: 'silence' } + if (muted) return { kind: 'silence', reason: 'muted' } if (msg.type === 'interactive') { if ((conversation?.state === 'awaiting_company' || companyChoiceOpen) && msg.interactiveReplyId) { return { kind: 'company_interactive', companyId: msg.interactiveReplyId } } // Stale button tap after the question closed: silence beats lecturing. - return { kind: 'silence' } + return { kind: 'silence', reason: 'stale_interactive' } } if (msg.type === 'image' || msg.type === 'document') { return msg.media ? { kind: 'media' } : { kind: 'unsupported' } @@ -301,7 +381,7 @@ function classify( } // Truly unknown types (reactions, ephemeral, future additions): stay // silent rather than lecture someone for sending a thumbs-up. - return { kind: 'silence' } + return { kind: 'silence', reason: 'ignorable_type' } } /** True when this inbound wamid was already persisted (Meta redelivery). */ @@ -520,6 +600,9 @@ async function handleLinkedSender( // decrypt the link instead (resolveRecipient). raw_payload: keepContent ? redactRawPayload(msg.raw) : null, processing_status: initialStatus, + // Deliberate silences carry their reason (#1552): a skipped row with + // no explanation is exactly the blind spot this exists to close. + error_message: disposition.kind === 'silence' ? SILENCE_REASON_TEXT[disposition.reason] : null, correlation_id: correlationId, }) .select('id') @@ -637,12 +720,24 @@ export const whatsappInboxExtension: Extension = { const supabase = buildServiceClient() // Outbound delivery lifecycle updates (sent -> delivered -> read). + // A 'failed' status carries Meta's error detail (e.g. undeliverable, + // recipient unavailable): keep it on the row (#1552), it is the only + // record of WHY a reply never reached the sender. Two literal + // payloads so the phantom-column scanner can verify both shapes. for (const status of parsed.statuses) { - await supabase - .from('whatsapp_messages') - .update({ delivery_status: status.status }) - .eq('wamid', status.wamid) - .eq('direction', 'outbound') + if (status.status === 'failed' && status.errorDetail) { + await supabase + .from('whatsapp_messages') + .update({ delivery_status: status.status, error_message: status.errorDetail }) + .eq('wamid', status.wamid) + .eq('direction', 'outbound') + } else { + await supabase + .from('whatsapp_messages') + .update({ delivery_status: status.status }) + .eq('wamid', status.wamid) + .eq('direction', 'outbound') + } } const deferredMessageIds: string[] = [] @@ -727,18 +822,49 @@ export const whatsappInboxExtension: Extension = { if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) const { data } = await ctx.supabase .from('whatsapp_phone_links') - .select('phone_masked, default_company_id, muted_at, verified_at') + .select('id, phone_masked, default_company_id, muted_at, verified_at') .eq('user_id', ctx.userId) .is('revoked_at', null) .maybeSingle() if (!data) return NextResponse.json({ data: { linked: false } }) const row = data as { + id: string phone_masked: string default_company_id: string | null muted_at: string | null verified_at: string } + + // Last inbound event + last reply delivery (#1552): the panel answers + // "when did you last hear from me and what happened". whatsapp_messages + // is service-role only, so read with the service client, keyed strictly + // by the link row RLS just proved the caller owns. Only a closed enum + // and timestamps leave the server, never message content. + const serviceClient = createServiceClient() + const { data: lastInbound } = await serviceClient + .from('whatsapp_messages') + .select('created_at, processing_status, error_message, inbox_item_id') + .eq('phone_link_id', row.id) + .eq('direction', 'inbound') + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle() + const { data: lastOutbound } = await serviceClient + .from('whatsapp_messages') + .select('delivery_status') + .eq('phone_link_id', row.id) + .eq('direction', 'outbound') + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle() + + const inboundRow = lastInbound as { + created_at: string + processing_status: string + error_message: string | null + inbox_item_id: string | null + } | null return NextResponse.json({ data: { linked: true, @@ -746,6 +872,11 @@ export const whatsappInboxExtension: Extension = { defaultCompanyId: row.default_company_id, muted: row.muted_at != null, verifiedAt: row.verified_at, + lastInboundAt: inboundRow?.created_at ?? null, + lastInboundEvent: inboundRow ? summarizeInboundEvent(inboundRow) : null, + lastReplyFailed: + (lastOutbound as { delivery_status: string | null } | null)?.delivery_status === + 'failed', }, }) }, diff --git a/extensions/general/whatsapp-inbox/lib/conversation.ts b/extensions/general/whatsapp-inbox/lib/conversation.ts index c7709c5a..aae05cdf 100644 --- a/extensions/general/whatsapp-inbox/lib/conversation.ts +++ b/extensions/general/whatsapp-inbox/lib/conversation.ts @@ -26,9 +26,44 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { createLogger } from '@/lib/logger' import type { WhatsAppConversation, WhatsAppMessage } from '@/types' import { decryptPhone } from './phone-crypto' +import { TEMPLATE } from './messages' const log = createLogger('whatsapp-inbox/conversation') +// The M1 "not linked" greeting is hard throttled per phone hash: 1/hour, +// 3/day, then silence. Shared by the unknown-sender path (webhook) and the +// revoked-mid-processing path (process-inbound), so it lives here. +const GREETING_HOUR_MS = 60 * 60 * 1000 +const GREETING_DAY_MS = 24 * 60 * 60 * 1000 +const GREETING_DAY_MAX = 3 + +/** True when another M1 greeting to this phone hash would exceed the cap. + * Fails CLOSED: if the throttle window cannot be read, no greeting goes + * out, matching the unknown-sender quota's stance. */ +export async function greetingThrottled( + supabase: SupabaseClient, + phoneHash: string, +): Promise { + const since = new Date(Date.now() - GREETING_DAY_MS).toISOString() + const { data, error } = await supabase + .from('whatsapp_messages') + .select('created_at') + .eq('direction', 'outbound') + .eq('sender_phone_hash', phoneHash) + .eq('raw_payload->>template', TEMPLATE.m1Unlinked) + .gte('created_at', since) + .order('created_at', { ascending: false }) + .limit(GREETING_DAY_MAX) + if (error) { + log.warn('greeting throttle window unreadable; staying silent', { error: error.message }) + return true + } + const rows = (data ?? []) as Array<{ created_at: string }> + if (rows.length >= GREETING_DAY_MAX) return true + const hourAgo = Date.now() - GREETING_HOUR_MS + return rows.some((r) => new Date(r.created_at).getTime() > hourAgo) +} + export const COMPANY_PIN_TTL_MS = 8 * 60 * 60 * 1000 export const QUESTION_TTL_MS = 48 * 60 * 60 * 1000 export const LATE_ANSWER_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 diff --git a/extensions/general/whatsapp-inbox/lib/graph-api.ts b/extensions/general/whatsapp-inbox/lib/graph-api.ts index b4b055a6..204fe094 100644 --- a/extensions/general/whatsapp-inbox/lib/graph-api.ts +++ b/extensions/general/whatsapp-inbox/lib/graph-api.ts @@ -69,6 +69,8 @@ export interface SendTextArgs extends SendMessageBase { export interface SendTextResult { ok: boolean wamid: string | null + /** Why the send failed, for the outbound row (#1552). Null on success. */ + errorDetail: string | null } /** POST one message payload to the Graph API. Never throws. */ @@ -78,6 +80,7 @@ async function postToGraph( ): Promise { let wamid: string | null = null let ok = false + let errorDetail: string | null = null try { const response = await fetchWithTimeout( @@ -101,6 +104,7 @@ async function postToGraph( ok = true } else { const detail = await response.text().catch(() => '') + errorDetail = `Send failed (HTTP ${response.status}): ${detail.slice(0, 250)}` log.warn('WhatsApp send failed', { status: response.status, template, @@ -108,13 +112,12 @@ async function postToGraph( }) } } catch (err) { - log.warn('WhatsApp send errored', { - template, - error: err instanceof Error ? err.message : String(err), - }) + const message = err instanceof Error ? err.message : String(err) + errorDetail = `Send errored: ${message.slice(0, 250)}` + log.warn('WhatsApp send errored', { template, error: message }) } - return { ok, wamid } + return { ok, wamid, errorDetail } } /** Persist the outbound message row. Never throws. */ @@ -136,6 +139,9 @@ async function persistOutbound( // Outbound rows are not jobs: mark done so the sweep never claims them. processing_status: 'done', delivery_status: result.ok ? 'sent' : 'failed', + // A failed reply used to be indistinguishable from a delivered one at + // the row level (#1552): keep the Graph error on the record. + error_message: result.ok ? null : (result.errorDetail ?? 'Send failed'), correlation_id: args.correlationId ?? null, inbox_item_id: args.inboxItemId ?? null, }) diff --git a/extensions/general/whatsapp-inbox/lib/last-event.ts b/extensions/general/whatsapp-inbox/lib/last-event.ts new file mode 100644 index 00000000..48e6b3f0 --- /dev/null +++ b/extensions/general/whatsapp-inbox/lib/last-event.ts @@ -0,0 +1,51 @@ +/** + * Compact, content-free summary of the most recent inbound message on a + * phone link, for the settings panel (issue #1552): when we last heard from + * this number and what happened to that message. Derives a closed enum from + * processing_status + error_message so no internal error text reaches the + * client; the panel translates the enum through i18n. + */ + +export type LastInboundEventKind = + | 'filed' // done with an inbox item: the receipt landed in Underlag + | 'handled' // done without an item: command/answer/link code handled + | 'processing' // received or claimed, still in flight + | 'awaiting_company' // parked behind the open company question + | 'duplicate' // same file already exists in the company's Underlag + | 'rate_limited' // company intake quota declined the file + | 'unsupported' // media type outside the chat allowlist + | 'muted' // dropped because the sender paused the channel (stopp) + | 'declined' // other deliberate silence (stale tap, ignorable type) + | 'failed' // processing error (the M18 path) + +export interface LastInboundEventRow { + processing_status: string + error_message: string | null + inbox_item_id: string | null +} + +// error_message markers written by index.ts / process-inbound.ts / +// conversation.ts. Matched by prefix where the writer appends detail. +import { COMPANY_CHOICE_EXPIRED, STAGED_AWAITING_COMPANY } from './conversation' + +export function summarizeInboundEvent(row: LastInboundEventRow): LastInboundEventKind { + switch (row.processing_status) { + case 'done': + return row.inbox_item_id ? 'filed' : 'handled' + case 'received': + case 'processing': + return 'processing' + case 'skipped': { + const reason = row.error_message ?? '' + if (reason === STAGED_AWAITING_COMPANY) return 'awaiting_company' + if (reason === COMPANY_CHOICE_EXPIRED) return 'failed' + if (reason.startsWith('Duplicate document')) return 'duplicate' + if (reason === 'Rate limited') return 'rate_limited' + if (reason.startsWith('Unsupported media type')) return 'unsupported' + if (reason.startsWith('Muted')) return 'muted' + return 'declined' + } + default: + return 'failed' + } +} diff --git a/extensions/general/whatsapp-inbox/lib/process-inbound.ts b/extensions/general/whatsapp-inbox/lib/process-inbound.ts index cf5fe850..8fd66028 100644 --- a/extensions/general/whatsapp-inbox/lib/process-inbound.ts +++ b/extensions/general/whatsapp-inbox/lib/process-inbound.ts @@ -49,6 +49,7 @@ import { extractRecipient, getContext, getOrCreateConversation, + greetingThrottled, hasLivePin, loadConversation, markRecentQuestion, @@ -393,6 +394,16 @@ async function processMediaMessage( await markStatus(supabase, row.id, 'error', { errorMessage: 'Message row is missing link or media reference', }) + // Not policy silence (#1552): a linked sender whose row lost its media + // reference gets the failure owned out loud, when a reply address can + // still be resolved through the link. + if (row.phone_link_id) { + const link = await loadLink(supabase, row.phone_link_id) + const fallbackTo = link && !link.revoked_at ? resolveRecipient(row, link) : null + if (fallbackTo) { + await sendErrorNoticeOnce(supabase, { to: fallbackTo, ...replyBase }) + } + } return { kind: 'none' } } @@ -403,6 +414,23 @@ async function processMediaMessage( await markStatus(supabase, row.id, 'skipped', { errorMessage: 'Phone link revoked before processing', }) + // Revoked between arrival and processing is a failure to own, not + // policy silence (#1552). The number is unlinked NOW, so the accurate + // reply is the M1 "not linked" copy, throttled exactly like the + // unknown-sender greeting so a revoked-mid-burst sender gets one. + const fallbackTo = link ? resolveRecipient(row, link) : extractRecipient(row) + if ( + fallbackTo && + row.sender_phone_hash && + !(await greetingThrottled(supabase, row.sender_phone_hash)) + ) { + await sendText(supabase, { + to: fallbackTo, + body: copy.m1Unlinked(), + template: TEMPLATE.m1Unlinked, + ...replyBase, + }) + } return { kind: 'none' } } to = resolveRecipient(row, link) diff --git a/extensions/general/whatsapp-inbox/lib/webhook-parse.ts b/extensions/general/whatsapp-inbox/lib/webhook-parse.ts index eb55b372..5a1d0726 100644 --- a/extensions/general/whatsapp-inbox/lib/webhook-parse.ts +++ b/extensions/general/whatsapp-inbox/lib/webhook-parse.ts @@ -52,6 +52,17 @@ const MessageSchema = z.object({ const StatusSchema = z.object({ id: z.string().min(1).max(200), status: z.string().max(40), + // Present on 'failed' statuses: the only record of WHY a message never + // reached the recipient (undeliverable, blocked, re-engagement needed). + errors: z + .array( + z.object({ + code: z.union([z.number(), z.string()]).optional(), + title: z.string().max(300).optional(), + message: z.string().max(300).optional(), + }), + ) + .optional(), }) const ContactSchema = z.object({ @@ -125,6 +136,8 @@ export interface ParsedInboundMessage { export interface ParsedStatus { wamid: string status: string + /** Compact "code: title" from the first status error, when Meta sent one. */ + errorDetail: string | null } export interface ParsedWebhook { @@ -225,7 +238,15 @@ export function parseWebhookEnvelope(body: unknown): ParsedWebhook { for (const rawStatus of value.statuses ?? []) { const parsed = StatusSchema.safeParse(rawStatus) - if (parsed.success) result.statuses.push({ wamid: parsed.data.id, status: parsed.data.status }) + if (!parsed.success) continue + const firstError = parsed.data.errors?.[0] + const errorDetail = firstError + ? [firstError.code, firstError.title ?? firstError.message] + .filter((part) => part != null && String(part).length > 0) + .join(': ') + .slice(0, 300) || null + : null + result.statuses.push({ wamid: parsed.data.id, status: parsed.data.status, errorDetail }) } } } diff --git a/messages/en.json b/messages/en.json index 2b21a48c..b15a9811 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3060,7 +3060,19 @@ "revoke_note": "Your documents in Accounted are not affected.", "revoke_confirm": "Disconnect your WhatsApp number? You can link it again at any time with a new code.", "revoked_toast": "The number is disconnected.", - "revoke_failed": "Could not disconnect the number." + "revoke_failed": "Could not disconnect the number.", + "last_event_label": "Latest message", + "last_event_filed": "the receipt is in Underlag", + "last_event_handled": "handled", + "last_event_processing": "being processed", + "last_event_awaiting_company": "waiting for a company choice in the chat", + "last_event_duplicate": "skipped: the same file already exists", + "last_event_rate_limited": "skipped: too many files", + "last_event_unsupported": "unsupported file type", + "last_event_muted": "ignored: the chat is paused", + "last_event_declined": "not answered", + "last_event_failed": "could not be received", + "last_reply_failed": "Our latest reply could not be delivered in WhatsApp. Check that the number can receive messages from Accounted." }, "inbox_bulk_book": { "total_label": "Total for the documents: {amount}", diff --git a/messages/sv.json b/messages/sv.json index 63e406a1..0d4608a9 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -3060,7 +3060,19 @@ "revoke_note": "Dina underlag i Accounted påverkas inte.", "revoke_confirm": "Vill du koppla från ditt WhatsApp-nummer? Du kan koppla det igen när som helst med en ny kod.", "revoked_toast": "Numret är frånkopplat.", - "revoke_failed": "Kunde inte koppla från numret." + "revoke_failed": "Kunde inte koppla från numret.", + "last_event_label": "Senaste meddelande", + "last_event_filed": "kvittot ligger i Underlag", + "last_event_handled": "hanterat", + "last_event_processing": "behandlas", + "last_event_awaiting_company": "väntar på företagsval i chatten", + "last_event_duplicate": "hoppades över: samma fil finns redan", + "last_event_rate_limited": "hoppades över: för många filer", + "last_event_unsupported": "filtypen stöds inte", + "last_event_muted": "ignorerades: chatten är pausad", + "last_event_declined": "besvarades inte", + "last_event_failed": "kunde inte tas emot", + "last_reply_failed": "Vårt senaste svar kunde inte levereras i WhatsApp. Kontrollera att numret kan ta emot meddelanden från Accounted." }, "inbox_bulk_book": { "total_label": "Underlagens summa: {amount}",