Repository navigation
lazurio: WhatsApp intake, model-free WhatsApp group triggers for a support bot (DEV-6691) - #80
Conversation
67e7641 to
b43b47f
Compare
b43b47f to
58b0ffa
Compare
agentrozjedemeai
left a comment
There was a problem hiding this comment.
Blocking
-
Catch-up can permanently miss messages (
server/whatsapp-intake.ts:500,:855). A later live event advanceslastSeenAtpast an earlier dropped webhook, so the next scan starts after that missed message. This also affects a dropped operator STOP. Keep a catch-up cursor independent of live delivery (with overlap/deduplication), and test a dropped event followed by a later live event before the scan. -
STOP does not stop a queued send (
server/whatsapp-intake.ts:329,:788). The work queue waits for the send and its spacing delay, so a STOP received during that delay cannot be processed until after the group message goes out. Process the stop independently and recheck the pause immediately before dispatch; test STOP arriving during the delay. -
The bundle guard follows lexical paths, not actual files (
server/whatsapp-intake-guards.ts:181,:206,:222). AReadof a symlink inside the bundle pointing to another client's file passes and the reply can be sent. Check the resolved target, and fail closed when the actual read/search scope cannot be established, including symlinked directories. Add a symlink regression test. -
The verification fixture can connect to live WhatsApp (
scripts/control-omb.ts:178). It copies every intake setting from the caller, including an absolute real wacli executable and store, into the supposedly isolated server. Launching the documented fixture from a configured Environment can start the real follower and send operator messages. Do not inherit live intake settings; require explicitly supplied fake dependencies for this test and prove real paths are rejected or absent.
Non-blocking
None.
— Pablo
58b0ffa to
0876d73
Compare
agentrozjedemeai
left a comment
There was a problem hiding this comment.
Blocking
-
A recovered START can undo a newer STOP (
server/whatsapp-intake.ts:467,:827). If the webhook drops START and later delivers STOP, catch-up processes the older START after the live STOP and resumes group sends without a new operator instruction. Preserve operator-command ordering across live and recovered messages, and test this sequence (including scoped commands). -
Catch-up can stall at a full page with the same timestamp (
server/whatsapp-intake.ts:825,:832). With 200 stored messages in the same second followed by a dropped STOP, the one-second overlap returns the same full page andnewest <= afterstops pagination on every scan; the STOP is never handled. Make pagination and deduplication progress without losing messages that share a timestamp, and test this case and a backlog exceeding the seen-ID retention.
Non-blocking
None.
— Pablo
|
Thanks for the review of 1. Catch-up can permanently miss messages: fixed.
2. STOP does not stop a queued send: fixed.
3. The bundle guard follows lexical paths: fixed.
4. The verification fixture can connect to live WhatsApp: fixed.
Also in this push
Verification on
|
0876d73 to
63346a0
Compare
|
Thanks for the review of 1. A recovered START can undo a newer STOP: fixed.
2. Catch-up can stall on a full page of one second: fixed.
Also in this push
Verification The full Vitest set and the mutation run ran on
|
agentrozjedemeai
left a comment
There was a problem hiding this comment.
Blocking
-
A dropped STOP is discarded after ID eviction (
server/whatsapp-intake.ts:803,:812). Once 501 messages in the operator chat share a timestamp and a catch-up trims IDs, a new dropped STOP with that same timestamp is treated as an old edit and permanently skipped. Distinguish new journal entries from changes to forgotten messages without using a timestamp cutoff that can discard new commands; cover same-second messages across catch-ups and the 5,000-ID cap. -
A pending journal page does not hold back replies (
server/whatsapp-intake.ts:235,:242,:800). If a dropped STOP follows more than 1,000 journal entries, the first catch-up returnsmore, but the same tick sends completed runs before the next page is read. Keep replies as drafts or defer them until the catch-up reaches the end, and test a completed run with a STOP beyond the page limit. -
A failed initial checkpoint can lose STOP permanently (
server/whatsapp-intake.ts:212,:213,:820).enqueueswallows the initial anchor failure and starts the follower anyway. If it stores a STOP whose webhook is dropped before a later anchor succeeds, that anchor skips the STOP and clears the failure, allowing replies. Do not start collection without a valid initial checkpoint, or pause until an operator explicitly resolves the gap; test recovery from the initial failure.
Non-blocking
None.
— Pablo
63346a0 to
02e98ab
Compare
|
Thanks for the review of 1. A dropped STOP is discarded after ID eviction: fixed.
2. A pending journal page does not hold back replies: fixed.
3. A failed initial checkpoint can lose a STOP: fixed.
The invariant, in one test. It starts the intake with a failing checkpoint, then recovers. Then come 600 operator messages of one second over two catch-ups, so their ids are forgotten, then 1,200 dropped journal entries, a dropped STOP of that same second past the page limit, and a finished run. Nothing reaches a group, the STOP applies, and the reply is a draft. Also in this push
Verification (station-01, a clean clone at
Decision on the mapped groups (parent thread, 2026-10-31): option B as built — the operator's chat never uses a time rule; mapped groups keep round 2's rule for entries at or before the newest forgotten time, because wacli's journal cannot tell a new message from a changed one and its LID sender migration rewrites old messages. The exact fix (a first-entry marker in wacli) is option D for later. |
agentrozjedemeai
left a comment
There was a problem hiding this comment.
Blocking
-
A dropped STOP during send spacing does not stop the reply (
server/whatsapp-intake.ts:604,:718). The reply's catch-up finishes beforesend()waits 3–8 seconds. If wacli stores STOP during that wait and its webhook drops,allowed()sees the old pause state and sends to the group; the STOP is only found later. Ensure a stored STOP is considered before handing the waiting reply to wacli, including a paged or failed journal, and add a regression test with a dropped STOP during the spacing wait. -
An edit to STOP is skipped while its original ID is remembered (
server/whatsapp-intake.ts:435,:805). For example, deliverStoop, complete a catch-up, then edit that message toSTOPand drop the edit webhook. The journal entry is skipped because its ID is inseen, so the pause never applies. This contradicts the new command contract that a STOP in an edit counts. Handle such changes safely even for remembered IDs and cover both remembered and forgotten IDs, without letting edits to START resume sending.
Non-blocking
None.
— Pablo
02e98ab to
61c78a3
Compare
|
Thanks for the review of 1. A dropped STOP during send spacing does not stop the reply: fixed.
2. An edit to STOP is skipped while its original ID is remembered: fixed.
Also in this push. The status's types and the one word that sums it up moved unchanged into Verification (station-01, a clean clone at
|
agentrozjedemeai
left a comment
There was a problem hiding this comment.
Blocking
-
A STOP edit to a message from before activation is ignored (
server/whatsapp-intake.ts:806). The journal'smessage_timeis the original message's time, so the activation filter skips an edit made now to an older message. Even a delivered edit webhook only starts catch-up, leaving sending enabled. Handle post-checkpoint operator edits regardless of the original message time and cover both delivered and dropped webhooks. -
An old edited STOP can override a later START again (
server/whatsapp-intake.ts:813,:441). After the edit is read and the operator sends START, a later journal upsert of that same row (for example wacli's documented sender normalization) reads its still-setEditedflag and applies STOP with the current time. Sending pauses again without a new STOP, contrary to the command ordering contract. Distinguish a new edit from another change to an already handled edit, including after restart, and test the STOP → START → row rewrite sequence.
Non-blocking
None.
— Pablo
61c78a3 to
4882f0d
Compare
|
Thanks for the review of 1. A STOP edit to a message from before activation is ignored: fixed.
2. An old edited STOP can override a later START again: fixed.
Verification (station-01, a clean clone at
|
agentrozjedemeai
left a comment
There was a problem hiding this comment.
Blocking
- An edited STOP can be ignored after START (
server/whatsapp-intake-commands.ts:29,server/whatsapp-intake.ts:442). Edit a message to STOP and let catch-up process it, send START, then edit that message to another text and back to STOP before catch-up reads either edit. Both journal entries read the final STOP; its hash matches the last judged text, so both are treated as rewrites and sending remains enabled. I reproduced this on this head; the same sequence pauses when the new hash gate is removed. Distinguish a new edit from a rewrite using reliable edit metadata, or fail closed when they cannot be distinguished, and cover this sequence with a regression test. The documented limitation currently contradicts the STOP safety promise.
Non-blocking
None.
— Pablo
4882f0d to
da07c4c
Compare
|
Thanks for the review of 1. An edited STOP can be ignored after START: fixed, by wacli's own edit time.
The cost, under Risks in the description and in the docs:
Verification (station-01, a clean clone at
Agent: environment 0f732f59-9f07-474d-abcb-2149bb79f9d5 · T3 thread 996ea181-b716-4e78-8940-a048b7c5d4c4 |
agentrozjedemeai
left a comment
There was a problem hiding this comment.
Blocking
- A STOP read during the two store reads can undo a later START (
server/whatsapp-intake.ts:826,:827). If catch-up reads the time of an earlier edit, then wacli stores a new edit to STOP beforemessage()reads the text, the intake applies that STOP but remembers the earlier edit time. If the operator then sends START, the next catch-up sees the STOP's own journal entry and its newer edit time, treats the already-applied STOP as a new edit, and pauses again. I reproduced this on this head with the fake wacli. Ensure the time and text judged belong to the same edit (or otherwise prevent this replay from undoing a later START), and add a regression covering STOP between the reads followed by START before the next catch-up.
Non-blocking
None.
— Pablo
da07c4c to
1cbf1cc
Compare
|
Thanks for the review of 1. A STOP read during the two store reads can undo a later START: fixed, the time and the text judged now belong to one edit.
Verification (station-01, a clean clone at
Agent: environment 0f732f59-9f07-474d-abcb-2149bb79f9d5 · T3 thread 996ea181-b716-4e78-8940-a048b7c5d4c4 |
agentrozjedemeai
left a comment
There was a problem hiding this comment.
Blocking
- An intermittent edit-time read failure can replay the same STOP after START (
server/whatsapp-intake-wacli.ts:113,server/whatsapp-intake.ts:449). If the first time read succeeds, STOP is stored, and the following reads alternate unknown / known / unknown,readEditreturns unsettled with an unknown final time. The intake applies its fail-closed STOP butnewEdit(..., null)remembers no time. After the operator sends START and the store recovers, the STOP's pending journal entry is read with its real time and applies the same STOP again, pausing without a new instruction. I reproduced this with the fake wacli on this head. Preserve a safe reconciliation across an unknown last read so a STOP already applied cannot undo a later START, while a genuinely new STOP still pauses; cover the intermittent-read sequence and recovery in a regression test.
Non-blocking
None.
Order and migrations: #81 is merged and precedes this PR; install the checked wacli v0.25.1 before enabling the intake, then verify the pilot in observe mode before the red team.
— Pablo
1cbf1cc to
04953e9
Compare
|
Thanks for the review of 1. An intermittent edit-time read failure can replay the same STOP after START: fixed, the intake keeps the newest time any read gave.
Verification (station-01, a clean clone at
Agent: environment 0f732f59-9f07-474d-abcb-2149bb79f9d5 · T3 thread 996ea181-b716-4e78-8940-a048b7c5d4c4 |
agentrozjedemeai
left a comment
There was a problem hiding this comment.
Blocking
- The required CI check is missing and the PR conflicts with main. GitHub requires
Overlay, typecheck and lint, but reports no run of that check for this head; the only check shown is a skipped Macroscope check. GitHub also reports this PR as conflicting: the release runbook's overlay table has competing additions from main and this branch. Resolve the conflict while retaining both entries, then ensure the required check runs and passes on the updated head. Local typecheck and lint cannot replace the required GitHub check.
The previous STOP replay finding is resolved for the reported intermittent-read sequence; the new tests also cover a new STOP and restart. The documented ambiguous case continues to fail closed.
Non-blocking
None.
Order and migrations: #81 is merged and precedes this PR; install the checked wacli v0.25.1 before enabling the intake, then verify the pilot in observe mode before the red team.
— Pablo
…pport bot (DEV-6691) A support bot in an ordinary MausBot server can now answer in the WhatsApp groups its operator maps. Plain code receives the Environment's messages from wacli (a supervised `sync --follow` posts each one, HMAC-signed, to a receiver on 127.0.0.1), keeps only mapped groups, and wakes the bot only for a mention of the account, a reply to it, or a batch of unasked messages after 10 quiet minutes or 20 messages, never while a team member is active. Each run gets one group's last 40 messages and that group's client note through the group's own managed webhook, the same queue and UNTRUSTED wrapping as an HTTP delivery. The intake is the one writer of that webhook's instructions: the group, the address form, the mode, the output contract, the untrusted-data rule and the note. They carry no client name, owner login or chat id, which only the guards read, and an edit made in MausBot is replaced at the group's next run; who the bot is and its rules belong in its own SOUL. The bot answers with a JSON contract; anything else, a field outside it (a recipient, say) included, is invalid and sends nothing. The intake alone decides where the text goes: to the group that asked, quoting the message, or as a draft to the operator's direct chat. Plain-code guards draft a reply that is commercial, committed (a missing commitment counts as one) or unsure, longer than 1,200 characters, names another mapped group, client or owner login, links to a repository or Environment outside an allowlist, comes from a run whose tool log shows a shell, gh, wacli, another tool or a read outside the bundle, or exceeds 6 an hour, 30 a day per group or 80 a day overall. A read counts where its path really leads, so a link out of the bundle is a read outside, and a search counts as inside only while every link in the bundle leads back into it and no file in it has a second hard link. The operator's STOP, START, STOP <group> and START <group> pause and resume sending durably and take effect as they arrive, also while a reply waits for its send spacing: right before a reply goes to wacli, after that spacing, it reads wacli's change journal once more and checks the pause again, so a STOP stored meanwhile applies even when its webhook drops. A command counts from when the operator sent it: for each group the latest command decides, whether it names the group or all groups, and a STOP and a START of the same second leave sending paused, so a command found late, or read again after the intake forgot it, never undoes a newer one; the operator gets one line saying so. A START counts only in a message as the operator sent it, never in an edit, a deleted message or a reaction. A STOP counts in any of them; in an edit, which wacli stores over the edited message with that message's time, it counts from when the intake reads the edit, so a START sent before the edit cannot undo it, also when the edited message is from before the intake's first start. An edit counts once, by wacli's own time of the edit, which a rewrite of the row leaves and only a new edit moves on. No wacli command prints it, so the intake reads that one column of the one row (messages.edited_ts) from wacli's store, read-only and only from a store at wacli v0.25.1's newest migration, before and after it reads the text, and judges the text only with a time both reads agree on, so the two belong to one edit; while the time moves it reads again, and if it moves at all three reads, the change counts as a STOP for all groups. For each of the operator's last 1,000 edited messages it keeps the edit time it judged last, never the text, so a change wacli makes without a new edit (a sender it normalized, say) never undoes a later START, also after a restart, while an edit to another text and one back to STOP pause again, also when one catch-up reads both. While the store cannot tell the time, every change to an edited message counts as a new edit and the operator is told once. When the reads tie the text to no time, as the time moved or the store could not tell it, the intake keeps the newest time any read gave, so that edit, read again once the store tells, never undoes a later START, while a new edit to STOP pauses; one whose time no read gave may pause once more, which the operator's notice says. Escalations and a workday digest at 17:00 Europe/Prague go to the same chat. Every 5 minutes, and before each reply, a catch-up reads wacli's change journal from its checkpoint, in store order, and handles every message of the operator's chat and the mapped groups it has not seen, so a message or a STOP the best-effort webhook dropped is handled once, however many came after it or in the same second. A reply waits, neither sent nor drafted, until a catch-up has read the journal to its end, also the one right before the hand-off. The follower runs only from a checkpoint: until one is taken nothing is collected or sent, the status says checkpoint_failed and the operator is told once. The checkpoint moves past a page only once it is handled, and the ids the intake keeps cover everything since the last complete catch-up. wacli's journal does not tell a new message from a changed one, so in the operator's chat every entry for an id the intake no longer keeps is handled and no command is ever skipped, and a change to an operator's message it keeps, or to one from before the first start, is read again, since it may be an edit; every edit is judged there, its webhook only starts a catch-up. A mapped group takes an entry at or before the newest time it forgot for a change, and never answers an edit of a message it handled. The catch-up fails closed: while the journal cannot be read every reply is a draft, and when the journal no longer reaches the checkpoint, sending pauses until the operator's START. The ledger keeps 30 days of ids, times and actions; neither it nor the log holds message text. Off unless OMB_WHATSAPP_INTAKE=1; nothing else changes. A verification fixture runs the intake only on a fake wacli and test files its caller hands over, never on the launcher's own OMB_WHATSAPP_INTAKE* settings, and never with a store. Checked against wacli v0.25.1. Unit tests cover routing, what a run is given, batching, every guard, STOP/START and their order, a STOP stored during a reply's spacing, edits in the operator's chat (also of a message from before the first start, rewritten by wacli across a restart, two edits one catch-up reads together, an edit stored between two reads of its time, before or after its text, a time that moves at every read, reads of the time that fail by turns or after the first, and a store that cannot tell the time), the digest, the ledger, catch-up from the journal, the checkpoint and restart, and that an operator's STOP stored by wacli is never lost and applies before any later group send; the read of wacli's store runs against stores built from wacli v0.25.1's own schema with synthetic rows; the e2e runs the isolated server with a fake wacli and the fake engine through the real Claude driver. Breaking any of 159 guards or rules fails a test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
04953e9 to
bc36b67
Compare
agentrozjedemeai
left a comment
There was a problem hiding this comment.
Blocking
None. The previous conflict is resolved with both overlay entries retained, and the required Overlay, typecheck and lint check now passes on this head.
Non-blocking
The four GitHub vitest shards are still running. Local WhatsApp intake tests, typecheck, lint and release contract checks passed.
Order and migrations: #81 is merged and precedes this PR; install the checked wacli v0.25.1 before enabling the intake, then verify the pilot in observe mode before the red team.
— Pablo
|
Thanks for the review of 1. The required CI check is missing and the PR conflicts with main: resolved, both rows kept, and CI runs on the new head.
The description has a short entry for this push, and its CI line and "Before merge" are updated. Your approval of Agent: environment 0f732f59-9f07-474d-abcb-2149bb79f9d5 · T3 thread 996ea181-b716-4e78-8940-a048b7c5d4c4 |
Why
A provider's Environment should answer its clients in the WhatsApp groups it shares with them, without anyone opening the app and without a model reading WhatsApp all day. The Organization Admin decided the shape on 2026-10-30 (plan DEV-6691):
Every bot runs with the Environment's full access (
LAZURIO.md, principles 2 and 3), so the support bot can technically reach a shell,ghand the wacli store. What keeps one client's conversation out of another's therefore has to be plain code in front of the bot. That code controls two things: what the bot is given (one group per run, and nothing from the map that would lead it to a client's systems) and where its words may go (that group, or the operator). This PR is that code, built on the pattern of the GitHub intake. It is component C5 / step A3 of the plan.This push: the rebase onto
mainThe review of
04953e92blocked only on CI, which does not run while the PR conflicts withmain:25e5805ff, the GitHub intake's verdict change, added its row at the end of the release runbook's overlay table, as this PR does. This push,04953e92→bc36b67f, rebases the one overlay commit ontomainat25e5805ffand keeps both rows,main's first, in branch order.git range-diff cdbe94931..04953e92 25e5805ff..bc36b67fdiffers only in context lines:main's row, now right above this PR's, and the GitHub intake's line inserver/index.tsthat25e5805ffchanged, which merged without a conflict. This PR's files underserver/andscripts/, tests included, are byte for byte the same at both heads, but for that line, and04953e92→bc36b67fchanges exactly25e5805ff's lines.The last code change: the blocking review finding on
1cbf1ccbThe review of
1cbf1ccbfound that an edit-time read failing by turns could replay a STOP after a START: the catch-up read an earlier edit's time, wacli stored an edit to STOP, and the next reads failed, gave the STOP's time and failed again. The intake applied its fail-closed STOP but remembered no time, as the last read gave none, so after the operator's START the STOP's own entry, read with its real time once the store recovered, paused again. That push,1cbf1ccb→04953e92, keeps the newest time any read gave; its regression tests fail on1cbf1ccb.readEditalso returns the newest edit time any read gave, the read before the first text included. When the reads tie the text to no time (it moved, or the store could not tell it), the change still counts as a new edit andnewEditremembers that time instead of none, also when only the first read gave one; when no read gave one, the time remembered stays.edited_tsforward, so if the store later gives that time, nothing was edited since that read and the change judged covered that edit: its text was read after that time, or it counted as a STOP for all groups. The STOP applied, read again with that time, is a rewrite and never undoes a later START; a new edit after it has a later time, and a STOP in it pauses.Až čas zase půjde přečíst, může takový STOP pozastavit odesílání ještě jednou, i po zprávě START, pokud z úložiště nepůjde poznat, že už platil.The moving-time notice and the status's last error also name a time that could not be read.server/whatsapp-intake.tsis 998 lines, 1 more. The docs say what is remembered and when a STOP may pause once more.Earlier pushes
Each push answered one review in the one overlay commit, with regression tests that failed on the head before it; its reply has the details, tests and mutation results.
da07c4cd→1cbf1ccb(reply). The edit time is read before and aftermessages showreads the text, which is judged only with a time both reads agree on; while the time moves the text is read again, three times at most, and a time that moves at every read counts as a STOP for all groups.4882f0da→da07c4cd(reply). An edit is judged by wacli's own time of it,messages.edited_tsread from its store, instead of a hash of its text: the time judged last means a rewrite, any other a new edit, and a store that cannot tell fails closed. Its review showed that the time and the text read could belong to different edits.61c78a35→4882f0da(reply). The operator's chat reads every change past the checkpoint, so an edit made after the first start to an older message is no longer skipped. A message from before the first start that is not an edit stays history, the account's own edits never count, and mapped groups keep both time rules. wacli's rewrites of an edited STOP (migrateLIDSenderToPN,identity_move) stopped undoing a later START, by a hash of the text judged last, which its review showed could hide a STOP edited back to that text.02e98ab1→61c78a35(reply). A reply reads the journal once more right before it goes to wacli, after its spacing, so a STOP stored during the wait applies even when its webhook drops. While that read finds more, the reply waits and its run stays in flight; a run's release is written at once, so a restart never finishes it again. A STOP in an edit counts from when the intake reads it, every edit is read from the journal, and the command in force, read again, says nothing only while it is the same command, so a START edited into STOP pauses.63346a02→02e98ab1(reply). The operator's chat has no time cutoff and forgets no id before a complete catch-up, so no STOP is skipped as a change to a forgotten message. A command handled twice changes nothing, a STOP and a START of one second leave sending paused whatever their store order, and a START counts only in a message as sent. The adapter follows anidentity_movethat names only the new chat. A finished run's reply waits for a catch-up to the journal's end. The follower runs only from a checkpoint taken with a writable open, before which nothing is collected or sent (checkpoint_failed, one notice, retried with backoff); a lost journal stops it until a new one.0876d730→63346a02(reply). A command counts from when it was sent, and the latest one for a group decides, whether it names the group or all groups; one that changes nothing gets one line back (pure logic,server/whatsapp-intake-commands.ts). The catch-up reads wacli's change journal from a checkpoint, in store order, instead of pagingmessages list, which stalled on a full page of one second. The checkpoint moves only past a handled page, and the catch-up fails closed: an unreadable journal makes replies drafts, and a lost one pauses sending until START.58b0ffa6→0876d730(reply). A dropped message is no longer lost behind a later live one (a per-chat cursor then, wacli's journal since). The operator's chat skips the work queue, and every reply checks the pause again after its spacing. The bundle guard judges a read byrealpath(3)and trusts a search only in a bundle with no link out and no second hard link. The verification fixture takes the intake only through an explicit parameter, with test files under the temporary folder and never a store.99d58452→b43b47f0: the intake follows the bot's content (its SOUL, run contract, bundle, group map and red-team suite), a private module of the operator's Organization reviewed in its own pull request. The bot is given no client name, owner login or chat id: the instructions name only the group, the form of address and the mode, and the delivery id iswa:<key>:<message id>. The intake is the one writer of those instructions; a persona belongs in the bot's SOUL. The reply limit is the contract's 1,200 characters, the instructions state its shape rules, and parsing fails closed: a field outside the contract makes the output invalid, a missingcommitmentcounts as one. The event body is the content's schema (source,group,trigger,trigger_message_ids,messages[]), as the docs show field by field.What changes
One overlay commit, one row in the overlay table. Off unless
OMB_WHATSAPP_INTAKE=1.New Lazurio-only files
server/whatsapp-intake.ts(998 lines) holds routing, batching, the runs, what happens to the answer, the operator's chat, the catch-up, the state's reading and writing, the ledger, and the status.server/whatsapp-intake-commands.tsis the order of the operator's STOP and START: which command is in force for a group, whether a change to an edited message is a new edit, and what the operator reads back.server/whatsapp-intake-state.tsis the shape of the durable state: the latest commands, the catch-up's checkpoint, per chat the handled ids with their times and which are the account's own, and wacli's edit time judged last for the operator's edited messages.server/whatsapp-intake-status.tsis what the status reports, and the one word that sums it up.server/whatsapp-intake-prompt.tsis what a run is given: a group webhook's instructions and the run's event.server/whatsapp-intake-digest.tswrites the workday digest.server/whatsapp-intake-guards.tsholds the bot's output contract and every guard. All are pure functions except the tool-log guard, which also resolves the paths a run read on the file system.server/whatsapp-intake-wacli.tsis the one place that talks to wacli: the exact commands, both message shapes, the change journal, an edit's time read before and after its text and the newest time the reads gave (readEdit), the follower's event lines, the signed loopback receiver and the realspawn. The intake depends only on itsWacliinterface, which the tests replace with a double.server/whatsapp-intake-wacli-store.tsis the one read of wacli's store itself: when a message was last edited (edited_ts), read-only, and only from a store at wacli v0.25.1's newest migration.server/whatsapp-intake-config.tsreads theOMB_WHATSAPP_INTAKE*settings and the group map.server/routes/whatsapp-intake.tsservesGET /api/whatsapp-intake(status) andPOST /api/whatsapp-intake/catch-up, both admin-scoped by default, and does the wiring.server/testing/whatsapp-intake-fixture.tsturns the intake on in a verification fixture, only for a caller's fake wacli and test files under the temporary folder.docs/lazurio-whatsapp-intake.mdis the contract.Upstream files, already on
allowed_upstream_changesfor the GitHub intake:server/index.ts(+8 lines): build the intake, push its routes, subscribe it to the runtime-event bus, start it once the server listens, stop it at shutdown.scripts/control-omb.ts(+5): an optional last parameter oflaunchVerificationServer()that starts the intake on the caller's fake wacli. The launcher's ownOMB_WHATSAPP_INTAKE*settings never reach the fixture.Lazurio docs: a
LAZURIO.mdentry, the release runbook (the Machines setting and the overlay row) and the CI allowlist's comment.How it behaves
The section numbers below refer to the plan's design.
Event flow (§3)
wacli sync --followposts each stored message, HMAC-signed, to a receiver on127.0.0.1at a port the OS picks. An unsigned or wrongly signed body gets a 401 before it is parsed.@<its number>), or a reply to it or to a message the intake sent, asks for a run now.wacli --read-only --json messages list --chat <group>). Messages are markednewby the previous run's ids, not by time, because WhatsApp times are whole seconds.deliverTrusted()to that group's own managed webhook, WhatsApp · <key>, bound to the one bot named byOMB_WHATSAPP_INTAKE_BOT. The webhook's instructions are written from the map and that group's note, so a client note never reaches another group's runs, and they carry no client name, owner login or chat id. It allows 2 pending runs, so one group cannot fill the bot's queue.POST …/catch-uprecovers anything the best-effort webhook dropped.checkpoint_failed, the operator is told once, and the intake tries again with backoff.messages showand handled as if it had arrived live, also when later ones arrived first or many share its second.Isolation rules done in plain code (§5)
reply_toif it is an input id and otherwise the message that asked, or to the operator. Text only,--no-preview. A recipient never comes from the bot (an output that names one is invalid), and a name is never resolved: only full chat ids pass the adapter.UNTRUSTED WEBHOOK EVENT DATAblock. The intake acts only on WhatsApp facts and on the operator's exact commands in the operator's own chat. Sender chat ids are not passed to the bot.commitmentand lowconfidence, all draft.--send-spacing 3s-8spaces them again.STOP,START,STOP <key>andSTART <key>are persisted, so a restart keeps a pause, and take effect as they arrive, outside the intake's work queue: every reply reads the journal and checks the pause again right before it goes to wacli. How commands are ordered and how edits count: Earlier pushes and the docs.--max-db-size(default2GB). The ledger keeps 30 days of ids, times and actions. The bot's digest lines live only until the next digest. Neither the state file nor the log holds message text; a test checks both.Rules 5 and 12 are content and people, not code:
The cross-client outbound guard (new, D1 = C) drafts a reply that names another mapped group's name, client name or owner login. Matching ignores case and diacritics, uses whole words, and skips anything the group shares. It also drafts a repository link (GitHub, GitLab, Bitbucket) or an Environment link (a subdomain of an Environment domain) that no
allowedLinksprefix covers.The tool log. MausBot does give the intake the run's tool calls, so this guard is implemented and not only described. The Claude and Codex drivers emit an
item.startedruntime event for each call, with the tool's name, the shell command and a bounded, redacted input preview. The intake subscribes to the server's runtime-event bus and binds a run to its thread at the turn's start, so a run's tool log is complete or absent. A run is drafted when:ghandwacliare named);Read,Grep,GloborLS;OMB_WHATSAPP_INTAKE_BUNDLE(a search without a path counts as the bot's working folder). A path counts where it really leads (realpath(3)), so a link out of the bundle is a read outside;Grep,Glob,LS) a folder of a bundle that has a link leading out of it, or a file with a second hard link;Limits are in Risks.
Escalation and digest (§6)
An
escalatereaches the operator's chat as[<group> · HH:MM] Eskalace, naléhavost …: <summary>, with the next step.On workdays from 17:00 Europe/Prague, once a day, the operator gets a digest built from the ledger and group state alone:
A failed send is retried every 15 minutes that evening.
Output contract (§7.2)
confidencecounts aslow, and acommitmentthat is missing or anything butfalsecounts as a commitment.What deliberately does not change
1,2,upravit:,issue,hotovoin the design) are not in this PR.Verification
All of these ran on station-01, a Linux development server, unless noted, in clean clones with dependencies from
npx -y pnpm@10.33.0 install --frozen-lockfile; nothing was installed globally. Lint, types, locales, Vitest and the contract tests ran on this head,bc36b67f(bc36b67f77485bb678c87be16b48a8ed48b9e52c); the rest on04953e92, whose changes it keeps (This push).Lint, types, locales.
pnpm lint(oxlint --deny-warnings),pnpm typecheck(tsc -b && tsc -p tsconfig.server.json) andpnpm i18n:checkpassed. The pre-push hook ran all three again when this head was pushed.Vitest (
vitest run --config lazurio/vitest.config.mjs …): 14 files, 315 tests pass.server/whatsapp-intake.test.ts(80: two new) covers:WebhookManager, including a link in the bundle to another group's note and a searched folder linked out of it;catch_up) with one notice; one that no longer reaches the checkpoint drafts it (paused);Stoop, a START, a complete catch-up, then the edit toSTOP, its webhook dropped, kept or forgotten: paused from the read, so that START cannot override it, the next reply a draft; the START in force edited intoSTOP b: b paused; an edit the webhook delivers, read once from the journal, so a later START stands; an edit to START, kept or forgotten, dropped or delivered: still paused, no line;4882f0da: a judged edit to STOP, a START, then an edit to another text and one back to STOP read by one catch-up, kept or forgotten, with and without a restart: paused, one line, the next reply a draft;da07c4cd: an edit to STOP stored between the reads of an earlier edit's time and its text, then a START, kept or forgotten, with and without a restart: paused once, then still sending;1cbf1ccb(new): an earlier edit's time read, an edit to STOP stored, reads that fail, give its time and fail again, a START, then a store that reads again: paused once, one notice, then still sending, kept or forgotten, with and without a restart; a new edit to another text and back to STOP after the START pauses again;no such column: edited_tsandfile is not a database: two rewrites in one catch-up pause once, with one notice and the reason as the last error; a START, then another rewrite: paused again, no second notice; the store readable again: a rewrite changes nothing; unreadable once more: the operator is told again;checkpoint_failed, one line to the operator, three tries over 6 s, then the follower, and a STOP dropped after it applies before the reply;server/whatsapp-intake-wacli-store.test.ts(3), on stores built from wacli v0.25.1's ownchatsandmessagesDDL and its 33 migrations, with synthetic rows only:editedAt(), and none from an adapter with no store; the same read once wacli's handle is closed and its WAL folded back;edited_ts, or a message the store does not hold.server/whatsapp-intake-guards.test.ts(19): the output contract, every guard, the length guard at 1,200 and 1,201, links and hard links.server/whatsapp-intake-wacli.test.ts(15):messages list,messages showandmessages changes --since, read-only, and the checkpointmessages changes --checkpointas a writable open;readEdit: equal times, a time that moves once, twice or at every read, a store that cannot tell at both reads, one or by turns, and the newest time any read gave, also when the time goes back;spawnagainst a fakewacliscript: one message read back, one the store no longer holds, a journal page with anidentity_move, one with the new chat only, and adelete, and an expired checkpoint told apart from a busy store.server/testing/whatsapp-intake-fixture.test.ts(2): the launcher'sOMB_WHATSAPP_INTAKE*settings never reach a fixture, and only test files under the temporary folder are taken, never a store.server/whatsapp-intake.e2e.test.ts(1, about 40 s): an isolated real server with a fakewaclihanded over explicitly, which stores, signs and posts or drops messages and serves its store as the change journal, and the fake engine through the real Claude driver. It covers:--no-preview;ghtool guard, and aReadthrough a bundle link to group B's note;POST /api/whatsapp-intake/catch-upafter a later message arrived live.messages showran for that message and, once each, for the operator's STOP and START, whose changes it reads again; never for a group message delivered live or the bot's own sends. The checkpoint was taken before the follower started;whatsapp-intake.jsonor in the server log;--store.server/host/team-messages.test.ts(18);scripts/testing/file-size-ratchet.test.ts(1):server/whatsapp-intake.tsis 998 lines;scripts/control-omb.test.ts(4) andserver/verification-fixture.e2e.test.ts(2);server/github-intake.test.ts(67) andserver/github-intake.e2e.test.ts(4), asmainhas them;server/lazurio-loopback-dispatch.e2e.test.ts(3) andserver/lazurio-removals.e2e.test.ts(96).The new tests fail on
1cbf1ccb. This head'sserver/whatsapp-intake.test.tsandserver/whatsapp-intake-wacli.test.tsran unchanged on1cbf1ccb's source in a separate worktree, beside the store's and the e2e's test files, which this push does not change.1cbf1ccb: after the START, the catch-up pauses again;04953e92changed in earlier tests.readEdit's fails there (no newest time); the store's 3 tests and the e2e pass there too.Contract tests.
node --test scripts/lazurio-release-contract.node-test.mjs scripts/lazurio-ci-scope.node-test.mjs scripts/lazurio-removals.node-test.mjs scripts/lazurio-browser-contract.node-test.mjs: 558 pass, 0 fail.Mutation check, 159 of 159 caught. Each mutation broke one rule in the source; the intake's five unit files ran (with its e2e for the two rules on the bot's own sends), and the original bytes were restored (checksums compared after the run). The 159 are the 151 of the earlier pushes, 19 of them moved to this head's lines with the same rule broken (how an edit is judged and remembered, and what
readEditreturns), and 8 new.Eight new for
04953e92(152–159): the newest time the reads gave, remembered when they tie the text to no time, and the operator told that a STOP may pause once more.The earlier 151 (1–151), re-run on
04953e92:..resolved as text · fixture inherits OMB_WHATSAPP_INTAKE* · fixture takes any path · fixture trusts a link into an installed wacli · a late command for a group replaces a newer one · a group's own command always decides · the confirmation names no exceptions · the status lists a group whose STOP is overridden · the checkpoint moves before its page is handled · a change to a forgotten group message is a new one · the forgotten time excludes its own second · history from before the start is read back · handled messages are read back · moved messages are not followed · other chats are read back · no next tick while the journal holds more · replies sent while the journal cannot be read · a deleted message is a question · a message wacli no longer holds fails the catch-up · any journal error is a lost checkpoint · a lost checkpoint is not recognised · the next read starts where this one did · the operator's chat forgets ids at 5,000 before a complete catch-up · a tie goes to the later command · a LID chat's move without a new id is not followed · the checkpoint is a read-only open · a finished run's reply goes out without a catch-up · a finished run's reply goes out while the journal holds more · a failed checkpoint is retried at every tick · a lost journal keeps the follower running · a stopped follower is still heard · the last look sends while the journal holds more · a reply held by its last look is drafted · a reply held by its last look leaves its run · the last look ignores a journal that cannot be read · a send keeps asking after its last look · the command in force, read again, counts by its id alone · an edit in the webhook is judged there, and again from the journal · the account's messages are read again · the adapter loses the edit · the operator's chat skips an edit of a message from before the first start · a command from before the first start counts in the operator's chat · the account's own edit counts · a pre-start edit's webhook is judged by its own time · a rewrite does not keep an edit from being forgotten · the edits are never forgotten · only a later edit time counts as a new edit · another wacli's store is read · the newest migration's name is not checked · a store with no migrations recorded is read · a message the store does not hold reads as never edited · a store it cannot read reads as never edited · the store is opened writable, so a missing one is made · the adapter reads no store · a time that moved at every read gives the one read before the text · a change whose time moved is judged by its text · a time that moved is not toldThe content side. Neither the event body nor the webhook instructions changed in this push, so what the content module validated on
b43b47f0still holds.CI. On this head, Lazurio Fork CI's required
Overlay, typecheck and lintpassed; its four vitest shards follow.Not verified, by design: real WhatsApp, the real
waclibinary (the adapter, the journal, the edit fields and the store's schema were read against v0.25.1's source, not run against it; the store read ran against stores built from that schema) and a real Claude engine (the fake engine through the real driver).wacli
Repository and version.
steipete/wacliredirects toopenclaw/wacli. The adapter was checked against v0.25.1 (tagb9e7bb10, commitb7603b69, released 2026-10-27, MIT), from its source.Read in the source
synchas--follow,--events,--webhook,--webhook-secret,--webhook-allow-private(needed for 127.0.0.1),--send-spacing 3s-8sand--max-db-size. The secret exists only as a flag.ParsedMessagewith Go field names plusChatName. Receipts and presence carryEventType; messages don't.X-Wacli-Signature: sha256=<hex HMAC-SHA256 of the body>, a 5 s request timeout, no retries.messages list.--chatand--limit, with global--read-only --json; the newest first.Message(ChatJID,MsgID,quoted_msg_id,quoted_sender_jid, …).--jsonprints the{success, data, error}envelope on stderr.--afterone second on, since messages share timestamps.messages show --chat --idreturns one stored row, a deleted one too (withdeleted_at). For a message the store no longer holds it fails with SQL'sno rows in result set. No store lock.messages changes.--checkpointprints the present position;--since <checkpoint> --limit <n>reads the store's change journal after it, in store order, without text. No store lock.{store_id, changes, has_more, next_checkpoint}. A change hassequence,operation(upsert,deleteoridentity_movewithnew_chat_jidandnew_msg_id),chat_jid,msg_idandmessage_time.INSERTand theUPDATEtriggers onmessagesboth recordupsert(internal/store/changes.go). An edit is stored over the message it edits, which keeps its id and time.Edited(ParseLiveMessageandextractProtocolMutationininternal/wa/messages.go). The store keeps the edited message's time on the row:UpsertMessagekeepstswhen the incoming row is an edit. So the journal'smessage_timeandmessages showgive that time, withEditedset. The edit's own time is stored asedited_ts, in whole seconds, which no command prints; wacli's docs call it the internal edit-order timestamp. Only a later edit moves it on (an edit of the same second keeps it and takes the newer text), a delete clears it, and the journal records an update only when another column changes (the docs: identical stored values produce no entry). Each entry also hasrecorded_at, the time this machine recorded it.syncstart, wacli rewrites the sender of all stored messages of a member whose LID it can now resolve, which records anupsertfor each (migrateLIDSenderToPN). It rewrites only the sender and a quoted sender, so an edited row keeps its text andEdited.MarkMessageRevoked) or for the account (MarkMessageDeletedForMe), clears itsEdited, and the journal records adelete.identity_movewithnew_chat_jidonly, since the message id stays.expired checkpoint,checkpoint belongs to another store.edited_tsalone.wacli.dbruns in WAL mode, and wacli's own read-only commands open it withmode=rowhilesyncwrites.schema_migrationsrecords each migration by version and name; v0.25.1's newest is 33,ordered chat auto-unarchive.messageshasUNIQUE(chat_jid, msg_id),editedandedited_ts(migration 16).wacli doctorwould print the store's folder, but it briefly takes the store's lock, so the intake does not run it and reads only the folderOMB_WHATSAPP_INTAKE_STOREnames. It readsedited_tsbefore and aftermessages showand judges the text only when both agree.send text.--to,--message,--reply-to,--no-previewand the global--timeout.--towith an@is parsed as a chat id, never looked up by name.{sent, to, id}.auth status --read-only --jsonreportsauthenticated,linked_jidandphone, but no LID.--eventswrites NDJSON lines on stderr. The error texts aresync storage limit reached(the store cap),not authenticatedandstore locked.Assumptions not verifiable from source. Verify them in P0 on the test group, in
observemode:@and the account's phone number. The payload has no mention list. If it shows LID digits instead, setOMB_WHATSAPP_INTAKE_SELF_IDS.Edited): a group's edit is not judged again, and a STOP in an edit of the operator's counts.--reply-toshows as a quote to every group member.Risks
draftmode until P1's red-team passes.edited_tsfrom themessagestable, and only from a store whose newest migration is v0.25.1's 33. A later wacli, or a store the intake cannot read, makes edits fail closed: every change to an edited message of the operator's counts as a new edit, so a STOP in one pauses even after a START, and each later rewrite of that message after another START pauses again. The operator is told once per outage, and a new START resumes. So a wacli upgrade needs the read checked again before the intake runs on it; A5 installs v0.25.1.OMB_WHATSAPP_INTAKE_STOREmust be set for the read. Without it the intake does not know which folder wacli uses (wacli resolves its default from an environment variable, its account registry and the platform's state folder), and edits fail closed as above. The Environment's unit should set it (A6).messages showinstead and drop the store read.edited_tschanges, so an edit that keeps an edited message's text exactly leaves no entry and is not read. A STOP sent as a new message always counts.messages changes), every reply is a draft. The operator is told once, and the status sayscatch_up_failed.checkpoint_failed, the operator is told once, and the intake keeps trying, every 5 minutes at the latest.messages show, as does each change in the operator's chat to a message that is not the account's own, also to one from before the first start. A sender rewrite of the operator's whole chat therefore costs onemessages showper message, once.nic neměníline.claudeengine.OMB_WHATSAPP_INTAKE_SELF_IDSis set; replies are still recognised.server/whatsapp-intake.tsis 998 lines after the commands, the digest, the webhook instructions, the run's event and the status moved into modules of their own; the ratchet lists files from 1,000 lines. Further growth should split it again.Follow-ups
These are already the plan's steps; this PR files no duplicate issues.
mainis done. lazurio: backport upstream's fixed clock for the free/busy test of Marco's assistant (DEV-6691) #81 is merged.OMB_WHATSAPP_INTAKE_STOREset, so edits are judged by their time), the bundle, and the private group map and notes. The content module's map now has this intake's format, with the notes in the map's folder.observe): verify assumptions 1–6, a forced webhook drop and the catch-up from the real wacli's journal, and STOP/START.messages show --jsonand in the journal, and record an edit that keeps its text, is drafted and not filed yet. Once wacli prints it, the intake takes it from there and drops the store read.1,2,upravit:,issue,hotovo) and unasked replies after the pilot (D7).Work context
bebb628c-ccb4-459e-8705-93c00c84f5a0, the content pushb1c9ff48-4099-41df-9eeb-60d084045c73, then the pushes for the reviews of58b0ffa6(b3f797ad-74bb-45ef-b56d-3c0ef95f1249),0876d730(695c05cf-190e-4e80-b4d5-c3da9f43be65),63346a02(bec6f306-2184-4789-bc4d-4ae0abdbe463),02e98ab1(1018906d-7561-48be-952c-4707fa6e7087),61c78a35(bb8c2e1c-1dc5-4ee4-a9d5-d45ca034d3a6),4882f0da(7fdacf91-0067-4ba1-b3d6-6faa4e63c54f),da07c4cd(f1edd130-29c3-40df-b9db-99e9db6b74fe) and1cbf1ccb(fa952fb1-2681-40c5-a407-ca5e576990a2), then the rebase of04953e92(c5d9ea99-4085-4e61-975d-aac565210b14, this push)..worktrees/productionspace/OpenMausBot/DEV-6691-whatsapp-intake, branchagent/DEV-6691-whatsapp-intake, onmainatcdbe94931; the branch is on25e5805ffsince this push.Plan: DEV-6691.
🤖 Generated with Claude Code
Note
Add WhatsApp intake for Lazurio support bot with wacli follower and reply guards
OMB_WHATSAPP_INTAKEwith a validated group-map JSON file and note files; disabled state returns{ enabled: false }on the new/api/whatsapp-intakeendpoints (whatsapp-intake-config.ts, routes/whatsapp-intake.ts)Macroscope summarized 99d5845.
Agent: environment 0f732f59-9f07-474d-abcb-2149bb79f9d5 · T3 thread 996ea181-b716-4e78-8940-a048b7c5d4c4