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:
Jakob Wennberg
2026-08-13 16:02:16 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 73c63209f1
commit 6c64dd2312
18 changed files with 573 additions and 69 deletions
+1
View File
@@ -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)
})
})
+164 -33
View File
@@ -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
View File
@@ -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
View File
@@ -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}",