* fix(whatsapp): company question survives a Meta-rejected interactive send (#1589)
The linked multi-company sender in #1589 never heard back because Meta
rejected the reply-button payload synchronously (HTTP 400, #131009
"Duplicate button title"): the sender belongs to two companies with the
same name, one of them archived. askCompanyQuestion rolled the question
back and returned not_asked, the row stayed parked as
staged_awaiting_company, and the channel went silent.
- Exclude archived companies wherever the channel resolves memberships
(isMember, resolveCompanyTarget, loadCompanyOptions, applyCompanyChoice,
the M3 greeting count), same inner-join filter as the middleware.
- uniqueTitles: interactive button/row titles are made unique (position
suffix) so two live same-named companies, or names that truncate to the
same prefix, no longer trip #131009.
- Numbered-text fallback: when the interactive send is rejected at send
time, ask the same M6 question as plain numbered text; roll back only
when that fails too. A typed digit is recorded as via='numbered'.
- Drain: when the sender now resolves as 'single', rows parked behind the
dead question are re-opened and kicked instead of expiring at Meta.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2
* fix(whatsapp): single-company drain also clears the dead company question (#1589)
Re-opening the parked rows left the conversation in state awaiting_company
with company_options and the company pending_question intact, so the sender
stayed behind a zombie question for up to 48h: every typed word became a
company_retry re-offering the archived company, 'byt' was swallowed, and
finalizeBurst could not ask about the drained receipts until the TTL sweep.
- After the drain, when a company question is open in any of its shapes
(awaiting_company state, kept company_options, company pending_question),
clear it through the guarded updateConversation: state -> idle, options and
the company pending_question deleted, other question types untouched.
- Tests: the clear in its awaiting_company and post-TTL (idle + options)
shapes, and its no-op for a representation question.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2
---------
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Users standing at a register saw nothing until the detailed ack, which
waits on extraction (10-60s) and reads as a black hole; failures could
take minutes longer via the sweep. Now the webhook reacts with a U+2705
checkmark on the sender's own media bubble right after the durable row
is persisted, so the 'correctly received' signal lands within seconds.
- sendReaction in graph-api: best-effort like mark-read, never throws,
no outbound row (a reaction is not a message in the conversation model)
- gated on the chat MIME allowlist (moved to lib/chat-mime.ts so tests
mocking process-inbound cannot lose it): junk earns M15, no checkmark
- no reaction for unknown, muted, or redelivered (23505) messages
- detailed M4/M5 combined ack and the one-message-per-burst design are
unchanged
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* 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>
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>