Skip to content

fix(baileys): key chat writes so the phone applies them - #1901

Open
rjsebening wants to merge 4 commits into
rmyndharis:mainfrom
rjsebening:fix/baileys-chatmodify-keys
Open

rjsebening wants to merge 4 commits into
rmyndharis:mainfrom
rjsebening:fix/baileys-chatmodify-keys

Conversation

@rjsebening

@rjsebening rjsebening commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Description

Fixes the Baileys chat writes described in #1891, scoped as discussed there. Archive, mark unread, clear, delete and read receipts are keyed to a message of the chat. Before this change, the phone ignored clear and delete, and after a restart every chat without new traffic was refused.

What changes (Baileys adapter, BaileysContacts)

  • The patch index and the range key now name the same chat. A 1:1 chat is addressed by its <lid>@lid whenever one is known (from the message key, the in-memory lid map or the lid mapping table), else by its phone jid. Before, a message keyed by its lid on a chat the session lists under the phone jid named two different chats. The server accepted that patch and the phone ignored it. BaileysSessionStore.lidOf exposes the lid the store already knew through chatTwins.
  • An own group message is sent without its participant. With the account's own lid as participant, the phone ignored the clear. A received group message keeps its sender, which Baileys requires.
  • Stored-message fallback. When memory holds no message of the chat, the engine asks for the newest one the gateway stored. ChatCapability methods take an optional StoredChatMessageLookup, which SessionService.storedLastMessage builds. It is session-scoped and covers the chat's id forms via resolveJidCandidates, the same rules the stored-chat purge uses. For a group it only takes a message that is own or has an author. The lookup runs only when the engine calls it. If it fails, the result counts as "nothing stored" and a warning is logged.
  • Read receipts without messageIds fall back to the newest received stored message the same way.
  • No message known at all:
    • Archive / unarchive and mark unread go out without a range, which WhatsApp applies.
    • Mark unread on an archived chat is refused instead, because the phone ignores it there without a range.
    • Clear and delete still return success: false.
  • When an action is declined or fails, the stored messages stay intact. The purge still runs only after the engine reports success.
  • whatsapp-web.js ignores the new optional argument and is unchanged.

Not in this PR (as requested in #1891):

  • The group-membership observation (deleting a group chat left the group).
  • The missing local archive echo.

Docs: docs/06-api-specification.md (read, unread, clear, archive, delete), the route descriptions plus a regenerated openapi.json, engine-capability-matrix.ts, and the ChatCapability doc comments. The CHANGELOG has two Fixed entries.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update

Checklist

  • Tests added/updated
    • Adapter (baileys.adapter.spec.ts, chat writes keyed to a message (#1891)):
      • A phone-keyed chat whose message is keyed by its lid is indexed by the lid, for clear, delete, mark unread and archive.
      • An own group key has no participant, and a received one keeps its sender.
      • A stored message is used when memory holds none, and the store is asked only then.
      • A stored message is keyed by the lid of a lid-migrated chat, and a stored group message carries its sender.
      • A received group message in memory without its sender falls back to the stored one.
      • With nothing stored or a failing lookup, clear and delete decline and archive goes out without a range.
      • An archived chat is marked unread only with a known message.
      • The read receipt falls back to a stored received message, never an own one.
    • Existing tests: Two expectations changed because archive and mark unread no longer decline without a message.
    • Mutation checks: Removing the index alignment, the participant rule or the stored fallback fails exactly the matching tests.
    • Service (session.service.spec.ts):
      • The lookup's query covers the id forms, applies the group rule and takes only received messages for receipts.
      • The lookup is not read unless the engine asks.
      • Stored rows stay when the engine read the stored message and then declined or failed.
  • Documentation updated
  • Lint passes. On the changed files, eslint, prettier --check, tsc --noEmit, nest build and npm run openapi:check all pass.
    • Jest: 10,462 pass. The 2 failures are environmental on my machine and occur outside this change: a catalog DTO currency-format locale, and the whatsapp-web.js install that skipped its patch.
    • test:docs: 7 failures, also environmental (the same two areas).
  • Self-reviewed

Validated on a phone (iPhone) against a production Baileys session running this build, one step at a time:

Case Result
Clear a 1:1 chat after a restart (stored message, lid-addressed) applied
Mark an archived chat unread after a restart (stored message, lid-addressed) applied
Unarchive a chat with no message known at all (no range) applied
Clear a group whose last message is my own (no participant) applied
Mark a chat read after a restart applied (blue ticks on the sender's side)

For the last case, Baileys may have redelivered the message on reconnect, so that run does not isolate the stored fallback for receipts. The unit tests cover that path.

Related Issues

Refs #1891

Baileys keys clear, delete, archive, mark unread and read receipts to a
message of the chat, and the gateway looked that message up only in
memory. The phone ignored clear and delete, and after a restart every
chat without new traffic was refused (rmyndharis#1891).

- Index the app-state patch and the range key by the same chat jid: a
  1:1 chat by its lid whenever one is known, else its phone jid. A
  message keyed by its lid on a chat listed under the phone jid named two
  different chats, which the server accepted and the phone ignored.
- Send an own group message's key without its participant; a received
  one keeps its sender.
- Fall back to the newest message the gateway stored for the chat,
  across its id forms, when memory holds none (StoredChatMessageLookup,
  read only when the engine asks). Read receipts fall back to the newest
  stored received message.
- With no message known at all, archive and mark unread go out without
  a range, which WhatsApp applies; mark unread on an archived chat and
  clear and delete still decline.
Keep both sides of the Unreleased Fixed section, with credit on the two
entries of this branch.
@rmyndharis

Copy link
Copy Markdown
Owner

Thanks for the thorough write-up and the phone checks. The change covers what the #1891 thread asked for: patch index and range key together, the own group key, a session-scoped fallback across id forms, archived mark unread, the refusal kept for clear and delete, and stored rows kept when an action is declined.

I merged main into the branch (abe924a). The only conflict was CHANGELOG.md; both entries are kept and now credit you. Two behaviors need to change before this can merge, and the tests and docs need to follow.

  1. Archive and mark unread without a range are now sent for any chat id, including one the account has no chat with. SessionService.archiveChat does not check that the chat exists, and BaileysContacts.archiveChat sends { archive, lastMessages: [] }. Baileys also applies its own patch locally (emitOwnEvents is on by default, see appPatch in Socket/chats.js), which emits chats.update with archived. upsertChats stores it, persistChatState creates a state row for an update that holds only state fields, and listChats lists every id with a state row. So, if the server accepts the patch, POST /chats/archive for a number with no chat, which answered success: false before, answers true, and GET /chats then lists an archived chat that survives a restart. This is the local echo from the [Bug]: Baileys clear and delete are ignored by the phone (lid vs. phone jid, own-message participant), and chat actions fail without a known last message #1891 thread, reached through the new path. Please send a range-less patch only for a chat the session knows (a chat record, a persisted state row or a stored message), keep answering false for the rest, and add a test for an unknown id. The spec archiveChat archives a chat with no known message without a range currently asserts the opposite for 628999@s.whatsapp.net.

  2. The lid is resolved from a cache that cannot see every mapping. toTarget uses lidOf, which reads lidToPn and lidMappingStore.lidsForPhone. lidsForPhone only sees lids resident in the forward cache (capped at 5000 entries, and its own comment notes that nothing phone-keyed warms it), while the stored-message query in SessionService uses findLidsForPhone, which reads the table. For a chat whose mapping is not resident (more than 5000 mappings, or one persisted by another node) the stored message is found but lidOf returns null, so the patch is indexed and keyed by the phone jid, the combination [Bug]: Baileys clear and delete are ignored by the phone (lid vs. phone jid, own-message participant), and chat actions fail without a known last message #1891 measured as ignored by the phone. The route still answers true, and purgeStoredChat then removes the stored rows. Please resolve the lid through the table-backed lookup for these writes, for example by passing the resolved lid along with the stored message, and cover it with a test where the mapping is not in the cache.

  3. toTarget forces the lid whenever a mapping is known, whichever way the message was addressed. The tests and the phone checks cover lid-addressed chats. Please also check a chat and a group message addressed by phone number on the phone, so the in-memory path that worked before does not regress.

Tests and docs:

  • The service spec asserts the where clause of storedLastMessage against a mocked repository, so the query itself never runs on SQLite or PostgreSQL. A spec with real rows would cover the id-form In, the group author rule and the ordering.
  • docs/29-engine-capability-matrix.md (around line 969) still says the four chatModify actions need the chat's last known message and that a chat without one answers false. docs/07 (the messageIds paragraph of chats/read) and the SDK doc comments on MarkChatReadRequest.messageIds still say only the newest received message held in memory is acknowledged. They need the final behavior once points 1 and 2 are settled.

For the record, outside this PR: muteChat, pinChat and the label writes still index their patch by chatJid(chatId), so they can name the phone jid of a chat the phone holds by its lid. Whether the phone applies them in that case has not been checked, and #1891 can track it.

The PR closes #1891 when it merges, while the local archive echo and the group-leave observation stay outside it, so those need to stay tracked.

Address the rmyndharis#1901 review:

- Archive and mark unread without a range now go out only for a chat
  the session knows (a chat record, a persisted state row or a stored
  message). An unknown id answers false again: Baileys replays its own
  patch locally, so archiving it listed a chat the account does not have.
- A stored message brings the lids the persisted mapping holds for the
  chat and for a group sender, so a mapping the engine's cache lacks no
  longer leaves the patch keyed by the phone jid.
- A message held in memory is keyed under the jid it arrived with
  instead of a lid forced on it, so a phone-addressed chat keeps the
  path that worked before.
- Test the stored-message query against real SQLite rows, and bring
  docs/07, docs/29 and the SDK markRead docs to the final behaviour.
@rjsebening

Copy link
Copy Markdown
Contributor Author

Thanks for the review. I merged the latest main (which brings #1888 and #1907) and pushed one commit on top.

  1. Range-less writes only for known chats. Archive and mark unread go out without a range only when the session knows the chat: a chat record or persisted state row (what listChats holds), or a stored message. Any other id answers false again, so POST /chats/archive for a number with no chat no longer creates a listed chat through the local echo. The two specs that asserted the opposite now cover both cases: a listed chat goes out without a range, and an unknown id is declined with no chatModify. A stored message counts as "known" even when it yields no usable key, for example a received group message without its sender.

  2. Lid from the table. storedLastMessage now returns the chat's lid, taken from the resolveJidCandidates expansion it already runs through findLidsForPhone, and for a group the sender's lid, via findLidsForPhone for the author. toTarget uses those before the cache. A new spec keys a stored message by the row's lid while the session's cache holds no mapping, for a 1:1 chat and a group sender. A service spec checks that the author's lid comes from the table.

  3. Phone-addressed messages. I changed the rule instead of only checking it. A message held in memory is now keyed under the jid it arrived with, and no longer has a lid forced on it. A lid-addressed message keeps its lid, and a phone-addressed one keeps the phone jid, which is what the code did before this PR. A spec covers a phone-addressed 1:1 message and a group message with a phone-number participant, with a mapping known. Only a stored message, which carries no addressing, is keyed by the chat's lid when one is known. I could not check a phone-addressed chat on the phone: every message in this account's baileys_stored_messages, 1:1 and group, is lid-addressed. The phone checks in the PR description cover the lid-addressed and stored paths.

Tests and docs

  • The stored-message query now also runs against real SQLite rows. The spec covers the id-form In, rows without a message id, the group sender rule, receipts taking only received messages, and the ordering.
  • Updated docs/29 and docs/07, the SDK doc comments on MarkChatReadRequest.messageIds (JS, Python, Go and Java), docs/06, the route description and the capability matrix.

Checks: ESLint on the changed files, Prettier, tsc, nest build, openapi:check, check:sdk-docs, check:sdk-routes, check:sdk-coverage, check:contract-shapes and the engine and session suites (4,353 tests). The one failure there is the whatsapp-web.js patch check, which fails because my local install skipped its patch; it is unrelated to this change.

Live-checked on the same production Baileys session with this head, on a phone (iPhone):

  • Archiving an unknown number answered {"success": false}. No chatModify went out, GET /chats still listed the same 14 chats with no entry for that id, and no state row was created.
  • Clearing a lid-addressed 1:1 chat after a restart worked. Its newest message had arrived before the restart, so it came from the store or from Baileys' redelivery on reconnect. The phone showed the chat empty.

Agreed on the points outside this PR. They stay tracked in #1891.

@rjsebening

Copy link
Copy Markdown
Contributor Author

A note on the failing Python SDK (3.9) check: actions/setup-python fails before any step runs, with "The version '3.9' with architecture 'x64' was not found for Ubuntu 26.04". The same job passed on main this morning (dc070ac6), so the runner image seems to have moved to Ubuntu 26.04 since then, and this will hit every PR until the workflow pins a runner or drops 3.9. It is unrelated to this change, which only edits a comment in sdk/python/openwa/types.py. Python SDK (3.12) passes.

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.

2 participants