Commit Graph
3 Commits
Author SHA1 Message Date
6c64dd2312 fix(whatsapp): make every silent disposition observable, stop pure silence for linked senders (#1563)
* fix(whatsapp): make every silent disposition observable, stop pure silence for linked senders (#1552)

Silence was a legitimate outcome in seven places and none left a trace a
support question could be answered from. Now:

- Unknown-sender declines (over quota, quota RPC failure, greeting
  throttle) persist content-free trace rows: wamid, phone hash, type,
  disposition. No body, media, raw payload, or profile name; capped at
  20 rows per hash and day; deleted by the existing 30-day retention.
  The wamid dedupe also stops redelivered bad-code/greeting messages
  from earning a second reply.
- Linked-sender deliberate silences (muted, stale tap, ignorable type)
  record their reason on the skipped row.
- Non-policy silences reply: a row missing its media reference sends
  M18 through the link's reply address, a link revoked between arrival
  and processing sends the M1 unlinked copy (greeting-throttled).
- Outbound rows keep WHY a send failed (Graph error detail), and Meta
  'failed' delivery statuses store their error code and title.
- The WhatsApp settings panel shows the last inbound event (closed
  enum, server-derived) and warns when the latest reply never left.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(whatsapp): include errorDetail in typed sendText mock results

SendTextResult gained errorDetail; vi.mocked call sites must match the
widened type or they raise fresh tsc errors over the repo baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(whatsapp): review fixes: fail-closed greeting throttle, cap only declined traces

From CodeRabbit's pass on #1563:
- greetingThrottled fails closed when the throttle window cannot be
  read, matching the unknown-sender quota's stance.
- The decline-trace day cap applies only to 'skipped' rows (the one
  unbounded path); 'done' traces always insert so the wamid dedupe
  keeps preventing duplicate M1/M2 replies even past the cap. Their
  volume is already bounded upstream by the greeting throttle and the
  pre-binding quota.
- company-question test mocks match the widened SendTextResult.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 16:02:16 +02:00
629069e281 feat(whatsapp-inbox): conversation layer with clarifying questions (#1340)
PR4 of the WhatsApp intake track: turns the per-message PR3 pipeline into a
conversation. Media replies are burst-debounced into ONE combined ack (M4
single / M5 numbered list) sent by the single winner of the atomic
pending_ack claim; losers stay silent. Multi-company senders get the company
question (reply buttons <=3, list 4-10, numbered text >10) with an 8h
sliding pin ('byt' clears it); their receipts park as staged message rows
until the answer and then run through the normal intake path.

Clarifying questions are evaluated per receipt after extraction, max one per
receipt, priority unreadable > representation > partial, keyed on the
Phase-0 classification (legibility/documentKind/merchantCategory) with
heuristic fallbacks (compressed-chat-photo signal, extended meal regex).
Budgets: <=2 content questions per burst, <=6 per sender per Stockholm day;
over budget acks only and flags the item moved_to_app. Questions expire
after 48h (sweep, silent hand-off) and are asked exactly once.

Free-text answers route through the ONE new LLM call
(lib/interpret-answer.ts): Sonnet via Bedrock, max_tokens 600, no thinking,
forced tool call validated by Zod with hard caps, gated by
checkAgentRateLimit, reply framed as untrusted data. Any failure degrades to
storing the raw text as a note; exact 'nej' short-circuits without the LLM.
Answers land in invoice_inbox_items.channel_context
(representation/user_note/quality) with ChannelQuestionAsked/Answered
processing-history events. Late answers match by quoted wamid or the most
recent open question within 7 days.

New per-minute sweep cron (registry-gated physical route, 503
EXTENSION_DISABLED when off) re-claims stuck rows (max 3 attempts), rescues
crashed burst acks, expires questions and pins. One new migration
(20260802210000) adds whatsapp_messages.acked_at, the relational burst-
membership marker, with pg-real coverage for the single-winner claim.

Verified: full vitest suite (12270), pg-real against a migrated
supabase/postgres 15 (977), lint 0 errors, tsc at the 405 baseline,
check:guards green, crontabs regenerated. Mutation-checked the debounce
claim and the daily budget gate.

Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 15:27:00 +02:00
398c734b93 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>
2026-08-05 14:47:08 +02:00