diff --git a/.env.example b/.env.example index 44d3f862..ca0bf1f3 100644 --- a/.env.example +++ b/.env.example @@ -56,6 +56,22 @@ CRON_SECRET=generate-a-random-secret # User-Key is entered by the user in the migration wizard) # BJORN_LUNDEN_CLIENT_ID= # BJORN_LUNDEN_CLIENT_SECRET= +# WhatsApp receipt intake (whatsapp-inbox extension, Meta Cloud API). +# ACCESS_TOKEN: system-user permanent token with whatsapp_business_messaging +# scope only. PHONE_NUMBER_ID: the Graph object id of the sending number. +# APP_SECRET verifies X-Hub-Signature-256 on the webhook; VERIFY_TOKEN is the +# GET-handshake shared secret you also enter in the Meta app dashboard. +# PHONE_HASH_KEY: random pepper for phone lookup hashes (openssl rand -hex 32). +# PHONE_ENCRYPTION_KEY: 32-byte hex AES-256-GCM key (openssl rand -hex 32). +# WHATSAPP_ACCESS_TOKEN= +# WHATSAPP_PHONE_NUMBER_ID= +# WHATSAPP_APP_SECRET= +# WHATSAPP_VERIFY_TOKEN= +# WHATSAPP_PHONE_HASH_KEY= +# WHATSAPP_PHONE_ENCRYPTION_KEY= +# Optional: the public number as E.164 digits (e.g. 46766867041) for the +# wa.me deep link in settings. Unset = resolved from the Graph API instead. +# WHATSAPP_PUBLIC_NUMBER= # Bolagsverket: digital inlämning av årsredovisning (bolagsverket extension). # BOLAGSVERKET_ENV is test | accept | prod (default test) and also caps which # environment a company may select in settings (test < accept < prod). diff --git a/DECISIONS.md b/DECISIONS.md index ce942305..58333a78 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -773,3 +773,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-01] Page-count gate (issue #553) changed from skip-extraction to slice-first-3-pages via pdf-lib: invoice data sits on page 1, and a >3-page supplier invoice getting ZERO fields was the worse failure mode. too_many_pages skip remains only for unsliceable (encrypted/malformed) PDFs; truncation recorded in extracted_data.pages. Side effect: client_opt_out now outranks too_many_pages in skip-reason priority (an opted-out caller never extracts regardless of length). [2026-08-01] Extraction classification fields (documentKind/payment/merchantCategory/legibility) validate with .catch(null) instead of strict enums: a hallucinated label must degrade to unknown, not sink the whole document parse; amounts keep strict parsing on purpose. HEIC handling = attempt sharp transcode at runtime, fall through to today's empty-extraction when libvips lacks HEIF (prebuilt binaries exclude it for patent reasons), with a UI hint replacing the silence. [2026-08-02] WhatsApp channel tables: whatsapp_messages/conversations/link_codes are RLS-enabled with NO policies (service-role only): rows hold third-party PII (phone hashes, chat text) with no company scope and no v1 UI reader; whatsapp_phone_links is USER-scoped (auth.uid()), not company-scoped, because a phone binding belongs to a person. whatsapp_conversations triaged into ARCHIVE_EXCLUDED_TABLES (bot state, company_id is only a pin; receipts live in document_attachments). check_and_increment_whatsapp_sender_quota is EXECUTE-granted to service_role only (quota-drain lesson from 20260726090000). +[2026-08-02] whatsapp-inbox link codes: a bare 6-char body without the AC prefix must contain a digit to count as a code; the ambiguity-free alphabet makes ordinary words ("hejhej") valid code shapes, and greeting text must earn M1, not a confusing "wrong code" M2. Prefixed codes (what the panel and wa.me prefill always send) are never rejected. +[2026-08-02] whatsapp-inbox webhook: truly unknown inbound types (reactions, ephemeral, future Meta additions) get silence + a skipped row instead of the M15 unsupported-content reply; answering a thumbs-up reaction with "I only take images and PDF" is noise. M15 stays for explicit content types (video/sticker/location/contacts). diff --git a/app/(dashboard)/settings/whatsapp/page.tsx b/app/(dashboard)/settings/whatsapp/page.tsx new file mode 100644 index 00000000..24a4970d --- /dev/null +++ b/app/(dashboard)/settings/whatsapp/page.tsx @@ -0,0 +1,5 @@ +import { WhatsAppSettingsContent } from '@/components/settings/sections/WhatsAppSettingsContent' + +export default function WhatsAppSettingsPage() { + return +} diff --git a/components/extensions/general/WhatsAppLinkPanel.tsx b/components/extensions/general/WhatsAppLinkPanel.tsx new file mode 100644 index 00000000..c2c4f415 --- /dev/null +++ b/components/extensions/general/WhatsAppLinkPanel.tsx @@ -0,0 +1,240 @@ +'use client' + +/** + * Settings panel for the whatsapp-inbox extension (Inställningar -> WhatsApp). + * + * Unlinked: mint a one-time code (10 min TTL) + wa.me deep link; the user + * sends the code from their phone and the webhook binds the number. + * Linked: masked phone, default-company select (multi-company routing), + * revoke. Muted (user sent *stopp* in chat) shows a hint: unmuting happens + * in the chat with *start*, not here. + */ + +import { useCallback, useEffect, useState } from 'react' +import { useTranslations } from 'next-intl' +import { Loader2, MessageCircle, ExternalLink } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { useToast } from '@/components/ui/use-toast' +import { useCompany } from '@/contexts/CompanyContext' +import { + SettingsGroup, + SettingsRow, + SettingsRowNote, + SettingsSelect, +} from '@/components/settings/SettingsRows' + +const BASE = '/api/extensions/ext/whatsapp-inbox' + +interface LinkStatus { + linked: boolean + phoneMasked?: string + defaultCompanyId?: string | null + muted?: boolean +} + +interface MintedCode { + code: string + expiresAt: string + waLink: string | null +} + +export function WhatsAppLinkPanel() { + const t = useTranslations('settings_whatsapp') + const { toast } = useToast() + const { companies } = useCompany() + + const [isLoading, setIsLoading] = useState(true) + const [loadFailed, setLoadFailed] = useState(false) + const [status, setStatus] = useState(null) + const [minted, setMinted] = useState(null) + const [isMinting, setIsMinting] = useState(false) + const [isSaving, setIsSaving] = useState(false) + const [minutesLeft, setMinutesLeft] = useState(null) + + const fetchStatus = useCallback(async () => { + setIsLoading(true) + setLoadFailed(false) + try { + const response = await fetch(`${BASE}/link`) + if (!response.ok) throw new Error('load failed') + const { data } = (await response.json()) as { data: LinkStatus } + setStatus(data) + } catch { + setLoadFailed(true) + } finally { + setIsLoading(false) + } + }, []) + + useEffect(() => { + void fetchStatus() + }, [fetchStatus]) + + // Countdown hint for the minted code (10 min TTL server-side). + useEffect(() => { + if (!minted) { + setMinutesLeft(null) + return + } + const tick = () => { + const msLeft = new Date(minted.expiresAt).getTime() - Date.now() + setMinutesLeft(msLeft > 0 ? Math.ceil(msLeft / 60_000) : 0) + } + tick() + const interval = setInterval(tick, 15_000) + return () => clearInterval(interval) + }, [minted]) + + const startLinking = async () => { + setIsMinting(true) + try { + const response = await fetch(`${BASE}/link/start`, { method: 'POST' }) + if (!response.ok) throw new Error('mint failed') + const { data } = (await response.json()) as { data: MintedCode } + setMinted(data) + } catch { + toast({ title: t('mint_failed'), variant: 'destructive' }) + } finally { + setIsMinting(false) + } + } + + const saveDefaultCompany = async (companyId: string) => { + setIsSaving(true) + try { + const response = await fetch(`${BASE}/link/default-company`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ companyId: companyId || null }), + }) + if (!response.ok) throw new Error('save failed') + setStatus((prev) => (prev ? { ...prev, defaultCompanyId: companyId || null } : prev)) + toast({ title: t('default_company_saved') }) + } catch { + toast({ title: t('default_company_save_failed'), variant: 'destructive' }) + } finally { + setIsSaving(false) + } + } + + const revoke = async () => { + if (!window.confirm(t('revoke_confirm'))) return + setIsSaving(true) + try { + const response = await fetch(`${BASE}/link/revoke`, { method: 'POST' }) + if (!response.ok) throw new Error('revoke failed') + setStatus({ linked: false }) + setMinted(null) + toast({ title: t('revoked_toast') }) + } catch { + toast({ title: t('revoke_failed'), variant: 'destructive' }) + } finally { + setIsSaving(false) + } + } + + if (isLoading) { + return ( +
+
+ ) + } + + if (loadFailed) { + return ( +
+ {t('load_failed')}{' '} + +
+ ) + } + + if (status?.linked) { + return ( + + + {status.phoneMasked} + + + {status.muted ? ( + + {t('muted_hint')} + + ) : null} + + + void saveDefaultCompany(event.target.value)} + > + + {companies.map(({ company }) => ( + + ))} + + + + + + {t('revoke_note')} + + + ) + } + + return ( + +
+

{t('unlinked_intro')}

+ + {minted ? ( +
+
+ + {minted.code} + + {minted.waLink ? ( + + ) : null} +
+
    +
  1. {t('step_open')}
  2. +
  3. {t('step_send_code')}
  4. +
  5. {t('step_confirm')}
  6. +
+

+ {minutesLeft != null && minutesLeft > 0 + ? t('expires_in', { minutes: minutesLeft }) + : minutesLeft === 0 + ? t('code_expired') + : t('expires_hint')} +

+
+ ) : ( + + )} +
+
+ ) +} + +export default WhatsAppLinkPanel diff --git a/components/settings/sections/WhatsAppSettingsContent.tsx b/components/settings/sections/WhatsAppSettingsContent.tsx new file mode 100644 index 00000000..d1fe85ba --- /dev/null +++ b/components/settings/sections/WhatsAppSettingsContent.tsx @@ -0,0 +1,17 @@ +'use client' + +import { useTranslations } from 'next-intl' +import { WhatsAppLinkPanel } from '@/components/extensions/general/WhatsAppLinkPanel' +import { SettingsSectionHeader } from '@/components/settings/SettingsRows' + +export function WhatsAppSettingsContent() { + const tNav = useTranslations('settings_nav') + const tIntro = useTranslations('settings_intro') + + return ( +
+ + +
+ ) +} diff --git a/components/settings/sections/index.ts b/components/settings/sections/index.ts index f9c36fcc..ecabf775 100644 --- a/components/settings/sections/index.ts +++ b/components/settings/sections/index.ts @@ -46,6 +46,10 @@ const BillingSettingsContent = dynamic(() => import('./BillingSettingsContent').then((module) => ({ default: module.BillingSettingsContent })), { loading: SettingsLoadingSkeleton }, ) +const WhatsAppSettingsContent = dynamic(() => + import('./WhatsAppSettingsContent').then((module) => ({ default: module.WhatsAppSettingsContent })), + { loading: SettingsLoadingSkeleton }, +) /** * Single source of truth mapping a settings section id to the component that @@ -66,6 +70,7 @@ export const SETTINGS_SECTIONS: Record = { assistant: AssistantSettingsContent, api: ApiSettingsContent, billing: BillingSettingsContent, + whatsapp: WhatsAppSettingsContent, } export type SettingsSectionId = keyof typeof SETTINGS_SECTIONS diff --git a/components/settings/useSettingsNavItems.ts b/components/settings/useSettingsNavItems.ts index 6eb5f003..1d800d20 100644 --- a/components/settings/useSettingsNavItems.ts +++ b/components/settings/useSettingsNavItems.ts @@ -41,6 +41,7 @@ export function useSettingsNavItems(): { items: SettingsNavItem[]; groups: Setti const hasCompany = !!company const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking') const hasMcpExtension = ENABLED_EXTENSION_IDS.has('mcp-server') + const hasWhatsAppExtension = ENABLED_EXTENSION_IDS.has('whatsapp-inbox') // Företagsprofil (TIC-snapshot) lives under Företag; Skatteverket under Skatt; // assistentens minne + kunskap under Assistenten; säkerhetsbackup under @@ -58,6 +59,7 @@ export function useSettingsNavItems(): { items: SettingsNavItem[]; groups: Setti { id: 'invoicing', href: '/settings/invoicing', label: t('invoicing'), group: 'sales', show: hasCompany }, { id: 'templates', href: '/settings/templates', label: t('templates'), group: 'sales', show: hasCompany }, { id: 'banking', href: '/settings/banking', label: t('banking'), group: 'tools', show: hasCompany && !isSandbox && hasBankingExtension }, + { id: 'whatsapp', href: '/settings/whatsapp', label: t('whatsapp'), group: 'tools', show: hasCompany && !isSandbox && hasWhatsAppExtension }, { id: 'assistant', href: '/settings/assistant', label: t('assistant'), group: 'tools', show: hasCompany && identity.isVerified }, { id: 'api', href: '/settings/api', label: t('api'), group: 'tools', show: hasCompany && hasMcpExtension }, ] diff --git a/docs/WHITELABEL.md b/docs/WHITELABEL.md index ed3ddf1f..6f9530de 100644 --- a/docs/WHITELABEL.md +++ b/docs/WHITELABEL.md @@ -66,6 +66,18 @@ Resolution order (last wins): **defaults → env vars → extension override**. | `RESEND_INBOUND_WEBHOOK_SECRET` | Verifies the `/inbound` webhook signature from Resend | | `RESEND_DELIVERY_WEBHOOK_SECRET` | Verifies the `/delivery-status` webhook signature from Resend. Optional: without it, invoice delivery history shows "sent" but never the delivery outcome | +### WhatsApp (when the `whatsapp-inbox` extension is enabled) + +| Env var | Purpose | +|---|---| +| `WHATSAPP_ACCESS_TOKEN` | Meta system-user permanent token (`whatsapp_business_messaging` scope only) | +| `WHATSAPP_PHONE_NUMBER_ID` | Graph object id of your WhatsApp Business number | +| `WHATSAPP_APP_SECRET` | Verifies `X-Hub-Signature-256` on the `/webhook` POST | +| `WHATSAPP_VERIFY_TOKEN` | Shared secret for the GET subscription handshake (also entered in the Meta app dashboard) | +| `WHATSAPP_PHONE_HASH_KEY` | Random pepper for phone lookup hashes (`openssl rand -hex 32`) | +| `WHATSAPP_PHONE_ENCRYPTION_KEY` | 32-byte hex AES-256-GCM key for phone numbers at rest (`openssl rand -hex 32`) | +| `WHATSAPP_PUBLIC_NUMBER` | Optional: the public number as E.164 digits (e.g. `46766867041`) for the wa.me deep link in settings; unset = resolved from the Graph API | + ## Things you MUST NOT change These are stable contracts. Renaming them breaks existing data, sessions, or external clients (npm package consumers, MCP connectors, browser sessions, invite links). Leave them alone in your fork: diff --git a/extensions.config.json b/extensions.config.json index eb21f8ce..cb7e7d03 100644 --- a/extensions.config.json +++ b/extensions.config.json @@ -1 +1 @@ -{"$schema":"./extensions.schema.json","extensions":["enable-banking","email","arcim-migration","tic","mcp-server","cloud-backup","skatteverket","invoice-inbox","document-extraction","stripe"]} +{"$schema":"./extensions.schema.json","extensions":["enable-banking","email","arcim-migration","tic","mcp-server","cloud-backup","skatteverket","invoice-inbox","document-extraction","stripe","whatsapp-inbox"]} diff --git a/extensions.schema.json b/extensions.schema.json index c3de065b..685ec04d 100644 --- a/extensions.schema.json +++ b/extensions.schema.json @@ -30,7 +30,8 @@ "mcp-server", "skatteverket", "cloud-backup", - "document-extraction" + "document-extraction", + "whatsapp-inbox" ] }, "description": "Extension IDs to enable. Each ID must match a manifest.json in the extensions/ directory." diff --git a/extensions/general/invoice-inbox/lib/upload-and-extract.ts b/extensions/general/invoice-inbox/lib/upload-and-extract.ts index 8b2b6483..fc62e179 100644 --- a/extensions/general/invoice-inbox/lib/upload-and-extract.ts +++ b/extensions/general/invoice-inbox/lib/upload-and-extract.ts @@ -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() diff --git a/extensions/general/whatsapp-inbox/__tests__/graph-api.test.ts b/extensions/general/whatsapp-inbox/__tests__/graph-api.test.ts new file mode 100644 index 00000000..10908625 --- /dev/null +++ b/extensions/general/whatsapp-inbox/__tests__/graph-api.test.ts @@ -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] + 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).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] + 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).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({ + 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() + }) + }) +}) diff --git a/extensions/general/whatsapp-inbox/__tests__/linking.test.ts b/extensions/general/whatsapp-inbox/__tests__/linking.test.ts new file mode 100644 index 00000000..267776ba --- /dev/null +++ b/extensions/general/whatsapp-inbox/__tests__/linking.test.ts @@ -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] + 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] + 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) + }) + }) +}) diff --git a/extensions/general/whatsapp-inbox/__tests__/phone-crypto.test.ts b/extensions/general/whatsapp-inbox/__tests__/phone-crypto.test.ts new file mode 100644 index 00000000..08493e63 --- /dev/null +++ b/extensions/general/whatsapp-inbox/__tests__/phone-crypto.test.ts @@ -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('+** *** ** **') + }) +}) diff --git a/extensions/general/whatsapp-inbox/__tests__/process-inbound.test.ts b/extensions/general/whatsapp-inbox/__tests__/process-inbound.test.ts new file mode 100644 index 00000000..637def88 --- /dev/null +++ b/extensions/general/whatsapp-inbox/__tests__/process-inbound.test.ts @@ -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 = {}) { + 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 = {}) { + 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 { + const updates = findCalls('whatsapp_messages', 'update') + return updates[updates.length - 1][0] as Record +} + +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() + }) +}) diff --git a/extensions/general/whatsapp-inbox/__tests__/webhook-post.test.ts b/extensions/general/whatsapp-inbox/__tests__/webhook-post.test.ts new file mode 100644 index 00000000..a106b476 --- /dev/null +++ b/extensions/general/whatsapp-inbox/__tests__/webhook-post.test.ts @@ -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) { + return { + object: 'whatsapp_business_account', + entry: [ + { + id: 'waba-1', + changes: [ + { + field: 'messages', + value: { messaging_product: 'whatsapp', ...value }, + }, + ], + }, + ], + } +} + +function textMessage(body: string, overrides: Record = {}) { + return { + from: '46701234567', + id: 'wamid.IN1', + timestamp: '1754000000', + type: 'text', + text: { body }, + ...overrides, + } +} + +function imageMessage(overrides: Record = {}) { + 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 = {}) { + 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] + 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] + 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] + 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] + 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] + 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, + link: Record, + ) { + 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).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] + 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).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) + }) +}) diff --git a/extensions/general/whatsapp-inbox/__tests__/webhook-verify.test.ts b/extensions/general/whatsapp-inbox/__tests__/webhook-verify.test.ts new file mode 100644 index 00000000..57dda472 --- /dev/null +++ b/extensions/general/whatsapp-inbox/__tests__/webhook-verify.test.ts @@ -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): 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) + }) +}) diff --git a/extensions/general/whatsapp-inbox/index.ts b/extensions/general/whatsapp-inbox/index.ts new file mode 100644 index 00000000..d5738f6c --- /dev/null +++ b/extensions/general/whatsapp-inbox/index.ts @@ -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 { + 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 { + 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 { + 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 { + 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, + 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 + 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 } }) + }, + }, + ], +} diff --git a/extensions/general/whatsapp-inbox/lib/graph-api.ts b/extensions/general/whatsapp-inbox/lib/graph-api.ts new file mode 100644 index 00000000..e252f04e --- /dev/null +++ b/extensions/general/whatsapp-inbox/lib/graph-api.ts @@ -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 { + 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 { + 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 { + 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 { + 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 +} diff --git a/extensions/general/whatsapp-inbox/lib/linking.ts b/extensions/general/whatsapp-inbox/lib/linking.ts new file mode 100644 index 00000000..ceb9d070 --- /dev/null +++ b/extensions/general/whatsapp-inbox/lib/linking.ts @@ -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 { + 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 { + 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 { + const { data } = await serviceClient + .from('whatsapp_phone_links') + .select('*') + .eq('phone_hash', phoneHash) + .is('revoked_at', null) + .maybeSingle() + return (data as WhatsAppPhoneLink | null) ?? null +} diff --git a/extensions/general/whatsapp-inbox/lib/messages.ts b/extensions/general/whatsapp-inbox/lib/messages.ts new file mode 100644 index 00000000..f992a115 --- /dev/null +++ b/extensions/general/whatsapp-inbox/lib/messages.ts @@ -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 = { sv: SV, en: EN } + +export function botCopy(locale: BotLocale = 'sv'): typeof SV { + return COPY[locale] ?? SV +} diff --git a/extensions/general/whatsapp-inbox/lib/phone-crypto.ts b/extensions/general/whatsapp-inbox/lib/phone-crypto.ts new file mode 100644 index 00000000..fdf90195 --- /dev/null +++ b/extensions/general/whatsapp-inbox/lib/phone-crypto.ts @@ -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}` +} diff --git a/extensions/general/whatsapp-inbox/lib/process-inbound.ts b/extensions/general/whatsapp-inbox/lib/process-inbound.ts new file mode 100644 index 00000000..e32f4880 --- /dev/null +++ b/extensions/general/whatsapp-inbox/lib/process-inbound.ts @@ -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 = 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 = { + '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 { + 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 { + 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 { + 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 { + 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 { + // 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 { + 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 => { + 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()) + } +} diff --git a/extensions/general/whatsapp-inbox/lib/webhook-parse.ts b/extensions/general/whatsapp-inbox/lib/webhook-parse.ts new file mode 100644 index 00000000..33f39cfd --- /dev/null +++ b/extensions/general/whatsapp-inbox/lib/webhook-parse.ts @@ -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 = new Set([ + 'text', + 'image', + 'document', + 'audio', + 'video', + 'sticker', + 'location', + 'contacts', +]) + +function toParsedMessage( + raw: unknown, + profileNames: Map, +): 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() + 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 +} diff --git a/extensions/general/whatsapp-inbox/lib/webhook-verify.ts b/extensions/general/whatsapp-inbox/lib/webhook-verify.ts new file mode 100644 index 00000000..9433b51b --- /dev/null +++ b/extensions/general/whatsapp-inbox/lib/webhook-verify.ts @@ -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) +} diff --git a/extensions/general/whatsapp-inbox/manifest.json b/extensions/general/whatsapp-inbox/manifest.json new file mode 100644 index 00000000..c770c945 --- /dev/null +++ b/extensions/general/whatsapp-inbox/manifest.json @@ -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." + } +} diff --git a/lib/extensions/__tests__/sectors.test.ts b/lib/extensions/__tests__/sectors.test.ts index db94ca72..f8045eff 100644 --- a/lib/extensions/__tests__/sectors.test.ts +++ b/lib/extensions/__tests__/sectors.test.ts @@ -48,8 +48,8 @@ describe('sectors registry', () => { expect(SECTORS.length).toBe(1) }) - it('should have 14 total extensions', () => { - expect(getAllExtensions().length).toBe(14) + it('should have 15 total extensions', () => { + expect(getAllExtensions().length).toBe(15) }) it('should have unique slugs within each sector', () => { @@ -94,7 +94,7 @@ describe('sectors registry', () => { it('getExtensionsBySector returns extensions for a sector', () => { const extensions = getExtensionsBySector('general') - expect(extensions.length).toBe(14) + expect(extensions.length).toBe(15) }) it('all extensions have required fields', () => { diff --git a/lib/extensions/_generated/enabled-extensions.ts b/lib/extensions/_generated/enabled-extensions.ts index e225beed..ecf4713f 100644 --- a/lib/extensions/_generated/enabled-extensions.ts +++ b/lib/extensions/_generated/enabled-extensions.ts @@ -11,4 +11,5 @@ export const ENABLED_EXTENSION_IDS: ReadonlySet = new Set([ 'invoice-inbox', 'document-extraction', 'stripe', + 'whatsapp-inbox', ]) diff --git a/lib/extensions/_generated/extension-list.ts b/lib/extensions/_generated/extension-list.ts index d80f73b8..4fd8032b 100644 --- a/lib/extensions/_generated/extension-list.ts +++ b/lib/extensions/_generated/extension-list.ts @@ -10,6 +10,7 @@ import { skatteverketExtension } from '@/extensions/general/skatteverket' import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox' import { documentExtractionExtension } from '@/extensions/general/document-extraction' import { stripeExtension } from '@/extensions/general/stripe' +import { whatsappInboxExtension } from '@/extensions/general/whatsapp-inbox' export const FIRST_PARTY_EXTENSIONS: Extension[] = [ enableBankingExtension, @@ -22,4 +23,5 @@ export const FIRST_PARTY_EXTENSIONS: Extension[] = [ invoiceInboxExtension, documentExtractionExtension, stripeExtension, + whatsappInboxExtension, ] diff --git a/lib/extensions/_generated/sector-definitions.ts b/lib/extensions/_generated/sector-definitions.ts index ec6c9022..53d8090e 100644 --- a/lib/extensions/_generated/sector-definitions.ts +++ b/lib/extensions/_generated/sector-definitions.ts @@ -131,5 +131,21 @@ export const EXTENSION_DEFINITIONS: Record = { "hasOwnData": true, "subscriptionNotice": "Denna integration kräver ett eget Stripe-konto. Stripes transaktionsavgifter tillkommer enligt ditt avtal med Stripe." }, + { + "slug": "whatsapp-inbox", + "name": "WhatsApp-inkorg", + "sector": "general", + "category": "import", + "icon": "MessageCircle", + "dataPattern": "both", + "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.", + "readsCoreTables": [ + "company_members", + "document_attachments", + "invoice_inbox_items" + ], + "hasOwnData": true + }, ], } diff --git a/lib/init.ts b/lib/init.ts index 9e1e0d45..56323332 100644 --- a/lib/init.ts +++ b/lib/init.ts @@ -37,6 +37,13 @@ const REQUIRED_EXTENSION_VARS: ReadonlyArray = [ ['ENABLE_BANKING_PRIVATE_KEY_PRODUCTION', 'ENABLE_BANKING_PRIVATE_KEY'], ['AWS_ACCESS_KEY_ID'], ['AWS_SECRET_ACCESS_KEY'], + // whatsapp-inbox extension (Meta Cloud API + phone PII at rest) + ['WHATSAPP_ACCESS_TOKEN'], + ['WHATSAPP_PHONE_NUMBER_ID'], + ['WHATSAPP_APP_SECRET'], + ['WHATSAPP_VERIFY_TOKEN'], + ['WHATSAPP_PHONE_HASH_KEY'], + ['WHATSAPP_PHONE_ENCRYPTION_KEY'], ] as const function validateEnvironment(): void { diff --git a/messages/en.json b/messages/en.json index 91677012..fa516905 100644 --- a/messages/en.json +++ b/messages/en.json @@ -322,7 +322,8 @@ "templates": "Reusable postings and patterns learned from your bookkeeping.", "banking": "Transactions are fetched automatically. Accounted can only read, never move money.", "assistant": "What the assistant knows, remembers and can do.", - "api": "Keys for MCP clients like Claude and Cursor, and for your own integrations." + "api": "Keys for MCP clients like Claude and Cursor, and for your own integrations.", + "whatsapp": "Link your mobile number and send receipts as photos or PDFs straight from WhatsApp." }, "settings_nav": { "aria_label": "Settings", @@ -343,6 +344,7 @@ "account": "Account", "api": "API", "billing": "Subscription", + "whatsapp": "WhatsApp", "group_account": "Account", "group_company": "Company", "group_accounting": "Accounting & tax", @@ -2783,6 +2785,36 @@ "cancel": "Cancel", "confirm": "Confirm booking" }, + "settings_whatsapp": { + "group_label": "WhatsApp receipts", + "loading": "Loading status …", + "load_failed": "Could not read the WhatsApp link status.", + "retry": "Try again", + "unlinked_intro": "Link your mobile number to send receipts as photos or PDFs straight from WhatsApp. They land in Underlag, ready to review and book.", + "connect_button": "Link WhatsApp", + "mint_failed": "Could not create a code. Try again in a moment.", + "open_whatsapp": "Open WhatsApp", + "step_open": "Open WhatsApp on your phone (the button above prefills the code).", + "step_send_code": "Send the code to the Accounted number from the mobile number you want to link.", + "step_confirm": "You get a confirmation in the chat once the number is linked.", + "expires_hint": "The code is valid for 10 minutes.", + "expires_in": "The code is valid for {minutes} more min.", + "code_expired": "The code has expired. Create a new one.", + "linked_number_label": "Linked number", + "muted_label": "Paused", + "muted_hint": "You paused the chat with stopp. Send start in WhatsApp to activate it again.", + "default_company_label": "Default company", + "default_company_help": "If you belong to several companies, this controls which company's Underlag your receipts land in.", + "default_company_none": "None selected", + "default_company_saved": "Default company saved.", + "default_company_save_failed": "Could not save the default company.", + "disconnect_label": "Disconnect", + "revoke_button": "Disconnect number", + "revoke_note": "Your documents in Accounted are not affected.", + "revoke_confirm": "Disconnect your WhatsApp number? You can link it again at any time with a new code.", + "revoked_toast": "The number is disconnected.", + "revoke_failed": "Could not disconnect the number." + }, "inbox_bulk_book": { "total_label": "Total for the documents: {amount}", "mixed_currency_totals_label": "Total per currency", diff --git a/messages/sv.json b/messages/sv.json index 300ce381..5ac33aac 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -322,7 +322,8 @@ "templates": "Återanvändbara konteringar och mönster som lärts in från din bokföring.", "banking": "Transaktioner hämtas automatiskt. Accounted kan bara läsa, aldrig flytta pengar.", "assistant": "Vad assistenten vet, minns och kan.", - "api": "Nycklar för MCP-klienter som Claude och Cursor, och för egna integrationer." + "api": "Nycklar för MCP-klienter som Claude och Cursor, och för egna integrationer.", + "whatsapp": "Koppla ditt mobilnummer och skicka kvitton som foto eller PDF direkt i WhatsApp." }, "settings_nav": { "aria_label": "Inställningar", @@ -343,6 +344,7 @@ "account": "Konto", "api": "API", "billing": "Abonnemang", + "whatsapp": "WhatsApp", "group_account": "Konto", "group_company": "Företag", "group_accounting": "Bokföring & skatt", @@ -2783,6 +2785,36 @@ "cancel": "Avbryt", "confirm": "Bekräfta bokföring" }, + "settings_whatsapp": { + "group_label": "WhatsApp-kvitton", + "loading": "Hämtar status …", + "load_failed": "Kunde inte läsa av WhatsApp-kopplingen.", + "retry": "Försök igen", + "unlinked_intro": "Koppla ditt mobilnummer så kan du skicka kvitton som foto eller PDF direkt i WhatsApp. De landar i Underlag, färdiga att granska och bokföra.", + "connect_button": "Koppla WhatsApp", + "mint_failed": "Kunde inte skapa någon kod. Försök igen om en stund.", + "open_whatsapp": "Öppna WhatsApp", + "step_open": "Öppna WhatsApp på din telefon (knappen ovan fyller i koden åt dig).", + "step_send_code": "Skicka koden till Accounteds nummer från det mobilnummer du vill koppla.", + "step_confirm": "Du får en bekräftelse i chatten när numret är kopplat.", + "expires_hint": "Koden gäller i 10 minuter.", + "expires_in": "Koden gäller i {minutes} min till.", + "code_expired": "Koden har gått ut. Skapa en ny.", + "linked_number_label": "Kopplat nummer", + "muted_label": "Pausad", + "muted_hint": "Du har pausat chatten med stopp. Skicka start i WhatsApp för att aktivera den igen.", + "default_company_label": "Standardföretag", + "default_company_help": "Är du med i flera företag styr valet vilket företags Underlag dina kvitton hamnar i.", + "default_company_none": "Inget valt", + "default_company_saved": "Standardföretag sparat.", + "default_company_save_failed": "Kunde inte spara standardföretaget.", + "disconnect_label": "Koppla från", + "revoke_button": "Koppla från numret", + "revoke_note": "Dina underlag i Accounted påverkas inte.", + "revoke_confirm": "Vill du koppla från ditt WhatsApp-nummer? Du kan koppla det igen när som helst med en ny kod.", + "revoked_toast": "Numret är frånkopplat.", + "revoke_failed": "Kunde inte koppla från numret." + }, "inbox_bulk_book": { "total_label": "Underlagens summa: {amount}", "mixed_currency_totals_label": "Summa per valuta",