* fix(whatsapp-inbox): erase the WhatsApp channel on account deletion whatsapp_phone_links relied on the auth.users ON DELETE CASCADE, but Accounted never deletes auth.users: account deletion is anonymize_user_account plus a ~100-year ban that keeps the auth row as a tombstone, so the cascade never fires and nothing revokes the link. After erasure the link stayed active with a decryptable phone_enc, lookupActiveLink kept resolving the number, and every further inbound message was persisted with body_text and the verbatim raw_payload while the bot kept replying: GDPR Art 17 plus continued collection with no lawful basis. The RPC is re-created verbatim from 20260724150000 with one added block that revokes and crypto-shreds the link, resets its conversation, nulls body_text/raw_payload on that link's messages and deletes outstanding link codes, plus a guarded repair pass for tombstones anonymized before this migration. Covered by a pg-real test that fails against the previous definition. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(whatsapp-inbox): pepper the link-code hash and bound code minting hashLinkCode stored a bare sha256 over CODE_ALPHABET^6 = 30^6 values behind a fixed 'AC-' prefix. The module cited the invite-token pattern, but invite tokens are 256-bit random; this space enumerates offline in about a second, so hashing at rest protected nothing. The sibling phone-crypto.ts already states the team's own threat model for a LARGER space ("a plain sha256 would be brute-forceable ... hence the pepper"), so link codes now hash through the same env-mandated pepper. /link/start was also an authenticated unbounded INSERT that left every earlier code valid. Minting now burns the caller's unused codes (the code the panel shows is the only one that works) and is capped per TTL window, with the route answering 429 instead of throwing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(whatsapp-inbox): harden the conversation layer against the review findings Pre-merge hardening of the unshipped chat layer. Every change below has a test that fails without it. Lifecycle and races: - conversation writes go through updateConversation(), an optimistic compare-and-set on updated_at (the trigger makes it a revision counter). The ack winner, the answer worker, the pin refresh and the sweep hold different claims, so blind whole-jsonb writes resurrected answered questions, wiped pending_question and dropped queue entries. - terminal markStatus writes are guarded on processing_status='processing' so a losing worker cannot overwrite the winner's 'done' and null its inbox_item_id. - the message -> inbox item path is idempotent: a pre-check plus a 23505 fallback adopt the item a concurrent worker created, instead of throwing after the WORM document is already committed. - PROCESSING_STUCK_MS 90s -> 5 min. The enforced step budget of one media row already exceeds 90s, so the sweep was re-claiming live workers. - sweep 2b re-arms only when the conversation itself has been quiet, not just the rows: pending_ack=false plus unacked rows is also the state of a live finalize, which produced a duplicate combined ack. - pin expiry re-checks against fresh state instead of writing back a stale whole context, which reverted company choices applied mid-pass. - askNextQueuedQuestion claims the pop before sending, so two answer workers cannot ask the same question twice. Company question: - the state is rolled back when the M6 send fails, so the next receipt re-asks instead of parking receipts behind a question nobody received. - applyCompanyChoice claims the open question (company_options) rather than the state: a double tap confirms once, a transient membership-query error is no longer read as "not a member", and a LATE answer still lands. - at the 48h TTL the parked receipts are kept, not discarded: options and staged rows survive so a late digit or tap still files them, and only rows past Meta's ~30-day media window get the terminal marker. - an out-of-range digit or a typed company name now gets the options repeated instead of silence or the "I cannot answer questions" reply. Inline dispositions: - stop/start/byt/company answers run their side effect BEFORE the terminal wamid row, with a SELECT pre-check for dedupe. Writing the row 'done' first made them at-most-once: a crash in between lost the action forever. Copy and answers: - acks state the extracted currency instead of labelling every total 'kr'. - M17 stops promising "about 10 minutes" when the daily quota tripped. - M18 is sent once per message tracked by the outbound row, so a file whose first attempt died still reaches the sender, including from the max-attempts path. - M11 no longer claims the number is disconnected: 'stopp' pauses, and muted senders now persist no chat content at all. - 'byt' is recognized in every state but awaiting_company (m6-confirm teaches the word, and it was being stored as answer data instead). - text sent while a re-send question is open is kept as a note on THAT receipt with the question left open, instead of binding to another receipt's question. - a quoted reply wins over the pending question and is appended when the quoted question is already answered, so corrections stop landing on the wrong receipt. - context answers keep raw_answer + answered_at like representation does. - finalizeBurst checks the send result: on failure it rolls the question back and leaves the rows unacked for the sweep. PII: - the sender's plaintext number is stripped from raw_payload before it is persisted; replies decrypt the link's phone_enc instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(whatsapp-inbox): record the erasure path and the hardening decisions RoPA gains the account-deletion row (immediate, not via the cron: the auth.users cascade never fires because the row is tombstoned) plus the two new security measures, and its "never in the clear" phone claim is now true of the stored payload. DECISIONS.md records the non-obvious calls: revoke-not-delete on erasure, commit-then-roll-back for the company question, keeping expired company choices answerable, the compare-and-set conversation write, effect-before-terminal-row for inline dispositions, honest M11 copy, and the raw_payload redaction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(whatsapp-inbox): stop the answer re-claim from following a confirm with M16 A worker that died after applying an answer and sending its confirmation leaves the row 'processing'. The sweep re-runs it, resolveAnswerTarget finds the question already answered, and the user got "I did not understand" immediately after the confirmation they had just received. The fallback is now first-attempt only. The catch comment claiming the sweep retries these rows is corrected too: 'error' is terminal for the sweep, and nothing on the answer path throws anyway (interpretChatAnswer degrades, sends never throw, supabase-js returns errors), so the catch is a programming-error net. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(whatsapp-inbox): drop the amount floor on the representation question The Swedish compliance review on #1340 caught a real error in the trigger rules: the representation question only fired above 150 kr, but the duty to document deltagare and syfte is what makes the expense deductible at all (BFL 5 kap 6-7 §) and it is not conditioned on any amount. The 300 kr per person figure I had in mind is the VAT-deduction base cap, a different rule. A 120 kr business lunch would have been booked with no participant trail, which is exactly the deduction Skatteverket denies later. Noise stays bounded by the triggers that were already there: the question fires only for receipt-shaped documents from restaurant, cafe or hotel merchants, at most once per receipt, twice per burst and six times per sender per day, and a single "nej" dismisses it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
814 lines
31 KiB
TypeScript
814 lines
31 KiB
TypeScript
/**
|
|
* 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.
|
|
*
|
|
* Conversation layer (PR4): media replies are burst-debounced into ONE
|
|
* combined ack (M4/M5) sent by the single winner of the pending_ack claim;
|
|
* multi-company senders get the company question (buttons/list/numbered) with
|
|
* an 8h sliding pin; clarifying questions (M7/M9/M10) ride on the ack and
|
|
* free-text answers route to the deferred interpret-answer worker. The
|
|
* per-minute sweep cron (app/api/extensions/whatsapp-inbox/sweep/cron)
|
|
* re-claims stuck rows and expires stale questions/pins.
|
|
*
|
|
* Deferred to PR5: retention cron, FieldsRail surfacing, booking-notes
|
|
* threading.
|
|
*/
|
|
|
|
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 { WhatsAppConversation, 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,
|
|
LinkCodeRateLimitError,
|
|
looksLikeLinkCode,
|
|
lookupActiveLink,
|
|
mintLinkCode,
|
|
} from './lib/linking'
|
|
import { sendText, getDisplayPhoneNumber, MAX_REPLY_BUTTONS } from './lib/graph-api'
|
|
import { botCopy, TEMPLATE } from './lib/messages'
|
|
import { kickInboundProcessing } from './lib/process-inbound'
|
|
import {
|
|
DEBOUNCE_WINDOW_MS,
|
|
getContext,
|
|
getOrCreateConversation,
|
|
redactRawPayload,
|
|
resolveAnswerTarget,
|
|
updateConversation,
|
|
type ConversationContext,
|
|
} from './lib/conversation'
|
|
import { applyCompanyChoice, type CompanyChoiceVia } from './lib/company-question'
|
|
|
|
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'
|
|
// Clears the 8h company pin. m6CompanyConfirm literally teaches this word, so
|
|
// it is recognized in every state EXCEPT awaiting_company (where the expected
|
|
// reply is a company, and 'byt' gets the re-prompt instead of being swallowed
|
|
// as answer text on someone's receipt).
|
|
const BYT_KEYWORD = 'byt'
|
|
|
|
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: 'byt' }
|
|
| { kind: 'company_digit'; digit: number }
|
|
| { kind: 'company_interactive'; companyId: string }
|
|
| { kind: 'company_retry' }
|
|
| { kind: 'answer' }
|
|
| { kind: 'text_open' }
|
|
| { kind: 'voice' }
|
|
| { kind: 'unsupported' }
|
|
| { kind: 'fallback' }
|
|
| { kind: 'silence' }
|
|
|
|
/** Dispositions with a durable side effect (mute, pin, company routing).
|
|
* Their effect runs BEFORE the terminal row is written: see
|
|
* handleLinkedSender. Reply-only dispositions keep the persist-first shape. */
|
|
const DURABLE_DISPOSITIONS: ReadonlySet<Disposition['kind']> = new Set([
|
|
'stop',
|
|
'start',
|
|
'byt',
|
|
'company_digit',
|
|
'company_interactive',
|
|
])
|
|
|
|
function classify(
|
|
msg: ParsedInboundMessage,
|
|
muted: boolean,
|
|
conversation: WhatsAppConversation | null,
|
|
): Disposition {
|
|
const state = conversation?.state ?? 'idle'
|
|
// company_options outlive the awaiting_company state: the 48h TTL resets the
|
|
// state but keeps the options so a LATE company answer still lands (media
|
|
// stays fetchable from Meta for ~30 days, well past the question TTL).
|
|
const companyChoiceOpen =
|
|
(conversation ? (getContext(conversation).company_options?.length ?? 0) : 0) > 0
|
|
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' }
|
|
if (normalized === BYT_KEYWORD && state !== 'awaiting_company') return { kind: 'byt' }
|
|
if (state === 'awaiting_company' || companyChoiceOpen) {
|
|
// The answer is a tapped button/row (interactive) or a typed digit.
|
|
if (/^\d{1,2}$/.test(normalized)) return { kind: 'company_digit', digit: Number(normalized) }
|
|
// Anything else while the question is on screen: repeat the options.
|
|
// The M16 fallback ("I cannot answer questions") right after the bot
|
|
// asked one is the wrong answer to a typed company name.
|
|
if (state === 'awaiting_company') return { kind: 'company_retry' }
|
|
}
|
|
if (
|
|
state === 'awaiting_representation' ||
|
|
state === 'awaiting_context' ||
|
|
// Words cannot answer the re-send question, but they are still about
|
|
// that receipt: the worker keeps them as a note (M9 note) instead of
|
|
// binding them to some other receipt's open question.
|
|
state === 'awaiting_resend'
|
|
) {
|
|
return { kind: 'answer' }
|
|
}
|
|
// Idle free text: maybe a late answer to an earlier question (quoted
|
|
// reply or the most recent open one); resolved below.
|
|
return { kind: 'text_open' }
|
|
}
|
|
if (muted) return { kind: 'silence' }
|
|
if (msg.type === 'interactive') {
|
|
if ((conversation?.state === 'awaiting_company' || companyChoiceOpen) && msg.interactiveReplyId) {
|
|
return { kind: 'company_interactive', companyId: msg.interactiveReplyId }
|
|
}
|
|
// Stale button tap after the question closed: silence beats lecturing.
|
|
return { kind: 'silence' }
|
|
}
|
|
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' }
|
|
}
|
|
|
|
/** True when this inbound wamid was already persisted (Meta redelivery). */
|
|
async function inboundAlreadyPersisted(
|
|
supabase: SupabaseClient,
|
|
wamid: string | null,
|
|
): Promise<boolean> {
|
|
if (!wamid) return false
|
|
const { data } = await supabase
|
|
.from('whatsapp_messages')
|
|
.select('id')
|
|
.eq('wamid', wamid)
|
|
.eq('direction', 'inbound')
|
|
.limit(1)
|
|
.maybeSingle()
|
|
return data != null
|
|
}
|
|
|
|
/** last_message_at + the 24h service window, touched for every inbound. */
|
|
async function touchInbound(
|
|
supabase: SupabaseClient,
|
|
link: WhatsAppPhoneLink,
|
|
conversationId: string | null,
|
|
now: Date,
|
|
armBurst: boolean,
|
|
): Promise<void> {
|
|
await supabase
|
|
.from('whatsapp_phone_links')
|
|
.update({ last_message_at: now.toISOString() })
|
|
.eq('id', link.id)
|
|
if (!conversationId) return
|
|
// Media arms the burst debounce: each file pushes the deadline forward,
|
|
// and the deferred worker whose deadline survives claims the ONE ack.
|
|
// Two literal payloads (not one built at runtime) so the phantom-column
|
|
// scanner can verify both shapes.
|
|
if (armBurst) {
|
|
await supabase
|
|
.from('whatsapp_conversations')
|
|
.update({
|
|
last_inbound_at: now.toISOString(),
|
|
service_window_expires_at: new Date(now.getTime() + SERVICE_WINDOW_MS).toISOString(),
|
|
debounce_until: new Date(now.getTime() + DEBOUNCE_WINDOW_MS).toISOString(),
|
|
pending_ack: true,
|
|
})
|
|
.eq('id', conversationId)
|
|
} else {
|
|
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)
|
|
}
|
|
}
|
|
|
|
async function handleLinkedSender(
|
|
supabase: SupabaseClient,
|
|
msg: ParsedInboundMessage,
|
|
phoneHash: string,
|
|
link: WhatsAppPhoneLink,
|
|
deferredMessageIds: string[],
|
|
): Promise<void> {
|
|
const conversation = await getOrCreateConversation(supabase, link.id)
|
|
const conversationId = conversation?.id ?? null
|
|
const muted = link.muted_at != null
|
|
let disposition = classify(msg, muted, conversation)
|
|
|
|
// Late-answer probe: free text with no open awaiting state still answers a
|
|
// recent question when it quotes one of its messages or one is open <=7d.
|
|
if (disposition.kind === 'text_open') {
|
|
const target = conversation
|
|
? await resolveAnswerTarget(supabase, conversation, msg.contextWamid)
|
|
: null
|
|
disposition = target ? { kind: 'answer' } : { kind: 'fallback' }
|
|
}
|
|
|
|
const correlationId = crypto.randomUUID()
|
|
const copy = botCopy('sv')
|
|
const now = new Date()
|
|
const replyBase = {
|
|
senderPhoneHash: phoneHash,
|
|
phoneLinkId: link.id,
|
|
conversationId,
|
|
correlationId,
|
|
}
|
|
|
|
// ── Durable dispositions: effect first, terminal row after ──
|
|
// Writing the row 'done' up front made these at-most-once: an instance that
|
|
// died between the insert and the effect lost the action forever, because
|
|
// Meta's redelivery hits the wamid dedupe and the sweep never claims 'done'
|
|
// rows. A dropped STOP is a dropped opt-out, so the order is inverted here
|
|
// and dedupe becomes a pre-check: the worst case is now a repeated (and
|
|
// idempotent) effect plus a duplicate confirmation, never a lost one.
|
|
if (DURABLE_DISPOSITIONS.has(disposition.kind)) {
|
|
if (await inboundAlreadyPersisted(supabase, msg.wamid)) return
|
|
await touchInbound(supabase, link, conversationId, now, false)
|
|
|
|
switch (disposition.kind) {
|
|
case 'company_digit':
|
|
case 'company_interactive': {
|
|
if (!conversation) return
|
|
const options = getContext(conversation).company_options ?? []
|
|
const via: CompanyChoiceVia =
|
|
disposition.kind === 'company_digit'
|
|
? 'numbered'
|
|
: options.length <= MAX_REPLY_BUTTONS
|
|
? 'button'
|
|
: 'list'
|
|
const applied = await applyCompanyChoice(supabase, {
|
|
conversation,
|
|
link,
|
|
choice:
|
|
disposition.kind === 'company_digit'
|
|
? { digit: disposition.digit }
|
|
: { companyId: disposition.companyId },
|
|
via,
|
|
to: msg.from,
|
|
replyBase,
|
|
})
|
|
if (applied.ok) {
|
|
if (applied.stagedMessageIds.length > 0) {
|
|
kickInboundProcessing(applied.stagedMessageIds, { companySelectedVia: via })
|
|
}
|
|
} else if (applied.reason === 'invalid_option' && options.length > 0) {
|
|
// A typed digit outside the range is a typo, not a forged payload:
|
|
// silence just leaves the receipts parked until they expire.
|
|
await sendText(supabase, {
|
|
to: msg.from,
|
|
body: copy.m6CompanyRetry({ options: options.map((o) => o.name) }),
|
|
template: TEMPLATE.m6CompanyRetry,
|
|
...replyBase,
|
|
})
|
|
}
|
|
// Other rejections stay silent (stale or forged payloads).
|
|
break
|
|
}
|
|
case 'byt': {
|
|
if (conversation) {
|
|
await updateConversation(supabase, conversation, (_current, context) => {
|
|
const next: ConversationContext = { ...context }
|
|
delete next.pin_expires_at
|
|
delete next.pin_source
|
|
return { company_id: null, context: next }
|
|
})
|
|
}
|
|
await sendText(supabase, { to: msg.from, body: copy.m6BytPin(), template: TEMPLATE.m6BytPin, ...replyBase })
|
|
break
|
|
}
|
|
case 'stop':
|
|
await supabase
|
|
.from('whatsapp_phone_links')
|
|
.update({ muted_at: now.toISOString() })
|
|
.eq('id', link.id)
|
|
.is('muted_at', null)
|
|
await sendText(supabase, { to: msg.from, body: copy.m11Stop(), template: TEMPLATE.m11Stop, ...replyBase })
|
|
break
|
|
case 'start':
|
|
if (muted) {
|
|
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 })
|
|
break
|
|
}
|
|
|
|
// Terminal row last. A concurrent redelivery that won the race 23505s.
|
|
const { error: durableInsertError } = 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),
|
|
raw_payload: redactRawPayload(msg.raw),
|
|
processing_status: 'done',
|
|
correlation_id: correlationId,
|
|
})
|
|
if (durableInsertError && durableInsertError.code !== '23505') {
|
|
log.error('Failed to persist handled WhatsApp message', durableInsertError)
|
|
}
|
|
return
|
|
}
|
|
|
|
// ── Everything else: 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' || disposition.kind === 'answer'
|
|
? 'received'
|
|
: disposition.kind === 'silence'
|
|
? 'skipped'
|
|
: 'done'
|
|
// A muted sender is told the channel is paused, so nothing they send is
|
|
// read: match the unknown-sender discipline and keep no content at all.
|
|
const keepContent = !muted
|
|
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: keepContent ? (msg.type === 'text' ? msg.text : (msg.caption ?? null)) : null,
|
|
media_id: keepContent ? (msg.media?.id ?? null) : null,
|
|
media_mime: keepContent ? (msg.media?.mime ?? null) : null,
|
|
media_sha256: keepContent ? (msg.media?.sha256 ?? null) : null,
|
|
media_filename: keepContent ? (msg.media?.filename ?? null) : null,
|
|
// The sender's plaintext E.164 number lived in here on every single
|
|
// row, which defeated the AES-256-GCM phone_enc on the link. Replies
|
|
// decrypt the link instead (resolveRecipient).
|
|
raw_payload: keepContent ? redactRawPayload(msg.raw) : null,
|
|
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
|
|
|
|
await touchInbound(supabase, link, conversationId, now, disposition.kind === 'media')
|
|
|
|
switch (disposition.kind) {
|
|
case 'media':
|
|
case 'answer':
|
|
// Media intake and answer interpretation both run deferred (the answer
|
|
// path may call the LLM; the webhook must 200 in seconds).
|
|
if (messageId) deferredMessageIds.push(messageId)
|
|
return
|
|
case 'company_retry': {
|
|
const options = conversation ? (getContext(conversation).company_options ?? []) : []
|
|
await sendText(supabase, {
|
|
to: msg.from,
|
|
body: options.length > 0 ? copy.m6CompanyRetry({ options: options.map((o) => o.name) }) : copy.m16Fallback(),
|
|
template: options.length > 0 ? TEMPLATE.m6CompanyRetry : TEMPLATE.m16Fallback,
|
|
...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 deferredMessageIds: 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, deferredMessageIds)
|
|
} 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, answer
|
|
// interpretation may call the LLM, and Meta expects the ack within
|
|
// seconds.
|
|
kickInboundProcessing(deferredMessageIds)
|
|
|
|
return NextResponse.json({
|
|
data: {
|
|
received: parsed.messages.length,
|
|
statuses: parsed.statuses.length,
|
|
queued: deferredMessageIds.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()
|
|
let minted: Awaited<ReturnType<typeof mintLinkCode>>
|
|
try {
|
|
minted = await mintLinkCode(serviceClient, ctx.userId)
|
|
} catch (err) {
|
|
if (err instanceof LinkCodeRateLimitError) {
|
|
return NextResponse.json(
|
|
{ error: 'För många koder begärda. Vänta en stund och försök igen.' },
|
|
{ status: 429 },
|
|
)
|
|
}
|
|
throw err
|
|
}
|
|
|
|
// 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 } })
|
|
},
|
|
},
|
|
],
|
|
}
|