Skip to content

fix: prove the typebot upload path, make history import durable, and order the message hooks - #52

Merged
rmyndharis merged 18 commits into
mainfrom
fix/upload-proof-backfill-hook-claiming
Jul 31, 2026
Merged

fix: prove the typebot upload path, make history import durable, and order the message hooks#52
rmyndharis merged 18 commits into
mainfrom
fix/upload-proof-backfill-hook-claiming

Conversation

@rmyndharis

Copy link
Copy Markdown
Owner

Closes three defects left open after the v0.12.0 alignment round. They are independent and can be read separately, but they ship together because the third one touches every plugin.

1. typebot-connector: the file-upload path was never proven

uploadFile was covered in isolation and handleTurn was covered only on its failure branch, so the success path — a WhatsApp media message becoming a Typebot attachment — had never been executed end to end. The seam between msg.media.{mimetype,filename,data} and uploadFile({mime, filename, data}) was guaranteed by types alone.

upload-e2e.test.ts wires the real TypebotClient into the real handleTurn, faking only the network and the WhatsApp send, and covers both upload branches. It pins the things types cannot: the decoded byte length in generate-upload-url (not the base64 length), the multipart boundary matching between header and body, every S3 policy field appearing before the file part, byte-exact attachment content, and the fileUrl — not the single-use presignedUrl — being threaded into continueChat.

A written smoke procedure covers what a fake fetch cannot: whether a real S3 endpoint accepts the multipart body. It gates the next typebot-connector tag and has not been run yet, so nothing in this PR claims it has.

2. chatwoot-adapter: history import could fail permanently and silently

Whether a chat's history had been imported was never stored. It was inferred from whether the Chatwoot conversation had just been created — and once that conversation existed, the inference read false forever. A single failed fetch meant that chat never got its history, with one log line and nothing on the health surface.

Three things caused or compounded it, and all three are fixed:

  • The fetch could not fit its budget. engine.getChatHistory is bounded at 30 seconds host-side, the host downloads each blob serially, and it does not cancel the work on timeout. There is no cursor on that capability, so paging is not available. Attachments are now requested only when the configured window is 25 messages or smaller; above that, older media arrives as a placeholder line and the conversation's shape survives intact.
  • Failure was indistinguishable from success. fetchHistory returned [] on any error, which reads identically to an engine with no history support. It now returns null for a failed fetch and [] only for a genuinely empty chat.
  • A failed import was recorded as a completed one. An import now counts as done only when every message actually posted, so a Chatwoot outage no longer marks a chat as imported with nothing in it.

Import state now lives on the chat's own mapping document — no new storage keys, since the host re-measures its quota by stat-ing every key on every write. A failed import retries on the chat's next message and gives up after three attempts, at which point the chat appears in the plugin's health status instead of vanishing.

Upgrade behavior: the marker is written only for chats this version creates, so upgrading does not re-import conversations that already exist. Enabling the opt-in one-time bulk sweep afterward still re-imports them; the changelog says so.

3. Nine plugins could all answer the same message

No plugin declared a hook priority. The chain order was therefore the loader's directory scan at boot, or click order after an operator enabled a plugin by hand — so installing after-hours, faq-bot and chat-flow together could draw three replies to one customer message, with a different one surviving after every restart.

PLUGIN-STANDARD.md gains a "Co-installation" section defining the contract, and every plugin now declares its band:

Band Plugins
Observer gsheets-logger 10, chatwoot-adapter 20
Transformer voice-transcription 40, group-translate 50
Responder http-action 70, chat-flow 75, faq-bot 80, typebot-connector 85, after-hours 95

Hooks sort ascending, so observers running first is a correctness requirement rather than a preference: a responder's {continue:false} ends the chain, and an observer ordered behind one silently loses those messages. chat-flow and group-translate already claimed conditionally, so that hole was live before this change.

Responders now claim the messages addressed to them. A claim means "this message is mine", decided from a synchronous predicate — which is what lets the two off-dispatch plugins claim at all, since they return before knowing whether the reply will succeed. http-action reuses the same matchAction call its handler uses, and typebot-connector reuses the same inScope predicate handleTurn re-checks, so a claim and the reply that follows it cannot disagree about which messages belong to the plugin. A reply that was suppressed by a cooldown or that threw is never claimed — claiming there would suppress the message for every later plugin while nothing was actually sent.

Two interactions follow from the ordering and are documented rather than engineered around: faq-bot with a fallback configured claims the first message in each cooldown window, and a typebot-connector bot owns every chat in its scope, so out-of-hours messaging belongs inside the Typebot flow rather than in after-hours.

Versions

gsheets-logger takes a patch bump — its only change is an explicit priority. The other eight take a minor bump: each gained or changed claim behavior, which changes what other installed plugins observe. plugins.json and the READMEs are regenerated.

Verification

520 tests pass (487 before), tsc --noEmit is clean, and catalog:check reports no drift. Every new assertion was checked against a mutation of the behavior it claims to pin — including the negative ones, where a falsy default makes a vacuous test easy to write by accident.

The upload success path had never been executed: typebot-client.test.ts covers
uploadFile in isolation and turn.test.ts covers only the failure branch, so the
seam between a WhatsApp media message and the upload request was guaranteed by
types alone. Wires the real client into the real turn handler for both upload
branches, pinning the decoded byte length, the multipart field ordering, the
attachment bytes, and the fileUrl threaded into continueChat.
…ch failures

getChatHistory has no cursor, so paging is impossible; requesting media for a
deep window makes the host download every blob serially and blow the 30 s
per-capability budget. Media is now requested only at a limit of 25 or below.
fetchHistory also stops returning [] on error, which was indistinguishable from
an engine with no history support and is what made the failure silent.
…criminate

The bulk-sweep test added for the media-budget fix asserted only that no
conversation was created for a chat whose history fetch failed. That
assertion also holds if the null-safety guard regresses (an unguarded
ordered.length throws on null, but the surrounding per-chat catch swallows
it with the same observable outcome), and it duplicated existing coverage.

Replaced it with a two-chat sweep where the first chat fails and the second
succeeds, asserting the second chat's conversation and history are
unaffected, plus a log assertion that catches the guard regression: a
reverted guard produces a second, spurious 'bulk backfill failed' log for
what is really just an already-explained fetch failure.

Also renamed the 'swallows a getChatHistory failure (best-effort)' test to
reflect the false-return contract it now proves.
Whether a chat's history had been imported was never stored — it was inferred
from whether the conversation had just been created. Once the conversation
existed the inference read false forever, so a single failed fetch meant that
chat never got its history and nothing said so. The state now lives on the chat's
own mapping document, with a bounded retry so a chat that can never be imported
does not add a 30 s timeout to every message it receives.

InboundDeps now requires onBackfillExhausted, so buildDeps in index.ts wires it
to a log line to keep the plugin compiling; turning that into a counted,
health-check-visible signal is follow-up work.
…marker

Three defects surfaced by review of the durable per-chat backfill marker:

- The `!backfill.done` guard in relayInbound had no test forcing it; deleting
  it left the suite green while an already-imported chat would re-run a full
  getChatHistory and replay on every later message, forever.
- backfillHistory returned true whenever the history FETCH succeeded, even if
  every post in the replay then failed (Chatwoot down as a chat's first
  message arrived). The caller wrote backfillDone on that true, permanently
  skipping the chat's history with no retry and no onBackfillExhausted — the
  exact silent loss this feature exists to remove, now recorded as done.
  replayHistory now reports whether every attempted message actually posted,
  and both backfillHistory and the bulk sweep only record a completed import
  when it did. A genuinely empty history still counts as success.
- onBackfillExhausted reported the raw (possibly @lid) chatId instead of the
  key the marker is actually patched under, so on the dual-lookup path an
  operator would be shown an id matching no stored state.
…kfill window

A chat that exhausts its import attempts now appears on the plugin health
surface instead of being a silent gap, and the config field states that
attachments are only imported for a window that fits the host budget.
Nothing described co-installation, so plugins shipped without a priority and
the chain order was whatever the loader happened to produce. Documents the
ascending sort, the bands, the rule that an observer must never claim, and what
a claim means for a handler that works off-dispatch.
Both ran at the default priority of 100, behind plugins that already end the
chain — chat-flow and group-translate both return continue:false today — so a
claimed message was silently absent from the spreadsheet and the Chatwoot
thread, and which messages went missing changed with every restart.
… band

Both act on a message before any responder should see it, but ran at the default
priority alongside the auto-repliers, so whether a translation reached the group
before or after a bot answered the untranslated text depended on enable order.
faq-bot and after-hours already knew whether they had replied and threw the
answer away, so installing them together meant one customer message could draw
several replies, with the survivor depending on enable order. Both now claim
when — and only when — a message was actually delivered, and all three take an
explicit responder priority.
Both return before their work finishes, so neither could claim on the
outcome — and http-action runs first among the responders, so without a
claim every plugin behind it answered the same message too. Both now
decide ownership from a pure synchronous predicate: a matching command
prefix, and an in-scope chat.
…ated

Whether a chat needed its history imported was inferred from the absence of
`backfillDone` on its mapping. Every mapping written by an earlier release
carries no backfill fields at all, so on the first message after an upgrade
each already-imported chat read as "never imported" and replayed its whole
window into Chatwoot again — duplicates posted ahead of the message that was
actually arriving, in every open conversation. The `seen` dedup markers expire
after three days, so they do not protect an install upgraded later than that.

`ensureConversation` now stamps `backfillDone: false` when it creates a
mapping, and the trigger tests for that value rather than for a falsy one. An
absent field means the mapping predates the marker and is left alone, which is
exactly the behaviour those installs have today. A chat whose import failed
before the upgrade is therefore never retried; every chat created from here on
gets the durable retry.

Also guard both marker writes. They sit between the import and the live relay,
so a rejected `set` — the host rejects every write once the plugin is at its
50 MiB quota — threw past `relayMessage` and turned a perfectly relayable
message into a retry-queue entry, with another full 30-second import behind it
on the drain. Failing to record the marker now costs at most one redundant
import, never the message.
Both plugins document a specific claiming contract, and neither had a test
that would notice it changing. Mutating faq-bot's fallback claim to return
false left all 12 of its tests green; mutating group-translate's hook to
return `{continue: true}` left all 47 of its tests green, coordinator suites
included — the coordinator pins `swallow`, but nothing pinned that the plugin
forwards it.

faq-bot gains a case for an unmatched message with `fallbackReply` set, which
asserts both that the reply was actually delivered and that the message was
claimed, so a regression that claims without sending cannot pass.

group-translate gains two: a `/tr` admin command must be claimed, an ordinary
translated message must not. Its context fake needed real messaging and engine
implementations first — with the empty stubs, every hook fire threw inside the
coordinator and was swallowed by the handler's catch, which is why no existing
test could see the mutation.

Also add the `onBackfillExhausted` member to five chatwoot-adapter fixtures
whose comment claimed every required callback was listed explicitly. It was
not, which made the comment worse than none.
The "Known interaction" note said faq-bot claims every message when
`fallbackReply` is set, so after-hours never sends. The fallback is
cooldown-gated at 600 seconds by default, so it claims only the first message
in each window and after-hours answers the rest — two different replies to one
out-of-hours conversation, not silence. The section now describes that, and
keeps the existing advice to leave `fallbackReply` empty when both plugins are
enabled.

The starvation that IS unconditional was undocumented: typebot-connector
claims on scope alone, with no cooldown and nothing an operator can narrow for
a one-to-one chat, so after-hours never fires while it is enabled. That is
intended — a Typebot bot owns the chats it is in scope for — and out-of-hours
messaging belongs inside the flow. Documented alongside the faq-bot case; no
priority changed.

The chatwoot-adapter README still described the history import as including
media at any window size. Attachments have been limited to windows of 25 or
smaller since the 30-second import budget was addressed; the manifest already
said so, the README did not.

The voice-transcription and group-translate changelogs claimed their new
priority guarantees them the message "regardless of what other plugins are
installed". That holds across the official plugins but not against a
third-party plugin registered lower. Both now claim what is actually
guaranteed: ahead of every responder.
@rmyndharis
rmyndharis merged commit 948bbcb into main Jul 31, 2026
1 check passed
@rmyndharis
rmyndharis deleted the fix/upload-proof-backfill-hook-claiming branch July 31, 2026 15:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant