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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
73c63209f1
commit
6c64dd2312
@@ -919,3 +919,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
|
||||
|
||||
@@ -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() {
|
||||
</SettingsRow>
|
||||
) : null}
|
||||
|
||||
{status.lastInboundAt ? (
|
||||
<SettingsRow label={t('last_event_label')}>
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm">
|
||||
{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',
|
||||
)}
|
||||
</span>
|
||||
{status.lastReplyFailed ? <AttnLine>{t('last_reply_failed')}</AttnLine> : null}
|
||||
</div>
|
||||
</SettingsRow>
|
||||
) : null}
|
||||
|
||||
<SettingsRow label={t('default_company_label')} help={t('default_company_help')}>
|
||||
<SettingsSelect
|
||||
aria-label={t('default_company_label')}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 }),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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<string, unknown>]
|
||||
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<string, unknown>]
|
||||
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 () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, unknown>]
|
||||
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<string, unknown>]
|
||||
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<string, unknown>]
|
||||
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<string, unknown>]
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<SilenceReason, string> = {
|
||||
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',
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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<boolean> {
|
||||
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
|
||||
|
||||
@@ -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<SendTextResult> {
|
||||
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,
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-1
@@ -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}",
|
||||
|
||||
+13
-1
@@ -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}",
|
||||
|
||||
Reference in New Issue
Block a user