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