feat(whatsapp-inbox): intake extension with webhook, phone linking and receipt ack (#1338)

Webhook lifecycle: GET hub.challenge handshake (constant-time verify-token
compare); POST verifies X-Hub-Signature-256 over the RAW body before any
parse, Zod-parses the envelope, persists inbound rows (partial-unique wamid
= dedupe against Meta's up-to-7-day redelivery), acks 200 fast and defers
media processing via the after() idiom. Rejected and rate-limited content
always acks 200 and lands as skipped/error rows, never a retryable status.

Linking: the settings panel (Installningar -> WhatsApp) mints AC- one-time
codes (sha256 stored, 10 min TTL, single use, ambiguity-free alphabet); the
webhook consumes the code, binds phone to user (HMAC-peppered hash + AES-256-
GCM at rest) and confirms with M3. Keyword commands stopp/start/hjalp;
unknown senders get one throttled M1 greeting (1/h, 3/day) behind the
sender-quota RPC, with no media download and no content persistence.

Intake worker: atomic claim on the message row (the durable job record),
company resolution (default -> sole membership -> M6 fallback, no item),
per-company inbox quota (ack-and-drop, M17 once per 10 min per sender),
MIME allowlist, 10 MB stream-checked media download, exact sha256 duplicate
check, then the shared uploadAndExtract funnel (source 'whatsapp',
channel_context caption, whatsapp_message_id) and the M4 ack with extracted
merchant/total/date. Failures wrap to 'error' + error_message + one M18.

uploadAndExtract widened: source 'whatsapp', optional channelMeta + actorId;
email/upload paths behaviorally unchanged.

Deferred to PR4: burst debounce + combined ack (M5), in-chat company choice
(M6 buttons + 8h pin), clarifying questions M7-M10, interpret-answer LLM
call, sweep cron, retention cron.

Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-05 14:47:08 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent bd3592cb99
commit 398c734b93
33 changed files with 3825 additions and 11 deletions
@@ -109,6 +109,24 @@ export interface EmailMeta {
resendAttachmentId?: string | null
}
// Chat-channel provenance (whatsapp-inbox extension). When present, the inbox
// row links back to the delivering whatsapp_messages row and seeds
// channel_context (kept OUT of extracted_data: retry-extraction overwrites
// that container wholesale, and chat context must survive it).
export interface ChannelMeta {
whatsappMessageId?: string
caption?: string | null
}
// Captions are attacker-adjacent free text from a chat client: strip control
// characters and cap length before they land in a jsonb column read by the UI.
function sanitiseCaption(raw: string | null | undefined): string | null {
if (!raw) return null
// eslint-disable-next-line no-control-regex
const cleaned = raw.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').trim().slice(0, 500)
return cleaned || null
}
// ── Shared helper: upload + extract + create inbox item ──────
export async function uploadAndExtract(
@@ -116,14 +134,20 @@ export async function uploadAndExtract(
userId: string,
companyId: string,
file: { name: string; buffer: ArrayBuffer; type: string },
source: 'upload' | 'email',
source: 'upload' | 'email' | 'whatsapp',
emailMeta?: EmailMeta,
// Pre-match the new inbox item to a bank transaction. Set when the caller
// already knows which transaction this receipt belongs to (e.g. the
// VerifyAndBookOverlay opened from a transaction row's paperclip or from
// a transaction-anchored chat). Skipped silently if missing.
matchedTransactionId?: string | null,
opts: { skipExtraction?: boolean } = {},
opts: {
skipExtraction?: boolean
channelMeta?: ChannelMeta
/** Overrides the system actor id on the DocumentIngested history event.
* Omitted = today's behavior (resend-inbound for email, user otherwise). */
actorId?: string
} = {},
) {
const correlationId = crypto.randomUUID()
@@ -132,7 +156,7 @@ export async function uploadAndExtract(
buffer: file.buffer,
type: file.type,
}, {
upload_source: source === 'email' ? 'email' : 'file_upload',
upload_source: source === 'email' ? 'email' : source === 'whatsapp' ? 'whatsapp' : 'file_upload',
})
try {
@@ -148,7 +172,11 @@ export async function uploadAndExtract(
mime_type: file.type,
size_bytes: file.buffer.byteLength,
},
actor: source === 'email' ? { type: 'system', id: 'resend-inbound' } : { type: 'user', id: userId },
actor: opts.actorId
? { type: 'system', id: opts.actorId }
: source === 'email'
? { type: 'system', id: 'resend-inbound' }
: { type: 'user', id: userId },
occurredAt: new Date(),
})
} catch (err) {
@@ -256,6 +284,13 @@ export async function uploadAndExtract(
: null,
correlation_id: correlationId,
matched_transaction_id: matchedTransactionId ?? null,
// Chat-channel provenance. Explicit nulls (not a conditional spread)
// keep the payload statically checkable; a null insert is identical to
// omitting the column, so the email/upload behavior is unchanged.
whatsapp_message_id: opts.channelMeta?.whatsappMessageId ?? null,
channel_context: opts.channelMeta
? { channel: 'whatsapp', caption: sanitiseCaption(opts.channelMeta.caption) }
: null,
})
.select('*')
.single()
@@ -0,0 +1,201 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { TimeoutError } from '@/lib/http/fetch-with-timeout'
import {
sendText,
downloadMedia,
getDisplayPhoneNumber,
resetDisplayNumberCacheForTests,
GraphApiError,
MAX_MEDIA_BYTES,
} from '@/extensions/general/whatsapp-inbox/lib/graph-api'
import { TEMPLATE } from '@/extensions/general/whatsapp-inbox/lib/messages'
import type { SupabaseClient } from '@supabase/supabase-js'
const fetchMock = vi.fn()
describe('graph-api', () => {
const originalEnv = { ...process.env }
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('fetch', fetchMock)
resetDisplayNumberCacheForTests()
process.env.WHATSAPP_ACCESS_TOKEN = 'token-1'
process.env.WHATSAPP_PHONE_NUMBER_ID = '111222333'
})
afterEach(() => {
vi.unstubAllGlobals()
process.env = { ...originalEnv }
})
describe('sendText', () => {
it('sends and persists an outbound row with the response wamid', async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ messages: [{ id: 'wamid.OUT1' }] }), { status: 200 }),
)
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: null, error: null })
const result = await sendText(supabase as unknown as SupabaseClient, {
to: '46701234567',
body: 'Hej!',
template: TEMPLATE.m16Fallback,
senderPhoneHash: 'hash-1',
})
expect(result).toEqual({ ok: true, wamid: 'wamid.OUT1' })
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.processing_status).toBe('done')
expect(row.raw_payload).toEqual({ template: TEMPLATE.m16Fallback })
expect(row.sender_phone_hash).toBe('hash-1')
// The Graph call itself
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toContain('/111222333/messages')
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer token-1')
expect(JSON.parse(init.body as string).text.body).toBe('Hej!')
})
it('never throws on non-2xx and records a failed row', async () => {
fetchMock.mockResolvedValueOnce(new Response('{"error":{}}', { status: 500 }))
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: null, error: null })
const result = await sendText(supabase as unknown as SupabaseClient, {
to: '46701234567',
body: 'Hej!',
template: TEMPLATE.m18Error,
})
expect(result).toEqual({ ok: false, wamid: null })
const [row] = findCall('whatsapp_messages', 'insert') as [Record<string, unknown>]
expect(row.delivery_status).toBe('failed')
expect(row.wamid).toBeNull()
})
it('never throws on a network error', async () => {
fetchMock.mockRejectedValueOnce(new Error('ECONNRESET'))
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null })
const result = await sendText(supabase as unknown as SupabaseClient, {
to: '46701234567',
body: 'Hej!',
template: TEMPLATE.m18Error,
})
expect(result.ok).toBe(false)
})
})
describe('downloadMedia', () => {
it('resolves the media id, downloads with Bearer auth, and returns the bytes', async () => {
const bytes = new Uint8Array([1, 2, 3, 4])
fetchMock
.mockResolvedValueOnce(
new Response(
JSON.stringify({ url: 'https://lookaside.example/m1', mime_type: 'image/jpeg' }),
{ status: 200 },
),
)
.mockResolvedValueOnce(new Response(bytes, { status: 200 }))
const media = await downloadMedia('media-1')
expect(media.mime).toBe('image/jpeg')
expect(media.fileSize).toBe(4)
expect(new Uint8Array(media.buffer)).toEqual(bytes)
const [downloadUrl, downloadInit] = fetchMock.mock.calls[1] as [string, RequestInit]
expect(downloadUrl).toBe('https://lookaside.example/m1')
expect((downloadInit.headers as Record<string, string>).Authorization).toBe('Bearer token-1')
})
it('rejects a non-2xx lookup', async () => {
fetchMock.mockResolvedValueOnce(new Response('{}', { status: 404 }))
await expect(downloadMedia('media-1')).rejects.toThrow(GraphApiError)
})
it('rejects a non-2xx download', async () => {
fetchMock
.mockResolvedValueOnce(
new Response(JSON.stringify({ url: 'https://lookaside.example/m1' }), { status: 200 }),
)
.mockResolvedValueOnce(new Response('gone', { status: 410 }))
await expect(downloadMedia('media-1')).rejects.toThrow(/download failed/)
})
it('rejects when the declared file size exceeds the cap', async () => {
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({ url: 'https://x/m1', file_size: MAX_MEDIA_BYTES + 1 }),
{ status: 200 },
),
)
await expect(downloadMedia('media-1')).rejects.toThrow(/size limit/)
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('rejects via the content-length header before reading the body', async () => {
fetchMock
.mockResolvedValueOnce(
new Response(JSON.stringify({ url: 'https://x/m1' }), { status: 200 }),
)
.mockResolvedValueOnce(
new Response('x', {
status: 200,
headers: { 'content-length': String(MAX_MEDIA_BYTES + 1) },
}),
)
await expect(downloadMedia('media-1')).rejects.toThrow(/size limit/)
})
it('rejects an oversized stream even without a content-length header', async () => {
const chunk = new Uint8Array(6 * 1024 * 1024)
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(chunk)
controller.enqueue(chunk) // 12 MB total > 10 MB cap
controller.close()
},
})
fetchMock
.mockResolvedValueOnce(
new Response(JSON.stringify({ url: 'https://x/m1' }), { status: 200 }),
)
.mockResolvedValueOnce(new Response(stream, { status: 200 }))
await expect(downloadMedia('media-1')).rejects.toThrow(/size limit/)
})
it('propagates timeouts as TimeoutError', async () => {
fetchMock.mockRejectedValueOnce(new TimeoutError('WhatsApp media lookup timed out'))
await expect(downloadMedia('media-1')).rejects.toThrow(TimeoutError)
})
it('throws when the access token is missing', async () => {
delete process.env.WHATSAPP_ACCESS_TOKEN
await expect(downloadMedia('media-1')).rejects.toThrow(/WHATSAPP_ACCESS_TOKEN/)
})
})
describe('getDisplayPhoneNumber', () => {
it('resolves and caches the display number as digits', async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ display_phone_number: '+46 10 123 45 67' }), { status: 200 }),
)
expect(await getDisplayPhoneNumber()).toBe('46101234567')
// Cached: second call issues no fetch.
expect(await getDisplayPhoneNumber()).toBe('46101234567')
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('returns null on failure instead of throwing', async () => {
fetchMock.mockResolvedValueOnce(new Response('{}', { status: 500 }))
expect(await getDisplayPhoneNumber()).toBeNull()
})
})
})
@@ -0,0 +1,173 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import crypto from 'crypto'
import { createQueuedMockSupabase } from '@/tests/helpers'
import {
CODE_ALPHABET,
normalizeLinkCode,
looksLikeLinkCode,
hashLinkCode,
mintLinkCode,
consumeLinkCode,
} from '@/extensions/general/whatsapp-inbox/lib/linking'
import type { SupabaseClient } from '@supabase/supabase-js'
describe('linking', () => {
const originalEnv = { ...process.env }
beforeEach(() => {
vi.clearAllMocks()
process.env.WHATSAPP_PHONE_HASH_KEY = 'test-pepper'
process.env.WHATSAPP_PHONE_ENCRYPTION_KEY = 'a'.repeat(64)
})
afterEach(() => {
process.env = { ...originalEnv }
})
describe('normalizeLinkCode', () => {
it('accepts the canonical form', () => {
expect(normalizeLinkCode('AC-7KP4QF')).toBe('AC-7KP4QF')
})
it('uppercases and trims', () => {
expect(normalizeLinkCode(' ac-7kp4qf ')).toBe('AC-7KP4QF')
})
it('tolerates a missing prefix (when digits disambiguate) and inner whitespace', () => {
expect(normalizeLinkCode('7KP4QF')).toBe('AC-7KP4QF')
expect(normalizeLinkCode('AC 7KP4QF')).toBe('AC-7KP4QF')
// All-letter bodies are only accepted WITH the prefix: bare ones would
// collide with ordinary words.
expect(normalizeLinkCode('AC-QSTVWX')).toBe('AC-QSTVWX')
expect(normalizeLinkCode('QSTVWX')).toBeNull()
})
it('rejects ambiguous characters outside the alphabet', () => {
// 1, 0, I, O, L, U are excluded from the alphabet.
expect(normalizeLinkCode('AC-7KP4Q1')).toBeNull()
expect(normalizeLinkCode('AC-7KP4QO')).toBeNull()
})
it('rejects free text and wrong lengths', () => {
expect(normalizeLinkCode('hej hej')).toBeNull()
expect(normalizeLinkCode('hejhej')).toBeNull()
expect(normalizeLinkCode('AC-7KP4Q')).toBeNull()
expect(normalizeLinkCode('AC-7KP4QFF')).toBeNull()
expect(normalizeLinkCode('')).toBeNull()
expect(normalizeLinkCode(null)).toBeNull()
})
it('looksLikeLinkCode mirrors normalizeLinkCode', () => {
expect(looksLikeLinkCode('ac-7kp4qf')).toBe(true)
expect(looksLikeLinkCode('lunch med anna')).toBe(false)
})
})
describe('hashLinkCode', () => {
it('is sha256 hex of the code', () => {
const expected = crypto.createHash('sha256').update('AC-7KP4QF').digest('hex')
expect(hashLinkCode('AC-7KP4QF')).toBe(expected)
})
})
describe('mintLinkCode', () => {
it('mints an AC- prefixed code from the ambiguity-free alphabet with a 10 min TTL', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: null, error: null })
const before = Date.now()
const minted = await mintLinkCode(supabase as unknown as SupabaseClient, 'user-1')
expect(minted.code).toMatch(/^AC-[A-Z2-9]{6}$/)
for (const ch of minted.code.slice(3)) {
expect(CODE_ALPHABET).toContain(ch)
}
const ttlMs = new Date(minted.expiresAt).getTime() - before
expect(ttlMs).toBeGreaterThan(9 * 60 * 1000)
expect(ttlMs).toBeLessThanOrEqual(10 * 60 * 1000 + 5000)
const insertArgs = findCall('whatsapp_link_codes', 'insert') as [Record<string, unknown>]
expect(insertArgs[0].user_id).toBe('user-1')
expect(insertArgs[0].code_hash).toBe(hashLinkCode(minted.code))
})
it('throws when the insert fails', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: { message: 'boom' } })
await expect(
mintLinkCode(supabase as unknown as SupabaseClient, 'user-1'),
).rejects.toThrow(/boom/)
})
})
describe('consumeLinkCode', () => {
const futureExpiry = () => new Date(Date.now() + 5 * 60 * 1000).toISOString()
it('consumes a valid unused code', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: { id: 'code-1', user_id: 'user-1', expires_at: futureExpiry(), used_at: null } })
enqueue({ data: { id: 'code-1' } })
const result = await consumeLinkCode(supabase as unknown as SupabaseClient, 'ac-7kp4qf')
expect(result).toEqual({ userId: 'user-1' })
const updateArgs = findCall('whatsapp_link_codes', 'update') as [Record<string, unknown>]
expect(updateArgs[0].used_at).toBeTruthy()
})
it('rejects an expired code', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({
data: {
id: 'code-1',
user_id: 'user-1',
expires_at: new Date(Date.now() - 1000).toISOString(),
used_at: null,
},
})
const result = await consumeLinkCode(supabase as unknown as SupabaseClient, 'AC-7KP4QF')
expect(result).toBeNull()
expect(findCalls('whatsapp_link_codes', 'update')).toHaveLength(0)
})
it('rejects an already-used code (single use)', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({
data: {
id: 'code-1',
user_id: 'user-1',
expires_at: futureExpiry(),
used_at: new Date().toISOString(),
},
})
const result = await consumeLinkCode(supabase as unknown as SupabaseClient, 'AC-7KP4QF')
expect(result).toBeNull()
expect(findCalls('whatsapp_link_codes', 'update')).toHaveLength(0)
})
it('loses the claim race gracefully', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'code-1', user_id: 'user-1', expires_at: futureExpiry(), used_at: null } })
enqueue({ data: null }) // guarded update matched no row: someone else won
const result = await consumeLinkCode(supabase as unknown as SupabaseClient, 'AC-7KP4QF')
expect(result).toBeNull()
})
it('rejects unknown codes without touching anything', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: null })
const result = await consumeLinkCode(supabase as unknown as SupabaseClient, 'AC-7KP4QF')
expect(result).toBeNull()
expect(findCalls('whatsapp_link_codes', 'update')).toHaveLength(0)
})
it('short-circuits on non-code text without any DB call', async () => {
const { supabase, calls } = createQueuedMockSupabase()
const result = await consumeLinkCode(supabase as unknown as SupabaseClient, 'hej!')
expect(result).toBeNull()
expect(calls).toHaveLength(0)
})
})
})
@@ -0,0 +1,64 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import {
normalizePhone,
hashPhone,
encryptPhone,
decryptPhone,
maskPhone,
} from '@/extensions/general/whatsapp-inbox/lib/phone-crypto'
const HEX_KEY = 'a'.repeat(64)
describe('phone-crypto', () => {
const originalEnv = { ...process.env }
beforeEach(() => {
process.env.WHATSAPP_PHONE_HASH_KEY = 'test-pepper'
process.env.WHATSAPP_PHONE_ENCRYPTION_KEY = HEX_KEY
})
afterEach(() => {
process.env = { ...originalEnv }
})
it('normalizes to digits only', () => {
expect(normalizePhone('+46 70-123 45 67')).toBe('46701234567')
expect(normalizePhone('46701234567')).toBe('46701234567')
})
it('hashes deterministically and collides across formatting variants', () => {
const a = hashPhone('46701234567')
expect(a).toMatch(/^[0-9a-f]{64}$/)
expect(hashPhone('+46 70 123 45 67')).toBe(a)
expect(hashPhone('46701234568')).not.toBe(a)
})
it('changes the hash when the pepper changes', () => {
const a = hashPhone('46701234567')
process.env.WHATSAPP_PHONE_HASH_KEY = 'other-pepper'
expect(hashPhone('46701234567')).not.toBe(a)
})
it('throws without the hash key', () => {
delete process.env.WHATSAPP_PHONE_HASH_KEY
expect(() => hashPhone('46701234567')).toThrow(/WHATSAPP_PHONE_HASH_KEY/)
})
it('encrypts and decrypts roundtrip', () => {
const stored = encryptPhone('+46 70 123 45 67')
expect(stored).toMatch(/^[0-9a-f]+$/)
expect(decryptPhone(stored)).toBe('46701234567')
})
it('produces a fresh iv per encryption', () => {
expect(encryptPhone('46701234567')).not.toBe(encryptPhone('46701234567'))
})
it('masks in the +46 70 *** ** 67 shape', () => {
expect(maskPhone('46701234567')).toBe('+46 70 *** ** 67')
})
it('fully masks too-short values', () => {
expect(maskPhone('12345')).toBe('+** *** ** **')
})
})
@@ -0,0 +1,349 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { SupabaseClient } from '@supabase/supabase-js'
vi.mock('@/extensions/general/whatsapp-inbox/lib/graph-api', async () => {
const actual = await vi.importActual<
typeof import('@/extensions/general/whatsapp-inbox/lib/graph-api')
>('@/extensions/general/whatsapp-inbox/lib/graph-api')
return {
...actual,
sendText: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT' }),
markReadWithTyping: vi.fn().mockResolvedValue(undefined),
downloadMedia: vi.fn(),
}
})
vi.mock('@/extensions/general/invoice-inbox/lib/upload-and-extract', () => ({
uploadAndExtract: vi.fn(),
}))
vi.mock('@/lib/rate-limits/inbox', () => ({
checkInboxUploadRateLimit: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/processing-history/append', () => ({
appendProcessingHistory: vi.fn().mockResolvedValue('event-1'),
}))
vi.mock('@/lib/core/documents/document-service', () => ({
computeSHA256: vi.fn().mockResolvedValue('sha-abc'),
}))
import {
sendText,
markReadWithTyping,
downloadMedia,
GraphApiError,
} from '@/extensions/general/whatsapp-inbox/lib/graph-api'
import { uploadAndExtract } from '@/extensions/general/invoice-inbox/lib/upload-and-extract'
import { checkInboxUploadRateLimit } from '@/lib/rate-limits/inbox'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { processInboundMessage } from '@/extensions/general/whatsapp-inbox/lib/process-inbound'
import { TEMPLATE } from '@/extensions/general/whatsapp-inbox/lib/messages'
const sendTextMock = vi.mocked(sendText)
const downloadMediaMock = vi.mocked(downloadMedia)
const uploadAndExtractMock = vi.mocked(uploadAndExtract)
const rateLimitMock = vi.mocked(checkInboxUploadRateLimit)
const appendHistoryMock = vi.mocked(appendProcessingHistory)
function makeRow(overrides: Record<string, unknown> = {}) {
return {
id: 'msg-1',
direction: 'inbound',
wamid: 'wamid.IN1',
sender_phone_hash: 'hash-1',
phone_link_id: 'link-1',
conversation_id: 'conv-1',
message_type: 'image',
body_text: 'lunch med kund',
media_id: 'media-1',
media_mime: 'image/jpeg',
media_sha256: null,
media_filename: null,
raw_payload: { from: '46701234567' },
processing_status: 'received',
attempts: 0,
error_message: null,
inbox_item_id: null,
delivery_status: null,
correlation_id: 'corr-1',
created_at: '2026-08-01T10:00:00Z',
updated_at: '2026-08-01T10:00:00Z',
...overrides,
}
}
function makeLink(overrides: Record<string, unknown> = {}) {
return {
id: 'link-1',
user_id: 'user-1',
phone_hash: 'hash-1',
phone_enc: 'enc',
phone_masked: '+46 70 *** ** 67',
wa_profile_name: null,
default_company_id: null,
last_company_id: null,
verified_at: '2026-08-01T09:00:00Z',
revoked_at: null,
muted_at: null,
last_message_at: null,
created_at: '2026-08-01T09:00:00Z',
updated_at: '2026-08-01T09:00:00Z',
...overrides,
}
}
function lastUpdate(findCalls: (table: string, method: string) => unknown[][]): Record<string, unknown> {
const updates = findCalls('whatsapp_messages', 'update')
return updates[updates.length - 1][0] as Record<string, unknown>
}
describe('processInboundMessage', () => {
beforeEach(() => {
vi.clearAllMocks()
sendTextMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT' })
rateLimitMock.mockResolvedValue({ ok: true })
downloadMediaMock.mockResolvedValue({
buffer: new Uint8Array([1, 2, 3, 4]).buffer,
mime: 'image/jpeg',
fileSize: 4,
})
uploadAndExtractMock.mockResolvedValue({
document_id: 'doc-1',
inbox_item_id: 'item-1',
status: 'received',
extracted_data: {
supplier: { name: 'Espresso House' },
totals: { total: 450 },
invoice: { invoiceDate: '2026-07-30' },
},
matched_supplier_id: null,
matched_transaction_id: null,
extraction_skipped: false,
skip_reason: null,
page_count: null,
} as never)
})
afterEach(() => {
vi.clearAllMocks()
})
it('happy path: claims, downloads, funnels through uploadAndExtract and acks with M4', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: makeRow() }) // load row
enqueue({ data: { id: 'msg-1' } }) // claim
enqueue({ data: makeLink() }) // load link
enqueue({ data: [{ company_id: 'company-1' }] }) // sole membership
enqueue({ data: null }) // sha256 dup check: none
enqueue({ data: null }) // final markStatus done
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(markReadWithTyping).toHaveBeenCalledWith('wamid.IN1')
expect(downloadMediaMock).toHaveBeenCalledWith('media-1')
expect(uploadAndExtractMock).toHaveBeenCalledTimes(1)
expect(uploadAndExtractMock).toHaveBeenCalledWith(
supabase,
'user-1',
'company-1',
expect.objectContaining({ type: 'image/jpeg' }),
'whatsapp',
undefined,
undefined,
{
channelMeta: { whatsappMessageId: 'msg-1', caption: 'lunch med kund' },
actorId: 'whatsapp-inbound',
},
)
const finalUpdate = lastUpdate(findCalls)
expect(finalUpdate.processing_status).toBe('done')
expect(finalUpdate.inbox_item_id).toBe('item-1')
expect(sendTextMock).toHaveBeenCalledTimes(1)
const ack = sendTextMock.mock.calls[0][1]
expect(ack.template).toBe(TEMPLATE.m4Ack)
expect(ack.to).toBe('46701234567')
expect(ack.body).toContain('Espresso House')
expect(ack.body).toContain('450 kr')
expect(ack.body).toContain('2026-07-30')
})
it('sends the empty-extraction M4 variant when no total was read', async () => {
uploadAndExtractMock.mockResolvedValue({
document_id: 'doc-1',
inbox_item_id: 'item-1',
extracted_data: { supplier: { name: null }, totals: { total: null }, invoice: {} },
} as never)
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: makeRow() })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink() })
enqueue({ data: [{ company_id: 'company-1' }] })
enqueue({ data: null })
enqueue({ data: null })
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m4AckEmpty)
})
it('does nothing when the claim is lost (already processing)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: makeRow() })
enqueue({ data: null }) // claim matched no row
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(downloadMediaMock).not.toHaveBeenCalled()
expect(uploadAndExtractMock).not.toHaveBeenCalled()
expect(sendTextMock).not.toHaveBeenCalled()
})
it('rejects disallowed MIME types with M15, skipped, and no download', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: makeRow({ media_mime: 'video/mp4', message_type: 'document' }) })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink() })
enqueue({ data: [{ company_id: 'company-1' }] })
enqueue({ data: null }) // markStatus skipped
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(downloadMediaMock).not.toHaveBeenCalled()
expect(uploadAndExtractMock).not.toHaveBeenCalled()
expect(sendTextMock).toHaveBeenCalledTimes(1)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m15Unsupported)
expect(lastUpdate(findCalls).processing_status).toBe('skipped')
})
it('multi-company sender without a default gets M6 fallback and no item', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: makeRow() })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink() })
enqueue({ data: [{ company_id: 'company-1' }, { company_id: 'company-2' }] })
enqueue({ data: null }) // markStatus skipped
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(uploadAndExtractMock).not.toHaveBeenCalled()
expect(downloadMediaMock).not.toHaveBeenCalled()
expect(sendTextMock).toHaveBeenCalledTimes(1)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m6NoDefaultCompany)
expect(lastUpdate(findCalls).processing_status).toBe('skipped')
})
it('uses the default company when set and still a member', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: makeRow() })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink({ default_company_id: 'company-7' }) })
enqueue({ data: { company_id: 'company-7' } }) // membership check for default
enqueue({ data: null }) // dup check
enqueue({ data: null }) // markStatus done
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(uploadAndExtractMock).toHaveBeenCalledWith(
supabase,
'user-1',
'company-7',
expect.anything(),
'whatsapp',
undefined,
undefined,
expect.anything(),
)
})
it('rate limit: drops with M17 once, records RateLimitedDropped, never a retryable status', async () => {
rateLimitMock.mockResolvedValue({ ok: false, scope: 'minute', retryAfterSec: 60 })
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: makeRow() })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink() })
enqueue({ data: [{ company_id: 'company-1' }] })
enqueue({ data: null }) // M17 notice check: none sent yet
enqueue({ data: null }) // markStatus skipped
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(appendHistoryMock).toHaveBeenCalledWith(
expect.objectContaining({ eventType: 'RateLimitedDropped', companyId: 'company-1' }),
)
expect(sendTextMock).toHaveBeenCalledTimes(1)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m17RateLimited)
expect(downloadMediaMock).not.toHaveBeenCalled()
expect(uploadAndExtractMock).not.toHaveBeenCalled()
expect(lastUpdate(findCalls).processing_status).toBe('skipped')
})
it('rate limit: stays silent when an M17 already went out inside the window', async () => {
rateLimitMock.mockResolvedValue({ ok: false, scope: 'minute', retryAfterSec: 60 })
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: makeRow() })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink() })
enqueue({ data: [{ company_id: 'company-1' }] })
enqueue({ data: { id: 'earlier-m17' } }) // notice already sent
enqueue({ data: null }) // markStatus skipped
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(sendTextMock).not.toHaveBeenCalled()
})
it('exact sha256 duplicate: M4-duplicate ack and no item created', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: makeRow() })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink() })
enqueue({ data: [{ company_id: 'company-1' }] })
enqueue({ data: { id: 'existing-doc' } }) // dup found
enqueue({ data: null }) // markStatus skipped
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(uploadAndExtractMock).not.toHaveBeenCalled()
expect(sendTextMock).toHaveBeenCalledTimes(1)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m4Duplicate)
expect(lastUpdate(findCalls).processing_status).toBe('skipped')
})
it('wraps failures: error status + error_message + a single M18', async () => {
downloadMediaMock.mockRejectedValue(new GraphApiError('Media download failed (500)', 500))
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: makeRow() })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink() })
enqueue({ data: [{ company_id: 'company-1' }] })
enqueue({ data: null }) // markStatus error
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
const finalUpdate = lastUpdate(findCalls)
expect(finalUpdate.processing_status).toBe('error')
expect(String(finalUpdate.error_message)).toContain('download failed')
expect(sendTextMock).toHaveBeenCalledTimes(1)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m18Error)
})
it('suppresses M18 on re-claims (attempts > 1)', async () => {
downloadMediaMock.mockRejectedValue(new GraphApiError('still failing'))
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: makeRow({ attempts: 1 }) })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink() })
enqueue({ data: [{ company_id: 'company-1' }] })
enqueue({ data: null }) // markStatus error
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(lastUpdate(findCalls).processing_status).toBe('error')
expect(sendTextMock).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,499 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import crypto from 'crypto'
import { createQueuedMockSupabase } from '@/tests/helpers'
vi.mock('@supabase/supabase-js', () => ({
createClient: vi.fn(),
}))
vi.mock('@/extensions/general/whatsapp-inbox/lib/graph-api', async () => {
const actual = await vi.importActual<
typeof import('@/extensions/general/whatsapp-inbox/lib/graph-api')
>('@/extensions/general/whatsapp-inbox/lib/graph-api')
return {
...actual,
sendText: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT' }),
markReadWithTyping: vi.fn().mockResolvedValue(undefined),
downloadMedia: vi.fn(),
getDisplayPhoneNumber: vi.fn().mockResolvedValue(null),
}
})
vi.mock('@/extensions/general/whatsapp-inbox/lib/process-inbound', () => ({
kickInboundProcessing: vi.fn(),
}))
import { createClient } from '@supabase/supabase-js'
import { whatsappInboxExtension } from '@/extensions/general/whatsapp-inbox'
import { sendText, downloadMedia } from '@/extensions/general/whatsapp-inbox/lib/graph-api'
import { kickInboundProcessing } from '@/extensions/general/whatsapp-inbox/lib/process-inbound'
import { TEMPLATE } from '@/extensions/general/whatsapp-inbox/lib/messages'
import { hashLinkCode } from '@/extensions/general/whatsapp-inbox/lib/linking'
const SECRET = 'meta-app-secret'
const createClientMock = vi.mocked(createClient)
const sendTextMock = vi.mocked(sendText)
const kickMock = vi.mocked(kickInboundProcessing)
function findRoute(method: string, path: string) {
return whatsappInboxExtension.apiRoutes!.find((r) => r.method === method && r.path === path)!
}
const route = findRoute('POST', '/webhook')
function signedRequest(body: unknown, secret = SECRET): Request {
const raw = JSON.stringify(body)
const signature =
'sha256=' + crypto.createHmac('sha256', secret).update(raw, 'utf8').digest('hex')
return new Request('http://localhost:3000/api/extensions/ext/whatsapp-inbox/webhook', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Hub-Signature-256': signature },
body: raw,
})
}
function envelope(value: Record<string, unknown>) {
return {
object: 'whatsapp_business_account',
entry: [
{
id: 'waba-1',
changes: [
{
field: 'messages',
value: { messaging_product: 'whatsapp', ...value },
},
],
},
],
}
}
function textMessage(body: string, overrides: Record<string, unknown> = {}) {
return {
from: '46701234567',
id: 'wamid.IN1',
timestamp: '1754000000',
type: 'text',
text: { body },
...overrides,
}
}
function imageMessage(overrides: Record<string, unknown> = {}) {
return {
from: '46701234567',
id: 'wamid.IN1',
timestamp: '1754000000',
type: 'image',
image: { id: 'media-1', mime_type: 'image/jpeg', sha256: 'abc', caption: 'kvitto' },
...overrides,
}
}
function makeLink(overrides: Record<string, unknown> = {}) {
return {
id: 'link-1',
user_id: 'user-1',
phone_hash: 'hash-x',
phone_enc: 'enc',
phone_masked: '+46 70 *** ** 67',
default_company_id: null,
revoked_at: null,
muted_at: null,
...overrides,
}
}
describe('POST /webhook', () => {
const originalEnv = { ...process.env }
beforeEach(() => {
vi.clearAllMocks()
sendTextMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT' })
process.env.WHATSAPP_APP_SECRET = SECRET
process.env.WHATSAPP_PHONE_HASH_KEY = 'test-pepper'
process.env.WHATSAPP_PHONE_ENCRYPTION_KEY = 'a'.repeat(64)
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://test.supabase.co'
process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-key'
})
afterEach(() => {
process.env = { ...originalEnv }
})
function mockSupabase() {
const mock = createQueuedMockSupabase()
createClientMock.mockReturnValue(mock.supabase as never)
return mock
}
it('503s when the app secret is not configured', async () => {
delete process.env.WHATSAPP_APP_SECRET
const response = await route.handler(signedRequest(envelope({ messages: [] })))
expect(response.status).toBe(503)
})
it('401s on an invalid signature before touching anything', async () => {
mockSupabase()
const response = await route.handler(
signedRequest(envelope({ messages: [textMessage('hej')] }), 'wrong-secret'),
)
expect(response.status).toBe(401)
expect(sendTextMock).not.toHaveBeenCalled()
expect(kickMock).not.toHaveBeenCalled()
})
it('updates outbound delivery status from statuses[]', async () => {
const { enqueue, findCall } = mockSupabase()
enqueue({ data: null }) // update chain
const response = await route.handler(
signedRequest(envelope({ statuses: [{ id: 'wamid.OUT9', status: 'delivered' }] })),
)
expect(response.status).toBe(200)
const updateArgs = findCall('whatsapp_messages', 'update') as [Record<string, unknown>]
expect(updateArgs[0]).toEqual({ delivery_status: 'delivered' })
})
it('persists a linked media message and defers processing', async () => {
const { enqueue, findCall } = mockSupabase()
enqueue({ data: makeLink() }) // active link lookup
enqueue({ data: { id: 'conv-1' } }) // conversation lookup
enqueue({ data: { id: 'msg-row-1' } }) // message insert
enqueue({ data: null }) // phone_links last_message_at update
enqueue({ data: null }) // conversation window update
const response = await route.handler(
signedRequest(envelope({ messages: [imageMessage()] })),
)
expect(response.status).toBe(200)
const [row] = findCall('whatsapp_messages', 'insert') as [Record<string, unknown>]
expect(row.direction).toBe('inbound')
expect(row.wamid).toBe('wamid.IN1')
expect(row.processing_status).toBe('received')
expect(row.media_id).toBe('media-1')
expect(row.media_mime).toBe('image/jpeg')
expect(row.body_text).toBe('kvitto') // caption travels in body_text
expect(kickMock).toHaveBeenCalledWith(['msg-row-1'])
expect(sendTextMock).not.toHaveBeenCalled() // per-receipt ack comes from the worker
})
it('dedupes a redelivered wamid via the unique index (23505): no reply, no processing', async () => {
const { enqueue } = mockSupabase()
enqueue({ data: makeLink() })
enqueue({ data: { id: 'conv-1' } })
enqueue({ data: null, error: { code: '23505', message: 'duplicate key' } })
const response = await route.handler(
signedRequest(envelope({ messages: [imageMessage()] })),
)
expect(response.status).toBe(200)
expect(kickMock).toHaveBeenCalledWith([])
expect(sendTextMock).not.toHaveBeenCalled()
})
describe('unknown senders', () => {
it('greets once with M1: no media download, no message 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
const response = await route.handler(
signedRequest(envelope({ messages: [imageMessage()] })),
)
expect(response.status).toBe(200)
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)
expect(kickMock).toHaveBeenCalledWith([])
})
it('stays silent when the M1 throttle window is exhausted', async () => {
const { enqueue } = mockSupabase()
enqueue({ data: null })
enqueue({ data: { ok: true } })
enqueue({ data: [{ created_at: new Date().toISOString() }] }) // greeted within the hour
await route.handler(signedRequest(envelope({ messages: [textMessage('hej')] })))
expect(sendTextMock).not.toHaveBeenCalled()
})
it('stays silent when the pre-binding sender quota is exhausted', async () => {
const mock = mockSupabase()
mock.enqueue({ data: null })
mock.enqueue({ data: { ok: false, scope: 'minute', retry_after_sec: 60 } })
await route.handler(signedRequest(envelope({ messages: [textMessage('hej')] })))
expect(sendTextMock).not.toHaveBeenCalled()
expect(mock.supabase.rpc).toHaveBeenCalledWith(
'check_and_increment_whatsapp_sender_quota',
expect.objectContaining({ p_phone_hash: expect.any(String) }),
)
// Only the link lookup ever touched a table.
const tables = [...new Set(mock.calls.map((c) => c.table))]
expect(tables).toEqual(['whatsapp_phone_links'])
})
})
describe('link codes', () => {
it('binds a valid code: creates link + conversation, replies M3 with the company name', async () => {
const { enqueue, findCall } = mockSupabase()
enqueue({ data: null }) // no active link
enqueue({ data: { ok: true } }) // quota
enqueue({
data: {
id: 'code-1',
user_id: 'user-1',
expires_at: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
used_at: null,
},
}) // code lookup
enqueue({ data: { id: 'code-1' } }) // code claim
enqueue({ data: null }) // revoke by phone hash
enqueue({ data: null }) // revoke by user
enqueue({ data: { id: 'link-9', user_id: 'user-1' } }) // link insert
enqueue({ data: { id: 'conv-9' } }) // conversation insert
enqueue({ data: null }) // content-free code-message row
enqueue({ data: [{ company_id: 'company-1' }] }) // memberships
enqueue({ data: { name: 'Bolaget AB' } }) // company name
const response = await route.handler(
signedRequest(
envelope({
contacts: [{ wa_id: '46701234567', profile: { name: 'Jakob' } }],
messages: [textMessage('ac-7kp4qf')],
}),
),
)
expect(response.status).toBe(200)
const [linkRow] = findCall('whatsapp_phone_links', 'insert') as [Record<string, unknown>]
expect(linkRow.user_id).toBe('user-1')
expect(linkRow.wa_profile_name).toBe('Jakob')
expect(linkRow.phone_masked).toBe('+46 70 *** ** 67')
// The code message row is persisted content-free (dedupe only).
const [codeRow] = findCall('whatsapp_messages', 'insert') as [Record<string, unknown>]
expect(codeRow.wamid).toBe('wamid.IN1')
expect(codeRow.body_text).toBeUndefined()
expect(codeRow.raw_payload).toBeUndefined()
// The code was claimed single-use.
const [claim] = findCall('whatsapp_link_codes', 'update') as [Record<string, unknown>]
expect(claim.used_at).toBeTruthy()
expect(sendTextMock).toHaveBeenCalledTimes(1)
const reply = sendTextMock.mock.calls[0][1]
expect(reply.template).toBe(TEMPLATE.m3Linked)
expect(reply.body).toContain('Bolaget AB')
})
it('replies M2 to an unknown code', async () => {
const { enqueue } = mockSupabase()
enqueue({ data: null })
enqueue({ data: { ok: true } })
enqueue({ data: null }) // code lookup: nothing
await route.handler(signedRequest(envelope({ messages: [textMessage('AC-7KP4QF')] })))
expect(sendTextMock).toHaveBeenCalledTimes(1)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m2BadCode)
})
it('replies M2 to an expired code', async () => {
const { enqueue } = mockSupabase()
enqueue({ data: null })
enqueue({ data: { ok: true } })
enqueue({
data: {
id: 'code-1',
user_id: 'user-1',
expires_at: new Date(Date.now() - 1000).toISOString(),
used_at: null,
},
})
await route.handler(signedRequest(envelope({ messages: [textMessage('AC-7KP4QF')] })))
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m2BadCode)
})
it('replies M2 to a reused code (single use)', async () => {
const { enqueue } = mockSupabase()
enqueue({ data: null })
enqueue({ data: { ok: true } })
enqueue({
data: {
id: 'code-1',
user_id: 'user-1',
expires_at: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
used_at: new Date().toISOString(),
},
})
await route.handler(signedRequest(envelope({ messages: [textMessage('AC-7KP4QF')] })))
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m2BadCode)
})
it('hashLinkCode matches what the webhook looks up', () => {
// Regression guard: panel mints, webhook consumes; both must hash alike.
expect(hashLinkCode('AC-7KP4QF')).toMatch(/^[0-9a-f]{64}$/)
})
})
describe('keywords', () => {
function enqueueLinkedTextPreamble(
mock: ReturnType<typeof mockSupabase>,
link: Record<string, unknown>,
) {
mock.enqueue({ data: link }) // link lookup
mock.enqueue({ data: { id: 'conv-1' } }) // conversation
mock.enqueue({ data: { id: 'msg-row-1' } }) // insert
mock.enqueue({ data: null }) // link last_message_at
mock.enqueue({ data: null }) // conversation window
}
it('stopp mutes the link and confirms with M11', async () => {
const mock = mockSupabase()
enqueueLinkedTextPreamble(mock, makeLink())
mock.enqueue({ data: null }) // muted_at update
await route.handler(signedRequest(envelope({ messages: [textMessage('Stopp')] })))
const linkUpdates = mock.findCalls('whatsapp_phone_links', 'update')
const mutedUpdate = linkUpdates.find(
(args) => (args[0] as Record<string, unknown>).muted_at != null,
)
expect(mutedUpdate).toBeTruthy()
expect(sendTextMock).toHaveBeenCalledTimes(1)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m11Stop)
})
it('while muted everything except start is silence', async () => {
const mock = mockSupabase()
enqueueLinkedTextPreamble(mock, makeLink({ muted_at: '2026-08-01T10:00:00Z' }))
await route.handler(signedRequest(envelope({ messages: [textMessage('hej, är du där?')] })))
expect(sendTextMock).not.toHaveBeenCalled()
const [row] = mock.findCall('whatsapp_messages', 'insert') as [Record<string, unknown>]
expect(row.processing_status).toBe('skipped')
})
it('while muted media is also silence', async () => {
const mock = mockSupabase()
mock.enqueue({ data: makeLink({ muted_at: '2026-08-01T10:00:00Z' }) })
mock.enqueue({ data: { id: 'conv-1' } })
mock.enqueue({ data: { id: 'msg-row-1' } })
mock.enqueue({ data: null })
mock.enqueue({ data: null })
await route.handler(signedRequest(envelope({ messages: [imageMessage()] })))
expect(sendTextMock).not.toHaveBeenCalled()
expect(kickMock).toHaveBeenCalledWith([])
})
it('start unmutes and welcomes back with M12', async () => {
const mock = mockSupabase()
enqueueLinkedTextPreamble(mock, makeLink({ muted_at: '2026-08-01T10:00:00Z' }))
mock.enqueue({ data: null }) // muted_at cleared
await route.handler(signedRequest(envelope({ messages: [textMessage('start')] })))
const linkUpdates = mock.findCalls('whatsapp_phone_links', 'update')
const unmute = linkUpdates.find(
(args) => (args[0] as Record<string, unknown>).muted_at === null,
)
expect(unmute).toBeTruthy()
expect(sendTextMock).toHaveBeenCalledTimes(1)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m12Start)
})
it('hjälp escalates to a human with M13', async () => {
const mock = mockSupabase()
enqueueLinkedTextPreamble(mock, makeLink())
await route.handler(signedRequest(envelope({ messages: [textMessage('Hjälp')] })))
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m13Help)
expect(sendTextMock.mock.calls[0][1].body).toContain('support@accounted.se')
})
it('other free text gets the M16 fallback', async () => {
const mock = mockSupabase()
enqueueLinkedTextPreamble(mock, makeLink())
await route.handler(
signedRequest(envelope({ messages: [textMessage('kan du bokföra allt åt mig?')] })),
)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m16Fallback)
})
it('voice notes get M14', async () => {
const mock = mockSupabase()
mock.enqueue({ data: makeLink() })
mock.enqueue({ data: { id: 'conv-1' } })
mock.enqueue({ data: { id: 'msg-row-1' } })
mock.enqueue({ data: null })
mock.enqueue({ data: null })
await route.handler(
signedRequest(
envelope({
messages: [
{
from: '46701234567',
id: 'wamid.IN1',
type: 'audio',
audio: { id: 'media-2', mime_type: 'audio/ogg', voice: true },
},
],
}),
),
)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m14Voice)
expect(kickMock).toHaveBeenCalledWith([])
})
it('stickers get M15', async () => {
const mock = mockSupabase()
mock.enqueue({ data: makeLink() })
mock.enqueue({ data: { id: 'conv-1' } })
mock.enqueue({ data: { id: 'msg-row-1' } })
mock.enqueue({ data: null })
mock.enqueue({ data: null })
await route.handler(
signedRequest(
envelope({
messages: [
{
from: '46701234567',
id: 'wamid.IN1',
type: 'sticker',
sticker: { id: 'media-3', mime_type: 'image/webp' },
},
],
}),
),
)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m15Unsupported)
})
})
it('acks signed-but-unparseable bodies without redelivery bait', async () => {
mockSupabase()
const raw = 'not json'
const signature =
'sha256=' + crypto.createHmac('sha256', SECRET).update(raw, 'utf8').digest('hex')
const response = await route.handler(
new Request('http://localhost:3000/api/extensions/ext/whatsapp-inbox/webhook', {
method: 'POST',
headers: { 'X-Hub-Signature-256': signature },
body: raw,
}),
)
expect(response.status).toBe(200)
})
})
@@ -0,0 +1,127 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import crypto from 'crypto'
import {
verifyMetaSignature,
verifyChallengeToken,
} from '@/extensions/general/whatsapp-inbox/lib/webhook-verify'
import { whatsappInboxExtension } from '@/extensions/general/whatsapp-inbox'
const SECRET = 'meta-app-secret'
function sign(body: string, secret = SECRET): string {
return 'sha256=' + crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex')
}
function findRoute(method: string, path: string) {
return whatsappInboxExtension.apiRoutes!.find((r) => r.method === method && r.path === path)!
}
describe('verifyMetaSignature', () => {
it('accepts a valid signature', () => {
const body = '{"object":"whatsapp_business_account"}'
expect(verifyMetaSignature(body, sign(body), SECRET)).toBe(true)
})
it('rejects a signature made with the wrong secret', () => {
const body = '{"a":1}'
expect(verifyMetaSignature(body, sign(body, 'forged-secret'), SECRET)).toBe(false)
})
it('rejects a signature over a different body', () => {
expect(verifyMetaSignature('{"a":2}', sign('{"a":1}'), SECRET)).toBe(false)
})
it('rejects a malformed (non-hex) header without throwing', () => {
expect(verifyMetaSignature('{}', 'sha256=zzzz-not-hex', SECRET)).toBe(false)
expect(verifyMetaSignature('{}', 'sha256=', SECRET)).toBe(false)
expect(verifyMetaSignature('{}', 'garbage', SECRET)).toBe(false)
})
it('rejects a missing header', () => {
expect(verifyMetaSignature('{}', null, SECRET)).toBe(false)
expect(verifyMetaSignature('{}', undefined, SECRET)).toBe(false)
})
it('rejects when the secret is empty', () => {
const body = '{}'
expect(verifyMetaSignature(body, sign(body), '')).toBe(false)
})
})
describe('verifyChallengeToken', () => {
it('accepts an exact match', () => {
expect(verifyChallengeToken('tok-123', 'tok-123')).toBe(true)
})
it('rejects mismatches and length differences', () => {
expect(verifyChallengeToken('tok-124', 'tok-123')).toBe(false)
expect(verifyChallengeToken('tok-1234', 'tok-123')).toBe(false)
})
it('rejects missing values', () => {
expect(verifyChallengeToken(null, 'tok-123')).toBe(false)
expect(verifyChallengeToken('tok-123', undefined)).toBe(false)
})
})
describe('GET /webhook (subscription handshake)', () => {
const originalEnv = { ...process.env }
const route = findRoute('GET', '/webhook')
beforeEach(() => {
process.env.WHATSAPP_VERIFY_TOKEN = 'verify-token-1'
})
afterEach(() => {
process.env = { ...originalEnv }
})
function handshakeRequest(params: Record<string, string>): Request {
const url = new URL('http://localhost:3000/api/extensions/ext/whatsapp-inbox/webhook')
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)
return new Request(url.toString(), { method: 'GET' })
}
it('echoes hub.challenge as text/plain on a valid handshake', async () => {
const response = await route.handler(
handshakeRequest({
'hub.mode': 'subscribe',
'hub.verify_token': 'verify-token-1',
'hub.challenge': '1158201444',
}),
)
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toContain('text/plain')
expect(await response.text()).toBe('1158201444')
})
it('403s on a wrong verify token', async () => {
const response = await route.handler(
handshakeRequest({
'hub.mode': 'subscribe',
'hub.verify_token': 'wrong-token',
'hub.challenge': '1158201444',
}),
)
expect(response.status).toBe(403)
})
it('403s on a missing mode', async () => {
const response = await route.handler(
handshakeRequest({ 'hub.verify_token': 'verify-token-1', 'hub.challenge': 'x' }),
)
expect(response.status).toBe(403)
})
it('503s when the verify token env is not configured', async () => {
delete process.env.WHATSAPP_VERIFY_TOKEN
const response = await route.handler(
handshakeRequest({
'hub.mode': 'subscribe',
'hub.verify_token': 'verify-token-1',
'hub.challenge': 'x',
}),
)
expect(response.status).toBe(503)
})
})
+580
View File
@@ -0,0 +1,580 @@
/**
* WhatsApp intake extension (PR3 of the WhatsApp track).
*
* Receives receipts sent to the shared Accounted WhatsApp number and lands
* them in the document inbox (Underlag) through the same uploadAndExtract
* funnel as email intake. Phone numbers bind to Accounted users via one-time
* codes; unknown senders get one canned, throttled greeting and are never
* processed further (no LLM, no media download, no content persistence).
*
* Webhook lifecycle: persist-first. POST verifies the Meta signature over the
* RAW body, Zod-parses, persists inbound rows (wamid partial-unique index =
* the dedupe key against Meta's up-to-7-day redelivery), 200s fast, and
* defers media processing via the after() idiom (lib/process-inbound.ts).
* Rejected/rate-limited content always acks 200: a retryable status would
* only buy a redelivery of something we already decided to drop.
*
* Deferred to PR4: burst debounce + combined ack, company choice buttons,
* clarifying questions (representation/quality/context), interpret-answer LLM
* call, sweep + retention crons.
*/
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import { NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'
import type { SupabaseClient } from '@supabase/supabase-js'
import { z } from 'zod'
import { createServiceClient } from '@/lib/supabase/server'
import { createLogger } from '@/lib/logger'
import type { WhatsAppPhoneLink } from '@/types'
import { verifyMetaSignature, verifyChallengeToken } from './lib/webhook-verify'
import { parseWebhookEnvelope, type ParsedInboundMessage } from './lib/webhook-parse'
import { hashPhone } from './lib/phone-crypto'
import {
consumeLinkCode,
createPhoneLink,
looksLikeLinkCode,
lookupActiveLink,
mintLinkCode,
} from './lib/linking'
import { sendText, getDisplayPhoneNumber } from './lib/graph-api'
import { botCopy, TEMPLATE } from './lib/messages'
import { kickInboundProcessing } from './lib/process-inbound'
const log = createLogger('whatsapp-inbox')
// ── Unknown-sender budgets ───────────────────────────────────
// Pre-binding limiter (check_and_increment_whatsapp_sender_quota): caps how
// 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
const SERVICE_WINDOW_MS = 24 * 60 * 60 * 1000
// Exact whole-message keyword sets (normalized lowercase + trim). Text
// messages only, never captions, per the conversation spec.
const STOP_KEYWORDS = new Set(['stopp', 'stop', 'avsluta'])
const HELP_KEYWORDS = new Set(['hjälp', 'hjalp', 'help', 'support', 'människa', 'manniska'])
const START_KEYWORD = 'start'
const DefaultCompanySchema = z.object({
companyId: z.string().uuid().nullable(),
})
function buildServiceClient(): SupabaseClient {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
)
}
// ── Unknown senders ──────────────────────────────────────────
async function greetingThrottled(
supabase: SupabaseClient,
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)
}
/**
* Unknown/unlinked sender. Hard rules: never download media, never persist
* message content or raw payloads, never touch any LLM. The only DB writes
* are the quota counters, a consumed link code, and outbound reply rows.
*/
async function handleUnknownSender(
supabase: SupabaseClient,
msg: ParsedInboundMessage,
phoneHash: string,
): Promise<void> {
const { data: quota, error: quotaError } = await supabase.rpc(
'check_and_increment_whatsapp_sender_quota',
{
p_phone_hash: phoneHash,
p_minute_max: UNKNOWN_SENDER_MINUTE_MAX,
p_day_max: UNKNOWN_SENDER_DAY_MAX,
},
)
if (quotaError) {
// Fail closed for unknown senders: without the limiter we send nothing.
log.warn('sender quota RPC failed; staying silent', { error: quotaError.message })
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) {
await sendText(supabase, {
to: msg.from,
body: copy.m2BadCode(),
template: TEMPLATE.m2BadCode,
senderPhoneHash: phoneHash,
})
return
}
const { link, conversationId } = await createPhoneLink(supabase, {
userId: consumed.userId,
phone: msg.from,
profileName: msg.profileName,
})
// Persist a content-free row for the code message so a Meta redelivery
// of the same wamid dedupes instead of falling into the keyword path.
await supabase.from('whatsapp_messages').insert({
direction: 'inbound',
wamid: msg.wamid,
sender_phone_hash: phoneHash,
phone_link_id: link.id,
conversation_id: conversationId,
message_type: 'text',
processing_status: 'done',
})
const { data: memberships } = await supabase
.from('company_members')
.select('company_id')
.eq('user_id', consumed.userId)
const companyIds = [...new Set((memberships ?? []).map((m) => m.company_id as string))]
let companyName: string | null = null
if (companyIds.length === 1) {
const { data: company } = await supabase
.from('companies')
.select('name')
.eq('id', companyIds[0])
.maybeSingle()
companyName = (company as { name?: string } | null)?.name ?? null
}
await sendText(supabase, {
to: msg.from,
body: copy.m3Linked({ companyName, companyCount: Math.max(companyIds.length, 1) }),
template: TEMPLATE.m3Linked,
senderPhoneHash: phoneHash,
phoneLinkId: link.id,
conversationId,
})
return
}
// 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
await sendText(supabase, {
to: msg.from,
body: copy.m1Unlinked(),
template: TEMPLATE.m1Unlinked,
senderPhoneHash: phoneHash,
})
}
// ── Linked senders ───────────────────────────────────────────
type Disposition =
| { kind: 'media' }
| { kind: 'stop' }
| { kind: 'start' }
| { kind: 'help' }
| { kind: 'voice' }
| { kind: 'unsupported' }
| { kind: 'fallback' }
| { kind: 'silence' }
function classify(msg: ParsedInboundMessage, muted: boolean): Disposition {
if (msg.type === 'text') {
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' }
}
if (STOP_KEYWORDS.has(normalized)) return { kind: 'stop' }
if (HELP_KEYWORDS.has(normalized)) return { kind: 'help' }
if (normalized === START_KEYWORD) return { kind: 'start' }
return { kind: 'fallback' }
}
if (muted) return { kind: 'silence' }
if (msg.type === 'image' || msg.type === 'document') {
return msg.media ? { kind: 'media' } : { kind: 'unsupported' }
}
if (msg.type === 'audio') return { kind: 'voice' }
if (msg.type === 'video' || msg.type === 'sticker' || msg.type === 'location' || msg.type === 'contacts') {
return { kind: 'unsupported' }
}
// Truly unknown types (reactions, ephemeral, future additions): stay
// silent rather than lecture someone for sending a thumbs-up.
return { kind: 'silence' }
}
async function resolveConversationId(
supabase: SupabaseClient,
phoneLinkId: string,
): Promise<string | null> {
const { data: existing } = await supabase
.from('whatsapp_conversations')
.select('id')
.eq('phone_link_id', phoneLinkId)
.maybeSingle()
if (existing) return (existing as { id: string }).id
const { data: created } = await supabase
.from('whatsapp_conversations')
.insert({ phone_link_id: phoneLinkId })
.select('id')
.maybeSingle()
return (created as { id: string } | null)?.id ?? null
}
async function handleLinkedSender(
supabase: SupabaseClient,
msg: ParsedInboundMessage,
phoneHash: string,
link: WhatsAppPhoneLink,
mediaMessageIds: string[],
): Promise<void> {
const disposition = classify(msg, link.muted_at != null)
const conversationId = await resolveConversationId(supabase, link.id)
const correlationId = crypto.randomUUID()
const copy = botCopy('sv')
// Persist-first, dedupe on the inbound-wamid partial unique index. A
// redelivered wamid violates it (23505): already handled, stop entirely.
const initialStatus =
disposition.kind === 'media' ? 'received' : disposition.kind === 'silence' ? 'skipped' : 'done'
const { data: inserted, error: insertError } = await supabase
.from('whatsapp_messages')
.insert({
direction: 'inbound',
wamid: msg.wamid,
sender_phone_hash: phoneHash,
phone_link_id: link.id,
conversation_id: conversationId,
message_type: msg.type,
body_text: msg.type === 'text' ? msg.text : (msg.caption ?? null),
media_id: msg.media?.id ?? null,
media_mime: msg.media?.mime ?? null,
media_sha256: msg.media?.sha256 ?? null,
media_filename: msg.media?.filename ?? null,
raw_payload: msg.raw as Record<string, unknown>,
processing_status: initialStatus,
correlation_id: correlationId,
})
.select('id')
.maybeSingle()
if (insertError) {
if (insertError.code === '23505') return // wamid dedupe: Meta redelivery
log.error('Failed to persist inbound WhatsApp message', insertError)
return
}
const messageId = (inserted as { id: string } | null)?.id ?? null
const now = new Date()
await supabase
.from('whatsapp_phone_links')
.update({ last_message_at: now.toISOString() })
.eq('id', link.id)
if (conversationId) {
await supabase
.from('whatsapp_conversations')
.update({
last_inbound_at: now.toISOString(),
service_window_expires_at: new Date(now.getTime() + SERVICE_WINDOW_MS).toISOString(),
})
.eq('id', conversationId)
}
const replyBase = {
senderPhoneHash: phoneHash,
phoneLinkId: link.id,
conversationId,
correlationId,
}
switch (disposition.kind) {
case 'media':
if (messageId) mediaMessageIds.push(messageId)
return
case 'stop':
await supabase
.from('whatsapp_phone_links')
.update({ muted_at: now.toISOString() })
.eq('id', link.id)
await sendText(supabase, { to: msg.from, body: copy.m11Stop(), template: TEMPLATE.m11Stop, ...replyBase })
return
case 'start':
if (link.muted_at != null) {
await supabase
.from('whatsapp_phone_links')
.update({ muted_at: null })
.eq('id', link.id)
}
await sendText(supabase, { to: msg.from, body: copy.m12Start(), template: TEMPLATE.m12Start, ...replyBase })
return
case 'help':
await sendText(supabase, { to: msg.from, body: copy.m13Help(), template: TEMPLATE.m13Help, ...replyBase })
return
case 'voice':
await sendText(supabase, { to: msg.from, body: copy.m14Voice(), template: TEMPLATE.m14Voice, ...replyBase })
return
case 'unsupported':
await sendText(supabase, { to: msg.from, body: copy.m15Unsupported(), template: TEMPLATE.m15Unsupported, ...replyBase })
return
case 'fallback':
await sendText(supabase, { to: msg.from, body: copy.m16Fallback(), template: TEMPLATE.m16Fallback, ...replyBase })
return
case 'silence':
return
}
}
// ── Extension definition ─────────────────────────────────────
export const whatsappInboxExtension: Extension = {
id: 'whatsapp-inbox',
name: 'WhatsApp-inkorg',
version: '1.0.0',
sector: 'general',
settingsPanel: {
label: 'WhatsApp',
path: '/settings/whatsapp',
},
apiRoutes: [
// ── Meta webhook: subscription handshake ────────────────
{
method: 'GET',
path: '/webhook',
skipAuth: true,
handler: async (request: Request) => {
const expected = process.env.WHATSAPP_VERIFY_TOKEN
if (!expected) {
return NextResponse.json({ error: 'Webhook not configured' }, { status: 503 })
}
const url = new URL(request.url)
const mode = url.searchParams.get('hub.mode')
const token = url.searchParams.get('hub.verify_token')
const challenge = url.searchParams.get('hub.challenge')
if (mode === 'subscribe' && verifyChallengeToken(token, expected) && challenge != null) {
return new Response(challenge, {
status: 200,
headers: { 'Content-Type': 'text/plain' },
})
}
return NextResponse.json({ error: 'Verification failed' }, { status: 403 })
},
},
// ── Meta webhook: inbound events ────────────────────────
{
method: 'POST',
path: '/webhook',
skipAuth: true,
handler: async (request: Request) => {
const appSecret = process.env.WHATSAPP_APP_SECRET
if (!appSecret) {
log.error('WHATSAPP_APP_SECRET not configured', undefined)
return NextResponse.json({ error: 'Webhook not configured' }, { status: 503 })
}
// Signature over the RAW body, before any parsing.
const rawBody = await request.text()
const signature = request.headers.get('x-hub-signature-256')
if (!verifyMetaSignature(rawBody, signature, appSecret)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
}
let body: unknown
try {
body = JSON.parse(rawBody)
} catch {
// Signed but unparseable: ack so Meta does not redeliver garbage.
return NextResponse.json({ data: { ignored: 'unparseable' } })
}
const parsed = parseWebhookEnvelope(body)
const supabase = buildServiceClient()
// Outbound delivery lifecycle updates (sent -> delivered -> read).
for (const status of parsed.statuses) {
await supabase
.from('whatsapp_messages')
.update({ delivery_status: status.status })
.eq('wamid', status.wamid)
.eq('direction', 'outbound')
}
const mediaMessageIds: string[] = []
for (const msg of parsed.messages) {
try {
const phoneHash = hashPhone(msg.from)
const link = await lookupActiveLink(supabase, phoneHash)
if (link) {
await handleLinkedSender(supabase, msg, phoneHash, link, mediaMessageIds)
} else {
await handleUnknownSender(supabase, msg, phoneHash)
}
} catch (err) {
// One bad message must not take down the batch or trigger a
// Meta redelivery of messages we already handled.
log.error('WhatsApp message handling failed', err, { wamid: msg.wamid })
}
}
// 200 first, processing after: extraction takes 10-60s and Meta
// expects the ack within seconds.
kickInboundProcessing(mediaMessageIds)
return NextResponse.json({
data: {
received: parsed.messages.length,
statuses: parsed.statuses.length,
queued: mediaMessageIds.length,
},
})
},
},
// ── Phone linking (authenticated settings panel) ────────
{
method: 'POST',
path: '/link/start',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
if (!process.env.WHATSAPP_ACCESS_TOKEN || !process.env.WHATSAPP_PHONE_NUMBER_ID) {
return NextResponse.json(
{ error: 'WhatsApp-kanalen är inte konfigurerad på den här installationen.' },
{ status: 503 },
)
}
// whatsapp_link_codes is service-role only (RLS with no policies).
const serviceClient = createServiceClient()
const minted = await mintLinkCode(serviceClient, ctx.userId)
// The wa.me link needs the real number, not the Graph object id.
// WHATSAPP_PUBLIC_NUMBER (E.164 digits) is authoritative when set;
// otherwise resolve display_phone_number from the Graph API (cached).
const publicNumber = (process.env.WHATSAPP_PUBLIC_NUMBER ?? '').replace(/\D/g, '')
const displayNumber = publicNumber || (await getDisplayPhoneNumber())
const waLink = displayNumber
? `https://wa.me/${displayNumber}?text=${encodeURIComponent(minted.code)}`
: null
return NextResponse.json({
data: { code: minted.code, expiresAt: minted.expiresAt, waLink },
})
},
},
{
method: 'GET',
path: '/link',
handler: async (_request: Request, ctx?: ExtensionContext) => {
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')
.eq('user_id', ctx.userId)
.is('revoked_at', null)
.maybeSingle()
if (!data) return NextResponse.json({ data: { linked: false } })
const row = data as {
phone_masked: string
default_company_id: string | null
muted_at: string | null
verified_at: string
}
return NextResponse.json({
data: {
linked: true,
phoneMasked: row.phone_masked,
defaultCompanyId: row.default_company_id,
muted: row.muted_at != null,
verifiedAt: row.verified_at,
},
})
},
},
{
method: 'POST',
path: '/link/revoke',
handler: async (_request: Request, ctx?: ExtensionContext) => {
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
await ctx.supabase
.from('whatsapp_phone_links')
.update({ revoked_at: new Date().toISOString() })
.eq('user_id', ctx.userId)
.is('revoked_at', null)
return NextResponse.json({ data: { revoked: true } })
},
},
{
method: 'POST',
path: '/link/default-company',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
let parsedBody: z.infer<typeof DefaultCompanySchema>
try {
parsedBody = DefaultCompanySchema.parse(await request.json())
} catch {
return NextResponse.json({ error: 'Ogiltig förfrågan.' }, { status: 400 })
}
// The caller must be a member of the company receipts are routed to:
// otherwise a user could point their intake at someone else's books.
if (parsedBody.companyId) {
const { data: membership } = await ctx.supabase
.from('company_members')
.select('id')
.eq('company_id', parsedBody.companyId)
.eq('user_id', ctx.userId)
.maybeSingle()
if (!membership) {
return NextResponse.json(
{ error: 'Du är inte medlem i det företaget.' },
{ status: 403 },
)
}
}
const { error } = await ctx.supabase
.from('whatsapp_phone_links')
.update({ default_company_id: parsedBody.companyId })
.eq('user_id', ctx.userId)
.is('revoked_at', null)
if (error) {
return NextResponse.json(
{ error: 'Kunde inte spara standardföretaget.' },
{ status: 500 },
)
}
return NextResponse.json({ data: { defaultCompanyId: parsedBody.companyId } })
},
},
],
}
@@ -0,0 +1,303 @@
/**
* Meta Cloud API (Graph v26.0) client for the WhatsApp channel.
*
* Plain HTTPS via fetchWithTimeout, deliberately no SDK: the surface we need
* is four endpoints, and the AGPL dependency budget is audited (CLAUDE.md).
*
* Every message send persists an outbound whatsapp_messages row through the
* caller's service client (wamid from the send response). Sends are
* best-effort: a failed send logs + records a failed row but never throws,
* because a reply must never take down webhook acking or intake processing.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchWithTimeout, TimeoutError } from '@/lib/http/fetch-with-timeout'
import { createLogger } from '@/lib/logger'
import type { TemplateId } from './messages'
const log = createLogger('whatsapp-inbox/graph-api')
const GRAPH_BASE = 'https://graph.facebook.com/v26.0'
const SEND_TIMEOUT_MS = 10_000
const MEDIA_LOOKUP_TIMEOUT_MS = 10_000
const MEDIA_DOWNLOAD_TIMEOUT_MS = 30_000
/** WhatsApp caps inbound images at 5 MB and documents at 100 MB; our inbox
* pipeline caps everything at 10 MB (matches invoice-inbox MAX_FILE_SIZE). */
export const MAX_MEDIA_BYTES = 10 * 1024 * 1024
export class GraphApiError extends Error {
readonly name = 'GraphApiError'
constructor(
message: string,
readonly status?: number,
) {
super(message)
}
}
function getAccessToken(): string {
const token = process.env.WHATSAPP_ACCESS_TOKEN
if (!token) throw new GraphApiError('WHATSAPP_ACCESS_TOKEN is not configured')
return token
}
function getPhoneNumberId(): string {
const id = process.env.WHATSAPP_PHONE_NUMBER_ID
if (!id) throw new GraphApiError('WHATSAPP_PHONE_NUMBER_ID is not configured')
return id
}
export interface SendTextArgs {
/** Recipient: E.164 digits, no '+' (Meta's wa_id format). */
to: string
body: string
/** Template id stamped into raw_payload for throttle checks and audit. */
template: TemplateId
senderPhoneHash?: string | null
phoneLinkId?: string | null
conversationId?: string | null
correlationId?: string | null
}
export interface SendTextResult {
ok: boolean
wamid: string | null
}
/**
* Send a plain text message and persist the outbound row. Never throws.
*/
export async function sendText(
supabase: SupabaseClient,
args: SendTextArgs,
): Promise<SendTextResult> {
let wamid: string | null = null
let ok = false
try {
const response = await fetchWithTimeout(
`${GRAPH_BASE}/${getPhoneNumberId()}/messages`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${getAccessToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
messaging_product: 'whatsapp',
recipient_type: 'individual',
to: args.to,
type: 'text',
text: { body: args.body },
}),
},
{ timeoutMs: SEND_TIMEOUT_MS, description: 'WhatsApp send' },
)
if (response.ok) {
const payload = (await response.json().catch(() => null)) as {
messages?: Array<{ id?: string }>
} | null
wamid = payload?.messages?.[0]?.id ?? null
ok = true
} else {
const detail = await response.text().catch(() => '')
log.warn('WhatsApp send failed', {
status: response.status,
template: args.template,
detail: detail.slice(0, 300),
})
}
} catch (err) {
log.warn('WhatsApp send errored', {
template: args.template,
error: err instanceof Error ? err.message : String(err),
})
}
try {
await supabase.from('whatsapp_messages').insert({
direction: 'outbound',
wamid,
sender_phone_hash: args.senderPhoneHash ?? null,
phone_link_id: args.phoneLinkId ?? null,
conversation_id: args.conversationId ?? null,
message_type: 'text',
body_text: args.body,
raw_payload: { template: args.template },
// Outbound rows are not jobs: mark done so the PR4 sweep never claims them.
processing_status: 'done',
delivery_status: ok ? 'sent' : 'failed',
correlation_id: args.correlationId ?? null,
})
} catch (err) {
log.error('Failed to persist outbound WhatsApp message row', err)
}
return { ok, wamid }
}
/**
* Mark an inbound message read and show the typing indicator. Best-effort:
* cosmetic, so failures are logged and swallowed.
*/
export async function markReadWithTyping(wamid: string): Promise<void> {
try {
const response = await fetchWithTimeout(
`${GRAPH_BASE}/${getPhoneNumberId()}/messages`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${getAccessToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
messaging_product: 'whatsapp',
status: 'read',
message_id: wamid,
typing_indicator: { type: 'text' },
}),
},
{ timeoutMs: SEND_TIMEOUT_MS, description: 'WhatsApp mark-read' },
)
if (!response.ok) {
log.warn('WhatsApp mark-read failed', { status: response.status })
}
} catch (err) {
log.warn('WhatsApp mark-read errored', {
error: err instanceof Error ? err.message : String(err),
})
}
}
export interface DownloadedMedia {
buffer: ArrayBuffer
mime: string | null
fileSize: number
}
/**
* Download a media item: resolve the media id to a fresh short-lived URL
* (TTL ~5 min, re-resolvable for days, so retries work), then fetch the bytes
* with the same Bearer token. Enforces MAX_MEDIA_BYTES twice: via the
* content-length header before reading, and while streaming the body (a
* missing or lying header must not let an oversized file through).
*
* Throws GraphApiError / TimeoutError: the caller owns error handling here,
* unlike sends, because a failed download IS a failed intake.
*/
export async function downloadMedia(mediaId: string): Promise<DownloadedMedia> {
const token = getAccessToken()
const lookupResponse = await fetchWithTimeout(
`${GRAPH_BASE}/${encodeURIComponent(mediaId)}?phone_number_id=${encodeURIComponent(getPhoneNumberId())}`,
{ method: 'GET', headers: { Authorization: `Bearer ${token}` } },
{ timeoutMs: MEDIA_LOOKUP_TIMEOUT_MS, description: 'WhatsApp media lookup' },
)
if (!lookupResponse.ok) {
throw new GraphApiError(`Media lookup failed (${lookupResponse.status})`, lookupResponse.status)
}
const lookup = (await lookupResponse.json().catch(() => null)) as {
url?: string
mime_type?: string
file_size?: number
} | null
if (!lookup?.url) {
throw new GraphApiError('Media lookup returned no download URL')
}
if (typeof lookup.file_size === 'number' && lookup.file_size > MAX_MEDIA_BYTES) {
throw new GraphApiError('Media exceeds the size limit')
}
const download = await fetchWithTimeout(
lookup.url,
{ method: 'GET', headers: { Authorization: `Bearer ${token}` } },
{ timeoutMs: MEDIA_DOWNLOAD_TIMEOUT_MS, description: 'WhatsApp media download' },
)
if (!download.ok) {
throw new GraphApiError(`Media download failed (${download.status})`, download.status)
}
const declaredLength = Number.parseInt(download.headers.get('content-length') ?? '', 10)
if (Number.isFinite(declaredLength) && declaredLength > MAX_MEDIA_BYTES) {
throw new GraphApiError('Media exceeds the size limit')
}
// Stream with a running byte count so a body larger than its content-length
// header is rejected without buffering the whole thing first.
const chunks: Uint8Array[] = []
let total = 0
if (download.body) {
const reader = download.body.getReader()
for (;;) {
const { done, value } = await reader.read()
if (done) break
if (value) {
total += value.byteLength
if (total > MAX_MEDIA_BYTES) {
await reader.cancel().catch(() => undefined)
throw new GraphApiError('Media exceeds the size limit')
}
chunks.push(value)
}
}
}
const buffer = new ArrayBuffer(total)
const view = new Uint8Array(buffer)
let offset = 0
for (const chunk of chunks) {
view.set(chunk, offset)
offset += chunk.byteLength
}
return {
buffer,
mime: lookup.mime_type ?? download.headers.get('content-type'),
fileSize: total,
}
}
// ── Display number (for the wa.me deep link) ─────────────────
//
// WHATSAPP_PHONE_NUMBER_ID is a Graph object id, not the phone number, and the
// env contract for this extension is fixed at six vars. The wa.me link needs
// the real number, so resolve it once from the Graph API and cache in module
// scope. On failure the panel simply gets no deep link (code still works).
let cachedDisplayNumber: { value: string; fetchedAt: number } | null = null
const DISPLAY_NUMBER_TTL_MS = 60 * 60 * 1000
export async function getDisplayPhoneNumber(): Promise<string | null> {
if (cachedDisplayNumber && Date.now() - cachedDisplayNumber.fetchedAt < DISPLAY_NUMBER_TTL_MS) {
return cachedDisplayNumber.value
}
try {
const response = await fetchWithTimeout(
`${GRAPH_BASE}/${getPhoneNumberId()}?fields=display_phone_number`,
{ method: 'GET', headers: { Authorization: `Bearer ${getAccessToken()}` } },
{ timeoutMs: MEDIA_LOOKUP_TIMEOUT_MS, description: 'WhatsApp number lookup' },
)
if (!response.ok) return null
const payload = (await response.json().catch(() => null)) as {
display_phone_number?: string
} | null
const digits = payload?.display_phone_number?.replace(/\D/g, '') ?? ''
if (!digits) return null
cachedDisplayNumber = { value: digits, fetchedAt: Date.now() }
return digits
} catch (err) {
if (!(err instanceof TimeoutError)) {
log.warn('WhatsApp display number lookup errored', {
error: err instanceof Error ? err.message : String(err),
})
}
return null
}
}
/** Test-only: reset the module-level display-number cache. */
export function resetDisplayNumberCacheForTests(): void {
cachedDisplayNumber = null
}
@@ -0,0 +1,189 @@
/**
* One-time link codes + phone-link lifecycle.
*
* The code proves control of an Accounted account (minted in an authenticated
* settings panel) and sending it proves possession of the phone; the webhook
* binds the two. Invite-token pattern (lib/auth/invite-tokens.ts): the raw
* code exists only in the user's chat, the DB stores its sha256.
*
* All functions here take a SERVICE-ROLE client: whatsapp_link_codes has RLS
* enabled with no policies, and link INSERTs are service-role only by design
* (see migration 20260802090000).
*/
import crypto from 'crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { WhatsAppPhoneLink } from '@/types'
import { encryptPhone, hashPhone, maskPhone } from './phone-crypto'
/** Uppercased twin of generate_inbox_local_part's ambiguity-free alphabet
* (no I/L/O/U, no 0/1): codes survive being read aloud or retyped. */
export const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTVWXYZ23456789'
export const CODE_PREFIX = 'AC-'
export const CODE_LENGTH = 6
export const CODE_TTL_MS = 10 * 60 * 1000
export function hashLinkCode(code: string): string {
return crypto.createHash('sha256').update(code).digest('hex')
}
/**
* Normalize a chat message into a canonical code ('AC-7KP4QF') or null when
* the text does not look like a code at all. Forgiving on formatting (trims,
* uppercases, tolerates a missing hyphen), but a BARE 6-char body without the
* AC prefix must contain at least one digit: otherwise ordinary greetings
* built from alphabet letters ('hej hej' -> HEJHEJ) would read as codes and
* earn a confusing M2 instead of the M1 greeting. The panel and the wa.me
* prefill always carry the prefix, so prefixed codes are never rejected.
*/
export function normalizeLinkCode(text: string | null | undefined): string | null {
if (!text) return null
const compact = text.trim().toUpperCase().replace(/\s+/g, '')
const match = compact.match(/^(AC-?)?([A-Z2-9]{6})$/)
if (!match) return null
const hasPrefix = match[1] != null
const body = match[2]
for (const ch of body) {
if (!CODE_ALPHABET.includes(ch)) return null
}
if (!hasPrefix && !/[2-9]/.test(body)) return null
return `${CODE_PREFIX}${body}`
}
export function looksLikeLinkCode(text: string | null | undefined): boolean {
return normalizeLinkCode(text) !== null
}
export interface MintedCode {
code: string
expiresAt: string
}
/** Mint a fresh code for the settings panel. Earlier unused codes stay valid
* until their own 10-minute expiry; the webhook consumes whichever arrives. */
export async function mintLinkCode(
serviceClient: SupabaseClient,
userId: string,
): Promise<MintedCode> {
let body = ''
for (let i = 0; i < CODE_LENGTH; i++) {
body += CODE_ALPHABET[crypto.randomInt(CODE_ALPHABET.length)]
}
const code = `${CODE_PREFIX}${body}`
const expiresAt = new Date(Date.now() + CODE_TTL_MS).toISOString()
const { error } = await serviceClient.from('whatsapp_link_codes').insert({
user_id: userId,
code_hash: hashLinkCode(code),
expires_at: expiresAt,
})
if (error) throw new Error(`Failed to mint link code: ${error.message}`)
return { code, expiresAt }
}
/**
* Verify + consume a code from an inbound chat message. Returns the owning
* user id, or null for unknown/expired/already-used codes. Single-use is
* enforced by the guarded UPDATE (used_at IS NULL): a concurrent redelivery
* loses the race and gets null.
*/
export async function consumeLinkCode(
serviceClient: SupabaseClient,
rawText: string,
): Promise<{ userId: string } | null> {
const code = normalizeLinkCode(rawText)
if (!code) return null
const { data: row } = await serviceClient
.from('whatsapp_link_codes')
.select('id, user_id, expires_at, used_at')
.eq('code_hash', hashLinkCode(code))
.maybeSingle()
if (!row || row.used_at) return null
if (new Date(row.expires_at).getTime() < Date.now()) return null
const { data: claimed } = await serviceClient
.from('whatsapp_link_codes')
.update({ used_at: new Date().toISOString() })
.eq('id', row.id)
.is('used_at', null)
.select('id')
.maybeSingle()
if (!claimed) return null
return { userId: row.user_id }
}
export interface CreatedPhoneLink {
link: WhatsAppPhoneLink
conversationId: string | null
}
/**
* Bind a verified phone to a user: revoke whatever active links stand in the
* way of the two partial-unique indexes (same phone bound elsewhere, or the
* user re-linking from a new phone), then insert the link + its conversation
* row. Revocation-not-deletion keeps the trail auditable.
*/
export async function createPhoneLink(
serviceClient: SupabaseClient,
args: { userId: string; phone: string; profileName?: string | null },
): Promise<CreatedPhoneLink> {
const phoneHash = hashPhone(args.phone)
const now = new Date().toISOString()
await serviceClient
.from('whatsapp_phone_links')
.update({ revoked_at: now })
.eq('phone_hash', phoneHash)
.is('revoked_at', null)
await serviceClient
.from('whatsapp_phone_links')
.update({ revoked_at: now })
.eq('user_id', args.userId)
.is('revoked_at', null)
const { data: link, error } = await serviceClient
.from('whatsapp_phone_links')
.insert({
user_id: args.userId,
phone_hash: phoneHash,
phone_enc: encryptPhone(args.phone),
phone_masked: maskPhone(args.phone),
wa_profile_name: args.profileName?.slice(0, 200) ?? null,
last_message_at: now,
})
.select('*')
.single()
if (error || !link) {
throw new Error(`Failed to create phone link: ${error?.message ?? 'no row returned'}`)
}
const { data: conversation } = await serviceClient
.from('whatsapp_conversations')
.insert({
phone_link_id: link.id,
last_inbound_at: now,
service_window_expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
})
.select('id')
.maybeSingle()
return { link: link as WhatsAppPhoneLink, conversationId: conversation?.id ?? null }
}
/** Active (non-revoked) link for a phone hash, or null. */
export async function lookupActiveLink(
serviceClient: SupabaseClient,
phoneHash: string,
): Promise<WhatsAppPhoneLink | null> {
const { data } = await serviceClient
.from('whatsapp_phone_links')
.select('*')
.eq('phone_hash', phoneHash)
.is('revoked_at', null)
.maybeSingle()
return (data as WhatsAppPhoneLink | null) ?? null
}
@@ -0,0 +1,181 @@
/**
* Outbound bot copy for the WhatsApp channel (PR3 subset of the approved
* conversation spec M1-M18; M5-M10 belong to the PR4 conversation layer).
*
* Server-side constants, NOT messages/*.json: these strings are sent from
* webhook/worker contexts where no next-intl session exists (same pattern as
* lib/email/reminder-templates.ts). Domain terms (Underlag, verifikation) stay
* Swedish in both locales per the i18n rules. WhatsApp formatting subset only
* (*bold*, _italic_); never em/en dashes, per repo hard rule.
*
* Locale: the chat has no session, and no per-user locale is stored server-side
* today, so dispatch always sends 'sv'. The 'en' variants exist so the wiring
* is a one-line change once a stored locale exists.
*/
export type BotLocale = 'sv' | 'en'
export interface LinkedTemplateArgs {
companyName?: string | null
companyCount?: number
merchant?: string | null
amount?: string | null
date?: string | null
minutes?: number
}
/** Stable ids stamped into whatsapp_messages.raw_payload.template on every send
* (throttle checks and tests key on these, never on the copy). */
export const TEMPLATE = {
m1Unlinked: 'm1_unlinked',
m2BadCode: 'm2_bad_code',
m3Linked: 'm3_linked',
m4Ack: 'm4_ack',
m4AckEmpty: 'm4_ack_empty',
m4Duplicate: 'm4_duplicate',
m6NoDefaultCompany: 'm6_no_default_company',
m11Stop: 'm11_stop',
m12Start: 'm12_start',
m13Help: 'm13_help',
m14Voice: 'm14_voice',
m15Unsupported: 'm15_unsupported',
m16Fallback: 'm16_fallback',
m17RateLimited: 'm17_rate_limited',
m18Error: 'm18_error',
} as const
export type TemplateId = (typeof TEMPLATE)[keyof typeof TEMPLATE]
const SV = {
m1Unlinked: () =>
'Hej! Det här är *Accounteds kvittomottagning*, en automatisk AI-tjänst.\n\n' +
'För att koppla ditt nummer: öppna Accounted, gå till *Inställningar -> WhatsApp* och skicka den 6-siffriga koden här.\n\n' +
'Skriv *hjälp* för att nå en människa.',
m2BadCode: () =>
'Koden stämmer inte eller har gått ut. Hämta en ny under *Inställningar -> WhatsApp* i Accounted och skicka den här inom 10 minuter.',
m3Linked: ({ companyName, companyCount = 1 }: LinkedTemplateArgs) => {
const intro = companyName
? `Klart! Ditt nummer är kopplat till *${companyName}*.`
: 'Klart! Ditt nummer är kopplat till *Accounted*.'
const tips =
'Skicka kvitton hit som foto eller PDF så lägger jag dem i *Underlag* i Accounted. ' +
'Två tips: skicka som _dokument_ (gem-ikonen) för bästa skärpa, och en fil per kvitto. Flersidiga kvitton skickas som PDF.'
const outro =
'Jag är en AI-assistent. Skriv *hjälp* för mänsklig support, *stopp* för att koppla från.'
const multi =
companyCount > 1
? `Du är med i ${companyCount} företag i Accounted. Välj standardföretag i panelen så hamnar kvitton rätt. `
: ''
return `${intro}\n\n${tips}\n\n${multi}${outro}`
},
m4Ack: ({ merchant, amount, date }: LinkedTemplateArgs) => {
const who = merchant ? `${merchant}, ` : ''
const when = date ? ` (${date})` : ''
return `*Kvitto mottaget:* ${who}${amount}${when}. Ligger i Underlag, granska och bokför i appen.`
},
m4AckEmpty: () =>
'*Kvitto mottaget.* Jag kunde inte läsa av beloppet, komplettera i appen under Underlag.',
m4Duplicate: () => 'Det kvittot finns redan i Underlag.',
m6NoDefaultCompany: () =>
'Du är med i flera företag. Välj standardföretag under *Inställningar -> WhatsApp* i Accounted, så tar jag emot kvitton här. Kvittot sparades inte.',
m11Stop: () =>
'Okej, jag slutar svara här och ditt nummer kopplas från. Dina underlag i Accounted påverkas inte. Skicka *start* om du vill aktivera igen.',
m12Start: () => 'Välkommen tillbaka! Ditt nummer är aktivt igen, skicka kvitton när du vill.',
m13Help: () =>
'Du når en människa på *support@accounted.se*, vi svarar vardagar, oftast samma dag. Skriv gärna vilket företag det gäller.\n\n' +
'Här i chatten tar jag annars emot kvitton och underlag (foto eller PDF).',
m14Voice: () =>
'Jag kan inte lyssna på röstmeddelanden ännu. Skicka kvittot som foto eller PDF, eller skriv en kort rad text.',
m15Unsupported: () =>
'Jag kan bara ta emot bilder och PDF-dokument (bild max 5 MB). Skicka kvittot som foto eller PDF.',
m16Fallback: () =>
'Jag är en automatisk kvittomottagning och kan inte svara på frågor här i chatten. Skicka ett kvitto (foto/PDF) så tar jag hand om det, eller skriv *hjälp* för mänsklig support.',
m17RateLimited: ({ minutes = 10 }: LinkedTemplateArgs) =>
`Det blev många filer på kort tid, jag pausar mottagningen en stund. Försök igen om cirka ${minutes} minuter.`,
m18Error: () =>
'Något gick fel när jag tog emot filen. Försök igen om en stund, eller ladda upp kvittot direkt i appen under *Underlag*. Skriv *hjälp* om det fortsätter.',
}
const EN: typeof SV = {
m1Unlinked: () =>
'Hi! This is *Accounted receipt intake*, an automated AI service.\n\n' +
'To link your number: open Accounted, go to *Settings -> WhatsApp* and send the 6-character code here.\n\n' +
'Type *hjälp* to reach a human.',
m2BadCode: () =>
'That code is wrong or has expired. Get a new one under *Settings -> WhatsApp* in Accounted and send it here within 10 minutes.',
m3Linked: ({ companyName, companyCount = 1 }: LinkedTemplateArgs) => {
const intro = companyName
? `Done! Your number is linked to *${companyName}*.`
: 'Done! Your number is linked to *Accounted*.'
const tips =
'Send receipts here as a photo or PDF and I will file them under *Underlag* in Accounted. ' +
'Two tips: send as a _document_ (the paperclip icon) for full sharpness, and one file per receipt. Multi-page receipts go as PDF.'
const outro =
'I am an AI assistant. Type *hjälp* for human support, *stopp* to disconnect.'
const multi =
companyCount > 1
? `You belong to ${companyCount} companies in Accounted. Pick a default company in the panel so receipts land in the right one. `
: ''
return `${intro}\n\n${tips}\n\n${multi}${outro}`
},
m4Ack: ({ merchant, amount, date }: LinkedTemplateArgs) => {
const who = merchant ? `${merchant}, ` : ''
const when = date ? ` (${date})` : ''
return `*Receipt received:* ${who}${amount}${when}. It is in Underlag, review and book it in the app.`
},
m4AckEmpty: () =>
'*Receipt received.* I could not read the amount, complete it in the app under Underlag.',
m4Duplicate: () => 'That receipt is already in Underlag.',
m6NoDefaultCompany: () =>
'You belong to several companies. Pick a default company under *Settings -> WhatsApp* in Accounted, then I can receive receipts here. The receipt was not saved.',
m11Stop: () =>
'Okay, I will stop replying here and your number is disconnected. Your documents in Accounted are not affected. Send *start* to activate again.',
m12Start: () => 'Welcome back! Your number is active again, send receipts whenever you like.',
m13Help: () =>
'You reach a human at *support@accounted.se*, we reply on weekdays, usually the same day. Please mention which company it concerns.\n\n' +
'Here in the chat I otherwise receive receipts and documents (photo or PDF).',
m14Voice: () =>
'I cannot listen to voice messages yet. Send the receipt as a photo or PDF, or write a short line of text.',
m15Unsupported: () =>
'I can only receive images and PDF documents (image max 5 MB). Send the receipt as a photo or PDF.',
m16Fallback: () =>
'I am an automated receipt intake and cannot answer questions here in the chat. Send a receipt (photo/PDF) and I will handle it, or type *hjälp* for human support.',
m17RateLimited: ({ minutes = 10 }: LinkedTemplateArgs) =>
`That was a lot of files in a short time, I am pausing intake for a bit. Try again in about ${minutes} minutes.`,
m18Error: () =>
'Something went wrong receiving the file. Try again in a moment, or upload the receipt directly in the app under *Underlag*. Type *hjälp* if it keeps happening.',
}
const COPY: Record<BotLocale, typeof SV> = { sv: SV, en: EN }
export function botCopy(locale: BotLocale = 'sv'): typeof SV {
return COPY[locale] ?? SV
}
@@ -0,0 +1,77 @@
/**
* Phone-number PII primitives for the WhatsApp channel.
*
* Three representations, three purposes:
* - hashPhone: HMAC-SHA256 with a server-side pepper (WHATSAPP_PHONE_HASH_KEY).
* The DB lookup key. A plain sha256 would be brute-forceable
* over the ~10^9 phone-number space, hence the pepper.
* - encryptPhone: AES-256-GCM (WHATSAPP_PHONE_ENCRYPTION_KEY), mirrors the
* lib/auth/bankid.ts codec (iv 12 | tag 16 | ciphertext) but
* with its own key: self-hosters without BankID must be able
* to run this channel. Stored hex-encoded in a text column.
* - maskPhone: display-only shape for the settings panel.
*
* Keys are deliberately NOT shared with the BankID key material.
*/
import crypto from 'crypto'
const ALGORITHM = 'aes-256-gcm'
/** Meta sends wa_id / from as E.164 digits without '+'. Normalize to digits only. */
export function normalizePhone(raw: string): string {
return String(raw ?? '').replace(/\D/g, '')
}
function getHashKey(): Buffer {
const key = process.env.WHATSAPP_PHONE_HASH_KEY
if (!key) throw new Error('WHATSAPP_PHONE_HASH_KEY is required for WhatsApp operations')
return Buffer.from(key, 'utf8')
}
function getEncryptionKey(): Buffer {
const key = process.env.WHATSAPP_PHONE_ENCRYPTION_KEY
if (!key) throw new Error('WHATSAPP_PHONE_ENCRYPTION_KEY is required for WhatsApp operations')
return Buffer.from(key, 'hex')
}
/** Peppered lookup hash (hex). Input is normalized first so '+46 70...' and '4670...' collide. */
export function hashPhone(rawPhone: string): string {
return crypto.createHmac('sha256', getHashKey()).update(normalizePhone(rawPhone)).digest('hex')
}
/** AES-256-GCM encrypt a phone number. Returns hex(iv | tag | ciphertext) for a text column. */
export function encryptPhone(rawPhone: string): string {
const key = getEncryptionKey()
const iv = crypto.randomBytes(12)
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
const encrypted = Buffer.concat([cipher.update(normalizePhone(rawPhone), 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return Buffer.concat([iv, tag, encrypted]).toString('hex')
}
/** Decrypt a stored hex(iv | tag | ciphertext) value back to the digit string. */
export function decryptPhone(stored: string): string {
const key = getEncryptionKey()
const raw = Buffer.from(stored, 'hex')
const iv = raw.subarray(0, 12)
const tag = raw.subarray(12, 28)
const encrypted = raw.subarray(28)
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8')
}
/**
* Mask for display: '+46 70 *** ** 67'. Keeps country code + two leading
* digits + two trailing digits; everything in between is starred. Works for
* any E.164 length; very short inputs degrade to a fully starred value.
*/
export function maskPhone(rawPhone: string): string {
const digits = normalizePhone(rawPhone)
if (digits.length < 8) return '+** *** ** **'
const cc = digits.slice(0, 2)
const lead = digits.slice(2, 4)
const tail = digits.slice(-2)
return `+${cc} ${lead} *** ** ${tail}`
}
@@ -0,0 +1,358 @@
/**
* Deferred intake worker: one inbound media message -> one Underlag item.
*
* The webhook persists the message row and 200s fast; this worker runs via
* the after() idiom (lib/webhooks/dispatch-kick.ts) on the same instance.
* The whatsapp_messages row IS the durable job record: the atomic claim
* (UPDATE ... WHERE processing_status='received' RETURNING) makes redelivered
* webhooks and the PR4 sweep cron safe to race.
*
* Failure policy: NOTHING here returns a retryable status to Meta. Rejected
* and rate-limited content acks in chat and lands as 'skipped'; real failures
* land as 'error' + error_message with a single M18 to the user.
*/
import { after } from 'next/server'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { checkInboxUploadRateLimit } from '@/lib/rate-limits/inbox'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { computeSHA256 } from '@/lib/core/documents/document-service'
import { createLogger } from '@/lib/logger'
import { uploadAndExtract } from '@/extensions/general/invoice-inbox/lib/upload-and-extract'
import type { InvoiceExtractionResult, WhatsAppMessage, WhatsAppPhoneLink } from '@/types'
import { sendText, markReadWithTyping, downloadMedia, GraphApiError } from './graph-api'
import { botCopy, TEMPLATE } from './messages'
const log = createLogger('whatsapp-inbox/process-inbound')
/** Chat intake accepts what phones actually produce. Narrower than the upload
* allowlist on purpose: WhatsApp transcodes photos to JPEG, so HEIC never
* arrives, and everything else gets the M15 nudge. */
export const CHAT_ALLOWED_MIME_TYPES: ReadonlySet<string> = new Set([
'image/jpeg',
'image/png',
'image/webp',
'application/pdf',
])
/** M17 is sent at most once per this window per sender, not once per file. */
const RATE_LIMIT_NOTICE_WINDOW_MS = 10 * 60 * 1000
const EXTENSION_FOR_MIME: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'application/pdf': 'pdf',
}
function fallbackFilename(mime: string): string {
const ext = EXTENSION_FOR_MIME[mime] ?? 'bin'
return `whatsapp-${new Date().toISOString().slice(0, 10)}.${ext}`
}
function formatSek(amount: number): string {
return `${new Intl.NumberFormat('sv-SE', {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(amount)} kr`
}
async function loadRow(
supabase: SupabaseClient,
messageId: string,
): Promise<WhatsAppMessage | null> {
const { data } = await supabase
.from('whatsapp_messages')
.select('*')
.eq('id', messageId)
.maybeSingle()
return (data as WhatsAppMessage | null) ?? null
}
async function loadLink(
supabase: SupabaseClient,
phoneLinkId: string,
): Promise<WhatsAppPhoneLink | null> {
const { data } = await supabase
.from('whatsapp_phone_links')
.select('*')
.eq('id', phoneLinkId)
.maybeSingle()
return (data as WhatsAppPhoneLink | null) ?? null
}
/**
* Resolve which company the receipt lands in (PR3 ladder: default company if
* still a member -> sole membership -> null). The PR4 conversation pin slots
* in above default when it exists.
*/
async function resolveCompanyId(
supabase: SupabaseClient,
link: WhatsAppPhoneLink,
): Promise<string | null> {
if (link.default_company_id) {
const { data: membership } = await supabase
.from('company_members')
.select('company_id')
.eq('user_id', link.user_id)
.eq('company_id', link.default_company_id)
.maybeSingle()
if (membership) return link.default_company_id
}
const { data: memberships } = await supabase
.from('company_members')
.select('company_id')
.eq('user_id', link.user_id)
const companyIds = [...new Set((memberships ?? []).map((m) => m.company_id as string))]
if (companyIds.length === 1) return companyIds[0]
return null
}
/** True when an M17 notice already went to this sender inside the window. */
async function rateLimitNoticeAlreadySent(
supabase: SupabaseClient,
senderPhoneHash: string,
): Promise<boolean> {
const since = new Date(Date.now() - RATE_LIMIT_NOTICE_WINDOW_MS).toISOString()
const { data } = await supabase
.from('whatsapp_messages')
.select('id')
.eq('direction', 'outbound')
.eq('sender_phone_hash', senderPhoneHash)
.eq('raw_payload->>template', TEMPLATE.m17RateLimited)
.gte('created_at', since)
.limit(1)
.maybeSingle()
return data != null
}
async function markStatus(
supabase: SupabaseClient,
messageId: string,
status: 'skipped' | 'error' | 'done',
extra: { errorMessage?: string | null; inboxItemId?: string | null } = {},
): Promise<void> {
// Literal payload (no spread) so the phantom-column scanner can verify it.
// Writing null where the caller passed nothing matches the columns' actual
// state on every path that reaches here.
await supabase
.from('whatsapp_messages')
.update({
processing_status: status,
error_message: extra.errorMessage ?? null,
inbox_item_id: extra.inboxItemId ?? null,
})
.eq('id', messageId)
}
/**
* Process one inbound media message end to end. Never throws.
*/
export async function processInboundMessage(
supabase: SupabaseClient,
messageId: string,
): Promise<void> {
const row = await loadRow(supabase, messageId)
if (!row) return
// Atomic claim: only a row still in 'received' can be taken, so a webhook
// redelivery racing this worker (or the PR4 sweep) claims exactly once.
const { data: claimed } = await supabase
.from('whatsapp_messages')
.update({ processing_status: 'processing', attempts: row.attempts + 1 })
.eq('id', messageId)
.eq('processing_status', 'received')
.select('id')
.maybeSingle()
if (!claimed) return
const attempt = row.attempts + 1
const copy = botCopy('sv')
const to = extractRecipient(row)
const replyBase = {
senderPhoneHash: row.sender_phone_hash,
phoneLinkId: row.phone_link_id,
conversationId: row.conversation_id,
correlationId: row.correlation_id,
}
try {
if (!row.phone_link_id || !row.media_id || !to) {
await markStatus(supabase, messageId, 'error', {
errorMessage: 'Message row is missing link or media reference',
})
return
}
if (row.wamid) await markReadWithTyping(row.wamid)
const link = await loadLink(supabase, row.phone_link_id)
if (!link || link.revoked_at) {
await markStatus(supabase, messageId, 'skipped', {
errorMessage: 'Phone link revoked before processing',
})
return
}
// ── Company resolution ─────────────────────────────────
const companyId = await resolveCompanyId(supabase, link)
if (!companyId) {
await sendText(supabase, { to, body: copy.m6NoDefaultCompany(), template: TEMPLATE.m6NoDefaultCompany, ...replyBase })
await markStatus(supabase, messageId, 'skipped', {
errorMessage: 'No default company set for multi-company sender',
})
return
}
// ── Per-company intake quota (ack-and-drop, never retryable) ──
const limit = await checkInboxUploadRateLimit(supabase, companyId)
if (!limit.ok) {
try {
await appendProcessingHistory({
companyId,
correlationId: row.correlation_id ?? messageId,
aggregateType: 'System',
aggregateId: messageId,
eventType: 'RateLimitedDropped',
payload: {
channel: 'whatsapp',
scope: limit.scope,
retry_after_sec: limit.retryAfterSec,
whatsapp_message_id: messageId,
},
actor: { type: 'system', id: 'whatsapp-inbound' },
occurredAt: new Date(),
})
} catch (err) {
log.error('RateLimitedDropped append failed', err)
}
if (row.sender_phone_hash && !(await rateLimitNoticeAlreadySent(supabase, row.sender_phone_hash))) {
await sendText(supabase, { to, body: copy.m17RateLimited({}), template: TEMPLATE.m17RateLimited, ...replyBase })
}
await markStatus(supabase, messageId, 'skipped', { errorMessage: 'Rate limited' })
return
}
// ── MIME allowlist ─────────────────────────────────────
const mime = (row.media_mime ?? '').split(';')[0].trim().toLowerCase()
if (!CHAT_ALLOWED_MIME_TYPES.has(mime)) {
await sendText(supabase, { to, body: copy.m15Unsupported(), template: TEMPLATE.m15Unsupported, ...replyBase })
await markStatus(supabase, messageId, 'skipped', {
errorMessage: `Unsupported media type: ${mime || 'unknown'}`,
})
return
}
// ── Download (fresh URL per media id; 10 MB stream-checked cap) ──
const media = await downloadMedia(row.media_id)
// ── Exact duplicate check within the company ───────────
const sha256 = await computeSHA256(media.buffer)
const { data: duplicate } = await supabase
.from('document_attachments')
.select('id')
.eq('company_id', companyId)
.eq('sha256_hash', sha256)
.limit(1)
.maybeSingle()
if (duplicate) {
await sendText(supabase, { to, body: copy.m4Duplicate(), template: TEMPLATE.m4Duplicate, ...replyBase })
await markStatus(supabase, messageId, 'skipped', { errorMessage: 'Duplicate document (sha256)' })
return
}
// ── Upload + extract (the shared invoice-inbox funnel) ──
const result = await uploadAndExtract(
supabase,
link.user_id,
companyId,
{ name: row.media_filename || fallbackFilename(mime), buffer: media.buffer, type: mime },
'whatsapp',
undefined,
undefined,
{
channelMeta: { whatsappMessageId: messageId, caption: row.body_text ?? null },
actorId: 'whatsapp-inbound',
},
)
await markStatus(supabase, messageId, 'done', {
inboxItemId: result.inbox_item_id,
})
// ── Receipt ack ────────────────────────────────────────
const extracted = result.extracted_data as InvoiceExtractionResult | undefined
const total = extracted?.totals?.total ?? null
if (total != null) {
await sendText(supabase, {
to,
body: copy.m4Ack({
merchant: extracted?.supplier?.name ?? null,
amount: formatSek(total),
date: extracted?.invoice?.invoiceDate ?? null,
}),
template: TEMPLATE.m4Ack,
...replyBase,
})
} else {
await sendText(supabase, { to, body: copy.m4AckEmpty(), template: TEMPLATE.m4AckEmpty, ...replyBase })
}
} catch (err) {
const message =
err instanceof GraphApiError || err instanceof Error ? err.message : String(err)
log.error('WhatsApp intake processing failed', err, { messageId })
try {
await markStatus(supabase, messageId, 'error', { errorMessage: message.slice(0, 500) })
// M18 once per message: only on the first attempt, so a PR4 sweep
// re-claim of the same row never spams the sender.
if (to && attempt <= 1) {
await sendText(supabase, { to, body: botCopy('sv').m18Error(), template: TEMPLATE.m18Error, ...replyBase })
}
} catch (innerErr) {
log.error('Failed to record WhatsApp processing error', innerErr, { messageId })
}
}
}
/**
* The recipient phone (E.164 digits) for replies. The raw inbound payload is
* persisted verbatim for linked senders, so `from` is read back from it: the
* row itself never stores the raw number outside raw_payload/phone_enc.
*/
function extractRecipient(row: WhatsAppMessage): string | null {
const raw = row.raw_payload as { from?: unknown } | null
return raw && typeof raw.from === 'string' && raw.from.length > 0 ? raw.from : null
}
/**
* Schedule processing of freshly persisted message rows after the webhook
* response is sent. Exact dispatch-kick idiom: never awaited by the caller,
* never throws, falls back to a microtask outside a request scope (tests).
*/
export function kickInboundProcessing(messageIds: string[]): void {
if (messageIds.length === 0) return
const run = async (): Promise<void> => {
try {
const supabase = createServiceClientNoCookies()
for (const id of messageIds) {
await processInboundMessage(supabase, id)
}
} catch (err) {
// The PR4 sweep cron re-claims 'received' rows: a failed kick is a
// latency regression, never a lost message.
log.warn('deferred WhatsApp processing failed; sweep will retry', {
error: err instanceof Error ? err.message : String(err),
})
}
}
try {
after(() => run())
} catch {
queueMicrotask(() => void run())
}
}
@@ -0,0 +1,214 @@
/**
* Zod parser for the Meta Cloud API webhook envelope (already signature
* verified). Extracts messages[] and statuses[] from
* entry[].changes[].value and flattens them for the dispatcher.
*
* Contract: NEVER throws. Anything that does not match the envelope parses to
* an empty result; any single message that does not match a known shape
* becomes type 'unknown' (a safe skip) rather than failing the batch. Meta
* adds message types over time and a new type must not take down intake for
* the whole webhook delivery.
*/
import { z } from 'zod'
const MediaSchema = z.object({
id: z.string().min(1),
mime_type: z.string().max(120).optional(),
sha256: z.string().max(200).optional(),
filename: z.string().max(500).optional(),
caption: z.string().max(4096).optional(),
voice: z.boolean().optional(),
})
// Permissive per-message schema: `type` is an open string and per-type payloads
// are all optional, so an unexpected combination degrades instead of failing.
const MessageSchema = z.object({
from: z.string().min(1).max(30),
id: z.string().min(1).max(200),
timestamp: z.string().max(30).optional(),
type: z.string().max(40),
text: z.object({ body: z.string().max(65536) }).optional(),
image: MediaSchema.optional(),
document: MediaSchema.optional(),
audio: MediaSchema.optional(),
video: MediaSchema.optional(),
sticker: MediaSchema.optional(),
context: z.object({ id: z.string().max(200).optional() }).optional(),
})
const StatusSchema = z.object({
id: z.string().min(1).max(200),
status: z.string().max(40),
})
const ContactSchema = z.object({
wa_id: z.string().max(30).optional(),
profile: z.object({ name: z.string().max(200).optional() }).optional(),
})
const EnvelopeSchema = z.object({
object: z.string().optional(),
entry: z
.array(
z.object({
changes: z
.array(
z.object({
field: z.string().optional(),
value: z
.object({
messages: z.array(z.unknown()).optional(),
statuses: z.array(z.unknown()).optional(),
contacts: z.array(z.unknown()).optional(),
})
.optional(),
}),
)
.optional(),
}),
)
.optional(),
})
export type ParsedMessageType =
| 'text'
| 'image'
| 'document'
| 'audio'
| 'video'
| 'sticker'
| 'location'
| 'contacts'
| 'unknown'
export interface ParsedMedia {
id: string
mime: string | null
sha256: string | null
filename: string | null
voice: boolean
}
export interface ParsedInboundMessage {
wamid: string
from: string
timestamp: string | null
type: ParsedMessageType
/** Body for text messages. */
text: string | null
/** Caption for media messages. */
caption: string | null
media: ParsedMedia | null
/** Quoted-reply target (context.id), when present. */
contextWamid: string | null
profileName: string | null
/** The raw value.messages[i] object, persisted verbatim for known senders. */
raw: unknown
}
export interface ParsedStatus {
wamid: string
status: string
}
export interface ParsedWebhook {
messages: ParsedInboundMessage[]
statuses: ParsedStatus[]
}
const KNOWN_TYPES: ReadonlySet<string> = new Set([
'text',
'image',
'document',
'audio',
'video',
'sticker',
'location',
'contacts',
])
function toParsedMessage(
raw: unknown,
profileNames: Map<string, string>,
): ParsedInboundMessage | null {
const parsed = MessageSchema.safeParse(raw)
if (!parsed.success) return null
const msg = parsed.data
const type: ParsedMessageType = KNOWN_TYPES.has(msg.type)
? (msg.type as ParsedMessageType)
: 'unknown'
const mediaSource =
type === 'image'
? msg.image
: type === 'document'
? msg.document
: type === 'audio'
? msg.audio
: type === 'video'
? msg.video
: type === 'sticker'
? msg.sticker
: undefined
return {
wamid: msg.id,
from: msg.from,
timestamp: msg.timestamp ?? null,
type,
text: type === 'text' ? (msg.text?.body ?? null) : null,
caption: mediaSource?.caption ?? null,
media: mediaSource
? {
id: mediaSource.id,
mime: mediaSource.mime_type ?? null,
sha256: mediaSource.sha256 ?? null,
filename: mediaSource.filename ?? null,
voice: mediaSource.voice === true,
}
: null,
contextWamid: msg.context?.id ?? null,
profileName: profileNames.get(msg.from) ?? null,
raw,
}
}
/**
* Parse a verified webhook body. Returns flattened messages and statuses in
* arrival order. Never throws; unparseable input yields empty arrays.
*/
export function parseWebhookEnvelope(body: unknown): ParsedWebhook {
const result: ParsedWebhook = { messages: [], statuses: [] }
const envelope = EnvelopeSchema.safeParse(body)
if (!envelope.success) return result
for (const entry of envelope.data.entry ?? []) {
for (const change of entry.changes ?? []) {
const value = change.value
if (!value) continue
const profileNames = new Map<string, string>()
for (const rawContact of value.contacts ?? []) {
const contact = ContactSchema.safeParse(rawContact)
if (contact.success && contact.data.wa_id && contact.data.profile?.name) {
profileNames.set(contact.data.wa_id, contact.data.profile.name)
}
}
for (const rawMessage of value.messages ?? []) {
const parsed = toParsedMessage(rawMessage, profileNames)
if (parsed) result.messages.push(parsed)
}
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 })
}
}
}
return result
}
@@ -0,0 +1,47 @@
/**
* Meta webhook authenticity checks.
*
* POST: X-Hub-Signature-256 is 'sha256=' + hex(HMAC-SHA256(rawBody, app secret)),
* computed over the RAW request body. Verify BEFORE any JSON parse: a body that
* fails the HMAC is untrusted input and must never reach a parser.
*
* GET: the one-time subscription handshake sends hub.verify_token; echo
* hub.challenge only when the token matches.
*
* Both compares are length-guarded timingSafeEqual (same shape as
* lib/webhooks/signing.ts): Buffer.from(x, 'hex') silently drops invalid hex,
* so lengths are compared AFTER decoding to keep a malformed header from
* throwing RangeError instead of returning false.
*/
import crypto from 'crypto'
const SIGNATURE_PREFIX = 'sha256='
export function verifyMetaSignature(
rawBody: string,
header: string | null | undefined,
appSecret: string,
): boolean {
if (!header || !appSecret) return false
const provided = header.startsWith(SIGNATURE_PREFIX)
? header.slice(SIGNATURE_PREFIX.length)
: header
const expected = crypto.createHmac('sha256', appSecret).update(rawBody, 'utf8').digest('hex')
const expectedBuf = Buffer.from(expected, 'hex')
const providedBuf = Buffer.from(provided, 'hex')
if (expectedBuf.length !== providedBuf.length) return false
return crypto.timingSafeEqual(expectedBuf, providedBuf)
}
/** Constant-time compare of the GET handshake's hub.verify_token. */
export function verifyChallengeToken(
token: string | null | undefined,
expected: string | null | undefined,
): boolean {
if (!token || !expected) return false
const a = Buffer.from(token, 'utf8')
const b = Buffer.from(expected, 'utf8')
if (a.length !== b.length) return false
return crypto.timingSafeEqual(a, b)
}
@@ -0,0 +1,27 @@
{
"id": "whatsapp-inbox",
"sector": "general",
"exportName": "whatsappInboxExtension",
"entryPoint": "@/extensions/general/whatsapp-inbox",
"workspace": null,
"requiredEnvVars": [
"WHATSAPP_ACCESS_TOKEN",
"WHATSAPP_PHONE_NUMBER_ID",
"WHATSAPP_APP_SECRET",
"WHATSAPP_VERIFY_TOKEN",
"WHATSAPP_PHONE_HASH_KEY",
"WHATSAPP_PHONE_ENCRYPTION_KEY"
],
"optionalEnvVars": ["WHATSAPP_PUBLIC_NUMBER"],
"npmDependencies": [],
"definition": {
"name": "WhatsApp-inkorg",
"category": "import",
"icon": "MessageCircle",
"dataPattern": "both",
"hasOwnData": true,
"readsCoreTables": ["company_members", "document_attachments", "invoice_inbox_items"],
"description": "Skicka kvitton som foto eller PDF till Accounteds WhatsApp-nummer: de landar i Underlag med avlästa fält",
"longDescription": "Koppla ditt mobilnummer med en engångskod och skicka sedan kvitton direkt i WhatsApp. Varje kvitto laddas upp till dokumentarkivet, fält som belopp och datum läses av med AI, och du får en bekräftelse i chatten. Bokföringen sker som vanligt i appen."
}
}