Skip to content

feat(msmsg): decrypt Meta AI / fbid bot replies (<enc type="msmsg">) - #650

Merged
jlucaso1 merged 24 commits into
mainfrom
feat/msmsg-meta-ai-bot
May 28, 2026
Merged

feat(msmsg): decrypt Meta AI / fbid bot replies (<enc type="msmsg">)#650
jlucaso1 merged 24 commits into
mainfrom
feat/msmsg-meta-ai-bot

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented May 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Implements end-to-end <enc type="msmsg"> decryption so the client renders Meta AI / fbid bot replies instead of dropping them and looping the offline queue. Closes the residual disconnect cause from #648 (the 070AB633… stream:error loop).

Verified in production (companion of a personal Android number):

  • DM with Meta AI — prompt captured, every streaming reply (edit chain) decrypted, receipts sent.
  • Group @metaai you invoke — captured via bot_metadata, all replies decrypted.
  • Group where another member invokes @metaai — captured via mention, decrypted.
  • Last prod run: 12 msmsg decrypts, 0 nack 495, 0 secret misses, 0 stream errors.

Cross-referenced WA Web WAWebBotMessageSecret.js + processRenderableMessages and whatsmeow msgsecret.go / message.go.

Crypto

k1   = HKDF-SHA256(messageSecret, salt=∅, info="Bot Message", L=32)
k2   = HKDF-SHA256(k1, salt=∅, info=msgID‖target_user_jid‖bot_user_jid, L=32)
AAD  = msgID ‖ 0x00 ‖ bot_user_jid
plain = AES-256-GCM.Decrypt(k2, enc_iv, enc_payload, AAD)

HKDF msgID unifies WA Web's two arms (fbid h() pre-resolves edit_target_id for INNER/LAST; regular f() tries info.id then falls back) into a primary + fallback attempt — a strict superset of both.

How it works

Capture (PUT). Every dispatched message carrying message_context_info.message_secret is persisted when it's a bot context — mirroring WA Web processRenderableMessages $ && (P || N || w || A) && !isForwarded:

  • P chat is a bot, N a mentioned JID is a bot, w/A proxied by message_context_info.bot_metadata (the bot-invocation envelope; present on our own group prompt even with no mention).
  • Keyed by chat type so the key matches what the bot reply echoes in <meta target_sender_jid>: bot DM → our LID (the raw sender there is our PN device JID); group/regular → info.source.sender (already our LID in a LID group / the other participant's JID). Awaited inline before the per-chat worker dequeues the next stanza, so a reply queued behind its own prompt can't race the write.

Lookup + decrypt (GET). <enc type="msmsg"> is bucketed (EncType::MessageSecret) and decrypted inline in process_classified_message under the global permit + per-chat lock. Secret looked up by (target_chat|chat, target_sender_jid, target_id), with an alternate PN↔LID lookup (WA Web getAlternateMsgKey). Missing secret / GCM fail / malformed proto / backend error → nack 495 (whatsmeow parity), so the server stops replaying — no loop.

Receipts. DM bot reply → normal delivery <receipt>. Group bot reply (chat not bot, author is bot) → bare <ack class="message"> (WA Web sendBotInvokeResponseAcks), not a receipt.

Retention. MsgSecretStore persists secrets in a new msg_secrets table. Pruning is opt-in via CacheConfig.msg_secret_ttl_secs (default 0 = no prune, matching whatsmeow / WA Web), driven by an independent keepalive tick.

Notable supporting changes

  • EncType::MessageSecret + bot_enc bucket; BotEditType derives WireEnum.
  • Parser: <meta target_id/target_sender_jid/target_chat_jid> and <bot edit/edit_target_id/sender_timestamp_ms> (edit_target_id read unconditionally).
  • PreparedDmStanza/PreparedGroupStanza surface the generated messageSecret (+ group sender_identity) so the outbound send path persists it too.
  • Encoder: AD_JID restricted to the 4 servers the decoder round-trips (Pn/Lid/Hosted/HostedLid) on both the string and typed-Jid paths — bot/group/broadcast/newsletter go through JID_PAIR so the server survives. Matches whatsmeow writeJID / WA Web WAWap.De.
  • Jid::to_non_ad_string() (single-alloc) replacing to_non_ad().to_string() across 23 sites.
  • MessageExt::mentions_any_bot / is_forwarded (canonical Jid::is_bot()).

Test plan

  • cargo fmt --all, cargo clippy --all --tests (clean)
  • cargo test -p wacore -p whatsapp-rust -p whatsapp-rust-sqlite-storage -p wacore-binary — all green (787 + 600 + 28 + 88 unit tests)
  • Production: DM + group (self-invoke and other-participant-invoke) all decrypt; 0 nacks / misses / stream errors.
  • Crypto (13), storage incl. TTL prune + multi-device isolation, EncType, outbound capture/identity, inbound dispatch + edit chain + fbid/regular fallback + alternate LID↔PN lookup, group bot capture via bot_metadata, participant-keyed capture, bare-ack-for-group vs receipt-for-DM, encoder round-trip (string + typed).

Review threads folded in

P1 secret threading; P2 LID-group sender identity; P2 encoder typed path; P2 retention; P2 msmsg+unknown sibling; P2 fanout ordering (awaited inline); P2 group-participant secret key; backend-error nack; SQLite with_retry; BotEditType WireEnum; primary/fallback dedup; &[u8; 32] signature; CacheConfig Debug field; canonical is_bot(); group bot capture via bot_metadata.

Skipped (with reason)

  • GCM-fail nack stays 495 (whatsmeow parity, not 500).
  • HKDF info has no separators (matches WA Web v(...) literally; interop-load-bearing).

jlucaso1 added 6 commits May 28, 2026 11:35
Required for Meta AI / fbid bot reply decryption (`<enc type="msmsg">`).
WA Web `WAWebBotMessageSecret.decryptMsmsgBotMessage` looks the secret up by
(chat, target_sender, target_id) where target_id is the id of our original
outbound stanza. whatsmeow does the same via `cli.Store.MsgSecrets`.

- `MsgSecretStore` trait in wacore (added to Backend composition).
- InMemoryBackend impl with composite-key map.
- SqliteStore impl backed by new `msg_secrets` table + Diesel migration,
  scoped by device_id for multi-account isolation.
- Tests: round-trip, miss returns None, composite-key independence,
  upsert overwrites, multi-device isolation.
Implements the dual-HKDF + AES-256-GCM open used by Meta AI / fbid bot
replies. Cross-verified against:

  WA Web `WAWebBotMessageSecret.js`
    k1 = HKDF-SHA256(messageSecret, info="Bot Message",                       L=32)
    k2 = HKDF-SHA256(k1,            info=msgId||target_user||bot_user,        L=32)
    AAD = msgId || 0x00 || bot_user
  whatsmeow `decryptBotMessage` / `generateMsgSecretKey` (msgsecret.go:42-160)
    Identical info concat + AAD layout (modificationType="" for msmsg).

`BotMessageContext` exposes msg_id / target_sender_user_jid / bot_user_jid
so callers (Phase 5 inbound dispatch) can plug in `info.id` or
`bot_info.edit_target_id` for the edit chain.

Tests (13):
- HKDF determinism + secret-size validation
- Per-message key sensitivity to every input field
- AAD layout matches WA Web `msgId + "\0" + bot_user_jid`
- Encrypt/decrypt round-trip
- Reject tag tampering, ciphertext tampering, wrong secret, wrong msg_id,
  wrong bot JID, short IV, short payload
- Known-input vector covering every binding (key + AAD) at once
Recognises `<enc type="msmsg">` as a distinct envelope (neither a Signal
session message nor a sender-key group message). is_session() stays false;
is_bot_secret() identifies the new variant.

- `categorize_enc_nodes` now routes msmsg into a new `bot_enc` bucket so
  callers can dispatch to the bot_message decrypt path (Phase 5).
- features::signal::decrypt_message rejects EncType::MessageSecret with a
  clear error pointing at the bot_message helper.
- Fixed existing unknown-enc tests that piggy-backed on "msmsg" as an
  example of an unknown type -- switched them to "frskmsg" so the
  semantics survive the recognition change.

Tests:
- categorize_msmsg_goes_into_bot_bucket (new)
- enc_type_msmsg_round_trip: from_wire/as_wire_str/is_session/is_bot_secret
…send

`send_message_impl` now calls `persist_outbound_msg_secret` after the stanza
lands on the wire when `message.message_context_info.message_secret` is set.
Key shape `(chat_non_ad, own_pn_non_ad, request_id)` matches the inbound
lookup (Phase 5) which derives target_sender from `<meta target_sender_jid>`
defaulting to our PN. No alloc of the secret -- the helper reads it directly
from the message ref after send; only the request_id is cloned because the
branch builders move it.

Tests (4):
- writes under (chat, ownPN, id) with the exact non-AD form
- skips when MessageContextInfo.message_secret is absent
- skips when get_pn() returns None
- chat-with-device is stored under the non-AD chat form so the inbound
  lookup (which uses the bare chat from `<meta target_chat_jid>`) hits
`classify_incoming_message` now routes `<enc type="msmsg">` to a new
`handle_msmsg_payload` helper on Client. The helper:

  1. Decodes the MessageSecretMessage proto (enc_iv, enc_payload).
  2. Resolves target_sender from `<meta target_sender_jid>` (fallback to
     our LID when the stanza sender is on the bot server, else our PN --
     mirrors whatsmeow `decryptBotMessage`).
  3. Looks up the stored secret by (target_chat or chat, target_sender,
     target_id). Missing secret => nack 495.
  4. Runs the dual-HKDF AES-256-GCM open via wacore::bot_message.
  5. Decodes the plaintext as wa::Message and dispatches via
     `dispatch_parsed_message` (delivery receipt fires from there).

Parser additions:
- `MsgMetaInfo.target_chat` field for `<meta target_chat_jid>`.
- `parse_message_info` populates target_id / target_sender / target_chat
  from `<meta>`.
- `BotEditType::from_wire` and `parse_message_info` populates `bot_info`
  from `<bot edit="..." edit_target_id="..." sender_timestamp_ms="..."/>`.

Tests (4):
- happy path: encrypt with the symmetric helper, route through classify,
  observe Event::Message on the bus.
- missing stored secret: nack `error=495`, no Message event.
- tampered GCM tag: nack 495.
- meta without target_id: nack 495.
When `<bot edit="inner|last">` is present alongside `edit_target_id`, swap
in that id as the HKDF input so the edited bot reply decrypts under the
same per-message key as the message it edits. Mirrors whatsmeow's
`decryptBotMessage` and WA Web `decryptMsmsgFbidBotMessage`. `first` and
absent edit fall through to the stanza's own id.

Tests (3):
- edit=inner with edit_target_id → HKDF uses edit_target_id, dispatch works
- no <bot> → HKDF stays on info.id, mismatched key fails GCM tag → nack 495
- edit=first → HKDF stays on info.id (must NOT swap)
@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

This PR was authored by the user configured for CodeRabbit reviews. CodeRabbit does not review PRs authored by this user. It's recommended to use a dedicated user account to post CodeRabbit review feedback.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 310c6111-ca39-47b3-8420-1ab6b36be312

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds msmsg support: storage trait + backends and DB migration, bot-message HKDF+AES-GCM crypto, wire parsing and classification for "msmsg", inbound msmsg decrypt/dispatch with NACK 495, and outbound message_secret capture and persistence.

Changes

Bot Message Secret Encryption & Persistence

Layer / File(s) Summary
Storage trait and backend implementations
wacore/src/store/traits.rs, wacore/src/store/in_memory.rs, storages/sqlite-storage/src/schema.rs, storages/sqlite-storage/migrations/..., storages/sqlite-storage/src/sqlite_store.rs, src/appstate_sync.rs, src/cache_config.rs, src/keepalive.rs
Adds MsgSecretStore trait and implements it for in-memory and SQLite (upsert+timestamp), adds migration/schema, MockBackend test stub, retention cleanup hook and CacheConfig TTL, and persistence/expiry/multi-account tests.
Bot message encryption and decryption
wacore/src/bot_message.rs, wacore/src/lib.rs
Implements two-pass HKDF key derivation, per-message AES-256-GCM key derivation, AAD layout (`msg_id
Message types, parsing, and bot metadata
wacore/src/message_processing.rs, wacore/src/types/message.rs, wacore/src/messages.rs
Adds EncType::MessageSecret / "msmsg", is_bot_secret() predicate, bot_enc bucket, <meta>/<bot> parsing, BotEditType wire mapping, and exposes bot_message module.
Message helpers
wacore/src/proto_helpers.rs
Adds macros and MessageExt::is_forwarded / mentions_any_bot used by secret capture and tests.
Inbound bot-secret dispatch and tests
src/message.rs
Buffers msmsg payloads into bot_payloads; makes dispatch async to await secret capture; adds Client::handle_msmsg_payload to decode MessageSecretMessage, resolve target sender/chat and lookup persisted secret (with PN↔LID alternate), derive HKDF msg-id per bot edit-chain rules, decrypt via decrypt_bot_message, dispatch wa::Message, and emit NACK 495 on failures; extensive msmsg tests and classification updates.
Signal feature guard against MessageSecret
src/features/signal.rs
Signal::decrypt_message now rejects EncType::MessageSecret, directing callers to the bot/message path.
Outbound message secret persistence
src/send.rs, wacore/src/send.rs
Propagates Prepared*Stanza.message_secret; send_message_impl captures outbound message_secret and msg_id, persists via persist_outbound_msg_secret keyed by (chat non-device, sender non-device, msg_id), choosing LID for bot DMs and PN otherwise; includes tests for keying and identity selection.
Binary encoder JID device handling
wacore/binary/src/encoder.rs
Restricts AD_JID emission to AD-capable servers and forces JID_PAIR for others; adds regression tests to ensure server/user preservation for non-AD servers with device-like suffixes.
JID helper and call-site updates
wacore/binary/src/jid.rs, src/client.rs, src/features/*, src/send.rs
Adds Jid::to_non_ad_string() and replaces several to_non_ad().to_string() call-sites with the new helper for allocation efficiency and consistent non-device normalization.

Sequence Diagram: inbound msmsg flow

sequenceDiagram
  participant Wire as Stanza
  participant Classify
  participant Handler as handle_msmsg_payload
  participant Store as MsgSecretStore
  participant Crypto as bot_message
  participant Dispatcher as EventBus

  Wire->>Classify: receive stanza (enc type="msmsg")
  Classify->>Classify: route payload -> bot_payloads
  Classify->>Handler: call handle_msmsg_payload(payload)
  Handler->>Store: get_msg_secret(chat,sender,msg_id)
  Store-->>Handler: secret or None
  alt secret present
    Handler->>Crypto: decrypt_bot_message(secret, iv, payload, ctx)
    Crypto-->>Handler: plaintext wa::Message
    Handler->>Dispatcher: dispatch wa::Message
  else missing or decrypt fails
    Handler->>Wire: emit NACK (495 MissingMessageSecret / ParsingError)
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

I expect this to work; review crypto, DB scoping, and NACK semantics carefully.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main feature: decrypting Meta AI bot replies via the msmsg encryption type, directly addressing the core objective of the PR.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The pull request description is comprehensive and directly addresses the changeset: implements msmsg decryption for bot replies with detailed crypto specification, storage architecture, outbound/inbound flows, and test coverage.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d3768daba9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/send.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/message.rs`:
- Around line 746-754: The msmsg decrypt/dispatch is being offloaded to
self.outbound_flush which breaks inbound ordering; instead route msmsg through
the inbound ordering path by removing the outbound_flush.spawn call around
handle_msmsg_payload and either (a) acquire the per-chat message_enqueue_locks
(or appropriate session_locks for sender) and call
client.handle_msmsg_payload(&info_arc, payload).await inline so it runs under
the inbound enqueue lock, or (b) if you must spawn, acquire the same
message_enqueue_locks before spawning so the handler executes serialized with
other incoming messages; update the code around outbound_flush,
handle_msmsg_payload, message_enqueue_locks and session_locks accordingly.
- Around line 172-210: The lookup uses the raw resolved target_sender
(target_sender_str) but secrets were stored under the client's persistence key
(a different identity family), so normalize the sender to the same identity
family used when secrets are persisted before calling
persistence_manager.backend().get_msg_secret: take the target_sender from
resolve_msmsg_target_sender, convert it with the same helper used when storing
outbound secrets (the persistence key normalization function used elsewhere in
the codebase / the persistence manager's JID->key helper), and pass that
normalized key (instead of target_sender_str) into get_msg_secret (keep
chat_for_lookup and target_id unchanged).
- Around line 222-227: The Err(e) branch that handles backend read failure for
message_secret currently only logs and returns, leaving the recognized msmsg
stanza unacknowledged so it can be replayed; modify that branch (the Err(e)
block where info.id and msmsg are in scope) to send a terminal NACK for the
msmsg stanza using the existing nack/negative-acknowledgement mechanism used
elsewhere in this module (call the same function you use to terminal-nack other
msmsg stanzas), include the error details in the log, and then return so the
stanza is removed/terminally failed instead of being silently dropped.

In `@storages/sqlite-storage/src/sqlite_store.rs`:
- Around line 2509-2553: The put_msg_secret implementation currently spawns a
blocking task directly and can surface transient SQLITE_BUSY/LOCKED errors;
change it to use the same retry/serialization helper (self.with_retry) used by
other SQLite writers so the insert_into(msg_secrets::table) +
on_conflict(...).do_update().set(...) execute path is retried/serialized.
Concretely: move the Diesel insert/update code (the closure that acquires
pool.get(), builds values for
msg_secrets::chat/sender/msg_id/secret/device_id/created_at and calls .execute)
into a closure passed to self.with_retry (ensuring you clone device_id, chat,
sender, msg_id, secret, now into the closure as done today) instead of calling
tokio::task::spawn_blocking directly; return and propagate errors via the
existing StoreError mapping so SQLITE_BUSY is retried rather than bubbled up.
- Around line 3257-3284: The test msg_secret_isolated_per_device_id is
incorrectly validating DB isolation because store_a and store_b use separate
in-memory DBs; change the test to use the same underlying DB connection while
differing only the device_id so the device_id filter is actually exercised.
Specifically, obtain a second Store that shares the same DB backend as store_a
(e.g., construct store_b from the same connection or provide a helper like
clone_with_db_and_device_id) then set store_b.device_id = store_a.device_id + 1,
call store_a.put_msg_secret("c","s","M",...) and assert
store_b.get_msg_secret("c","s","M").await.unwrap().is_none(); keep references to
create_test_store, device_id, put_msg_secret, and get_msg_secret to locate the
relevant code.

In `@wacore/src/types/message.rs`:
- Around line 158-176: Replace the manual string mapping with a WireEnum-driven
definition: derive WireEnum on BotEditType, add #[wire = "..."] attributes for
each variant's wire value (e.g., "first", "inner", "last"), remove
serde::Serialize from the enum per guideline, and reimplement from_wire as a
thin wrapper that calls BotEditType::try_from(s) (or maps the WireEnum try_from
result to Option) so the wire values are the single source of truth; update any
imports to bring WireEnum/try_from into scope and keep the public API
BotEditType and from_wire names unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a526c79b-02ab-4d03-a034-c85803982e16

📥 Commits

Reviewing files that changed from the base of the PR and between b7bdacf and d3768da.

📒 Files selected for processing (15)
  • src/appstate_sync.rs
  • src/features/signal.rs
  • src/message.rs
  • src/send.rs
  • storages/sqlite-storage/migrations/2026-05-28-000000_add_msg_secrets/down.sql
  • storages/sqlite-storage/migrations/2026-05-28-000000_add_msg_secrets/up.sql
  • storages/sqlite-storage/src/schema.rs
  • storages/sqlite-storage/src/sqlite_store.rs
  • wacore/src/bot_message.rs
  • wacore/src/lib.rs
  • wacore/src/message_processing.rs
  • wacore/src/messages.rs
  • wacore/src/store/in_memory.rs
  • wacore/src/store/traits.rs
  • wacore/src/types/message.rs

Comment thread src/message.rs
Comment thread src/message.rs
Comment thread src/message.rs Outdated
Comment thread storages/sqlite-storage/src/sqlite_store.rs Outdated
Comment thread storages/sqlite-storage/src/sqlite_store.rs Outdated
Comment thread wacore/src/types/message.rs Outdated
…identity, retries)

1. wacore-binary encoder: AD_JID was being emitted for any server with a
   `:device` suffix, even servers (bot/group/broadcast/newsletter/…) whose
   domain_type the decoder maps back to Pn, silently dropping the real
   server. Restrict AD_JID to the 4 servers the decoder round-trips
   (Pn/Lid/Hosted/HostedLid); everything else falls through to JID_PAIR,
   matching whatsmeow `writeJID` and WA Web `WAWap.De`.

2. send: capture the messageSecret that `prepare_dm/group_stanza` mints on
   its own clone of the message, not the caller's `&wa::Message` (which
   never has `message_context_info.message_secret` for ordinary sends).
   `PreparedDmStanza` / `PreparedGroupStanza` now carry an
   `Option<[u8; 32]>` so the post-send hook actually persists every
   outbound bot prompt.

3. msmsg ordering: route bot payloads through `process_classified_message`
   like session/group payloads instead of spawning on `outbound_flush`, so
   the handler runs under the same global semaphore + per-chat enqueue
   lock as the rest of the inbound pipeline.

4. msmsg identity: WA Web fbid path echoes our LID as
   `<meta target_sender_jid>`, but `persist_outbound_msg_secret` was
   storing every secret under our PN. For chats whose server is Bot,
   store under our LID so the inbound lookup hits.

5. msmsg backend error: terminal-nack with 495 when the SQLite read fails
   instead of returning silently (otherwise the server replays).

6. sqlite store: `put_msg_secret` now goes through `with_retry` for
   SQLITE_BUSY handling, matching the other writers.

7. wacore types: derive `WireEnum` on `BotEditType` so `"first"`/`"inner"`/
   `"last"` are the single source of truth per AGENTS.md.

Tests:
- `test_bot_jid_with_device_round_trips_via_jid_pair` (encoder regression)
- `prepared_dm_stanza_exposes_generated_message_secret` (P1 regression)
- `persist_outbound_msg_secret_uses_lid_for_bot_chats` (identity)
- `msmsg_outbound_put_and_inbound_get_match_for_lid_bot` (end-to-end LID)
- `msmsg_with_bot_device_suffix_round_trips` (encoder + msmsg)
- `msg_secret_isolated_per_device_id` rewritten to share the DB and only
  vary `device_id`, so the column filter is actually exercised.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (1)
src/message.rs (1)

191-211: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Separate the persistence lookup key from the HKDF sender value.

This still breaks PN-tagged bot replies. Bot-chat secrets are persisted under the normalized bot-chat identity, but get_msg_secret() is keyed with the raw target_sender_str from <meta>. When <meta target_sender_jid> comes back as our PN, the lookup misses and we nack a reply we could otherwise decrypt. Keep target_sender_str for BotMessageContext, but derive a second sender key for storage lookup using the same normalization path as persist_outbound_msg_secret().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/message.rs` around lines 191 - 211, The persistence lookup is using the
raw target_sender_str which fails for PN-tagged bot replies; keep
target_sender_str for BotMessageContext but derive a second normalized sender
key using the same normalization logic as persist_outbound_msg_secret() (i.e.,
the path used when storing bot-chat secrets) and use that normalized key in the
call to get_msg_secret(&chat_for_lookup, &normalized_sender_key, target_id);
update the code around target_sender_str, get_msg_secret, and BotMessageContext
to pass the original target_sender_str to BotMessageContext but the
normalized_sender_key to persistence_manager.backend().get_msg_secret so PN JIDs
resolve to the stored secret.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@src/message.rs`:
- Around line 191-211: The persistence lookup is using the raw target_sender_str
which fails for PN-tagged bot replies; keep target_sender_str for
BotMessageContext but derive a second normalized sender key using the same
normalization logic as persist_outbound_msg_secret() (i.e., the path used when
storing bot-chat secrets) and use that normalized key in the call to
get_msg_secret(&chat_for_lookup, &normalized_sender_key, target_id); update the
code around target_sender_str, get_msg_secret, and BotMessageContext to pass the
original target_sender_str to BotMessageContext but the normalized_sender_key to
persistence_manager.backend().get_msg_secret so PN JIDs resolve to the stored
secret.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: eb64c243-fc51-4056-8816-ec9315371575

📥 Commits

Reviewing files that changed from the base of the PR and between d3768da and f2e099b.

📒 Files selected for processing (6)
  • src/message.rs
  • src/send.rs
  • storages/sqlite-storage/src/sqlite_store.rs
  • wacore/binary/src/encoder.rs
  • wacore/src/send.rs
  • wacore/src/types/message.rs

WA Web `WAWebBotMessageSecret.js` has two dispatch arms:
  * `h()` (fbid bot, Meta AI) pre-resolves the HKDF msg_id to either
    `externalId` or `edit_target_id` based on edit_type, then makes a
    single AES-GCM attempt.
  * `f()` (regular bot) always tries `externalId` first, falls back to
    `edit_target_id` on AES-GCM failure.

We don't have an `isFbidBot()` check, so unify: the fbid-style id is the
primary attempt and the OTHER id (if available) is the fallback. This is
a strict superset:
  * INNER/LAST stanzas: primary = edit_target_id, fallback = info.id.
    Matches fbid outcome on first try; covers the regular-path scenario
    where the bot encrypted under `externalId` instead.
  * Other stanzas: primary = info.id, fallback = edit_target_id (when the
    parser populated it). Matches the regular path's first attempt.

`bot_info.edit_target_id` is still parsed only for INNER/LAST (matches
whatsmeow `parseMsgBotInfo`), so for non-edit stanzas there's no
fallback id — single attempt, nack 495 on failure.

Tests (2):
- `msmsg_falls_back_to_info_id_when_primary_uses_edit_target`: stanza
  declares `<bot edit="inner" edit_target_id="...">` but the ciphertext
  was minted under `info.id`. Primary tries the edit target, fails, and
  the fallback succeeds.
- `msmsg_no_fallback_when_no_edit_target_present`: no `<bot>` node →
  parser leaves `edit_target_id = None` → wrong key → no second attempt
  → nack 495 (single failure must not silently mask).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1e601af018

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/send.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/message.rs`:
- Around line 8251-8398: Add a new async test that mirrors
msmsg_falls_back_to_info_id_when_primary_uses_edit_target but exercises the
inverse branch: store the secret under edit_target_id (use
persistence_manager.backend().put_msg_secret with edit_target_id), set the
stanza id (info.id) to one value (e.g., REPLY_INV) and set the <bot edit
edit_target_id="EDIT_INV"> to another (edit_target_id), then encrypt the payload
under edit_target_id by constructing BotMessageContext with msg_id =
edit_target_id and call encrypt_bot_message/encode_message_secret_message (same
helpers used in the diff) so the primary attempt using info.id fails and the
fallback to edit_target_id succeeds; send the NodeBuilder message (include
meta.target_id/outbound id and bot edit as above) to
client.handle_incoming_message and assert you receive the decrypted message
(match on wacore::types::events::Event::Message where info.id == stanza id and
msg.conversation == "fallback ok" or similar) to confirm the info.id ->
edit_target_id fallback path is covered.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 40b34d26-e246-4acd-8a5a-ab42f22146e2

📥 Commits

Reviewing files that changed from the base of the PR and between f2e099b and 1e601af.

📒 Files selected for processing (1)
  • src/message.rs

Comment thread src/message.rs
…tionally

P2 (Codex): LID-mode groups were persisting messageSecret under our PN
because the helper branched on chat.server (Bot -> LID, else -> PN), but
`prepare_group_stanza` already picks LID or PN based on the group's
addressing_mode. `<meta target_sender_jid>` echoes whatever identity the
server addressed, so for a LID-mode group the inbound msmsg lookup keyed
under our LID would miss the row we stored under PN.

- PreparedGroupStanza carries the addressing identity it actually used
  (`sender_identity: Jid`).
- send_message_impl threads it through to a refactored
  `persist_outbound_msg_secret(chat, sender, msg_id, secret)` that takes
  the identity explicitly instead of deriving it from chat.server.
- A new `dm_sender_identity_for` helper keeps the previous DM heuristic
  (LID for bot chats, PN otherwise) for the DM path.

Loosened the `<bot>` parser to read `edit_target_id` unconditionally
(WA Web `f()` accesses it via `?.botEditTargetId` regardless of edit_type;
restricting to INNER/LAST blocked the regular-bot try-then-fallback from
ever firing for non-fbid bots).

Tests:
- `persist_uses_group_sender_identity_for_lid_mode_groups`
- `dm_sender_identity_picks_lid_for_bot_else_pn`
- `msmsg_falls_back_to_edit_target_when_primary_uses_info_id` (inverse
  of the existing primary→fallback test, exercises the regular bot path)
@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented May 28, 2026

Copy link
Copy Markdown

Claude finished @jlucaso1's task in 3m 54s —— View job


Claude Code Review

  • Gather context — read PR description, prior reviews, all changed areas
  • Crypto (wacore/src/bot_message.rs)
  • Inbound pipeline (src/message.rs)
  • Outbound persistence (src/send.rs, wacore/src/send.rs)
  • Storage trait + SQLite impl
  • Encoder JID change
  • Cross-check against prior reviewer findings (Codex P1/P2, CodeRabbit ×8)

Overall this is solid. The crypto layout matches WA Web / whatsmeow, the test matrix is thorough (HKDF input-sensitivity, AAD layout, every-field-mutation vector, fallback both directions, encoder round-trip, PUT/GET coherence), and the prior P1/P2/Major review threads were properly folded in (secret threaded out of prepare_*_stanza, sender_identity propagated for LID-mode groups, msmsg now runs inline under the per-chat lock, backend Err nacks 495, with_retry on put_msg_secret, BotEditType is now WireEnum, AD_JID restricted in parse_jid_meta).

Four findings worth a look — none blocking, two are minor/preexisting.


1. <P2> Encoder fix is asymmetric: write_jid_owned / write_jid_ref still AD-encode bot/group/broadcast JIDs with device > 0

wacore/binary/src/encoder.rs:299–346 correctly restricts AD_JID to the 4 servers that round-trip (Pn/Lid/Hosted/HostedLid) — but only on the string-encoding path (parse_jid_meta).

The typed Jid/JidRef paths at encoder.rs:774–795 and encoder.rs:799–820 still take the AD_JID branch whenever device > 0, regardless of server:

pub fn write_jid_owned(&mut self, jid: &Jid) -> Result<()> {
    if jid.device > 0 {                            // ← server type ignored
        self.write_u8(token::AD_JID)?;
        self.write_u8(server_to_domain_type(jid.server, jid.agent))?;
        ...
    }
}

For e.g. a Jid { server: Bot, device: 5 } constructed programmatically and emitted as NodeValue::Jid, server_to_domain_type falls into the _ => agent arm (=0), and the decoder reads it back as Pn — the same symptom this PR set out to fix on the string path. In practice today bot JIDs are bare (device == 0) so it isn't triggered, but the test test_jid_string_vs_direct_encoding_matches only covers Pn/Lid/Hosted/HostedLid; an inserted "867051314767696:5@bot" Jid would diverge from the string path. Worth either (a) mirroring the server filter in write_jid_owned/write_jid_ref, or (b) extending the round-trip test to cover Bot/Group/Newsletter so the asymmetry is caught.

2. <P3> Bad AES-GCM tag nacks with MissingMessageSecret (495)

src/message.rs:283–298 uses NackReason::MissingMessageSecret for the case where the secret is found but the GCM tag fails after both info.id and edit_target_id attempts. The secret is present, the protocol violation is a corrupt/forged payload, not a missing record. NackReason::UnhandledError (500) is a closer fit and lets logs/metrics distinguish a real lookup miss from a crypto failure. Cosmetic — the server only cares that the stanza is terminally acked — but it muddies post-mortem analysis.

3. <P3> Redundant primary/fallback attempt when info.id == edit_target_id

src/message.rs:245–266: if the bot reuses the same id for bot_info.edit_target_id as info.id (no edit chain present in practice, but the parser now reads edit_target_id unconditionally per commit e8bdfd0), primary_msg_id == info_id and fallback_msg_id == Some(info_id) — we run two identical decrypt_bot_message calls with the same context before nacking. Defensive guard:

let fallback_msg_id = match primary_msg_id == info_id {
    true  => info.bot_info.as_ref().and_then(|bi| bi.edit_target_id.as_deref()),
    false => Some(info_id),
}
.filter(|fb| *fb != primary_msg_id);   // dedup

Micro-optimisation, not a bug.

4. <P3> HKDF info field is concatenated without separators

wacore/src/bot_message.rs:64–76 builds the second-pass HKDF info as raw msg_id || target_user_jid || bot_user_jid with no length prefix or separator. This matches WA Web so changing it would break interop — but it's worth noting that the security argument relies on JIDs always containing @server (so the boundary between target and bot is unambiguous in any practical input). The constants here can't change without coordinated server work, so this is purely informational, not a request to change.


Things I checked that look right

  • target_sender_str / chat_for_lookup are normalised with .to_non_ad().to_string() on GET; the persist side does the same. The CodeRabbit duplicate concern about "PN-tagged bot replies missing under a LID-stored row" is mitigated by resolve_msmsg_target_sender falling back to LID when the sender is on @bot, and by <meta target_sender_jid> echoing whatever identity we addressed under (LID for bot, PN otherwise). The msmsg_outbound_put_and_inbound_get_match_for_lid_bot test pins this.
  • Inbound msmsg now runs inline in process_classified_message under the global permit + per-chat enqueue lock; the prior outbound_flush.spawn ordering concern is fixed (src/message.rs:1049–1054).
  • put_msg_secret goes through self.with_retry with Arc::clone-able captures (storages/sqlite-storage/src/sqlite_store.rs:2508–2553) — SQLITE_BUSY is now retried.
  • msg_secret_isolated_per_device_id now uses a single shared in-memory URL (file:...?mode=memory&cache=shared) with two device_ids, which actually exercises the device_id filter rather than DB-level isolation (sqlite_store.rs:3258–3297).
  • BotEditType is #[derive(WireEnum)] with #[wire="first|inner|last"]; from_wire is a thin try_from(s).ok() wrapper (wacore/src/types/message.rs:158–173). Matches AGENTS.md.
  • PreparedGroupStanza.sender_identity is populated with own_sending_jid (LID for LID-mode groups, PN for PN-mode) and consumed in send.rs:1411–1414 before falling back to dm_sender_identity_for. The P2 LID-group concern is genuinely addressed.
    · branch feat/msmsg-meta-ai-bot

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e8bdfd0149

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread wacore/binary/src/encoder.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/send.rs`:
- Around line 1477-1482: The helper persist_outbound_msg_secret currently
accepts a generic &[u8], which lets callers pass wrong-sized secrets; change its
signature to require a fixed 32-byte secret (e.g., & [u8; 32] or a [u8; 32]
value) so misuse is a compile-time error, update any call sites to
construct/borrow a [u8;32], and adjust internal usage to treat the parameter as
a 32-byte array (no length checks needed). Ensure the change propagates to any
trait impls or tests referencing persist_outbound_msg_secret so callers provide
the exact-size message_secret expected by the reporting-token/msmsg flow.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 48ed05e3-c71b-4b40-8f36-8a30d0c49284

📥 Commits

Reviewing files that changed from the base of the PR and between 1e601af and e8bdfd0.

📒 Files selected for processing (4)
  • src/message.rs
  • src/send.rs
  • wacore/src/messages.rs
  • wacore/src/send.rs

Comment thread src/send.rs Outdated
Codex P2 (encoder): the AD_JID restriction landed only on the string path
(`parse_jid_meta`). Typed JIDs encoded via `write_jid_owned` /
`write_jid_ref` (and their size estimators) still emitted AD_JID for any
`device > 0`, so a programmatically built `Jid { server: Bot, device: 5 }`
would round-trip back to Pn. Centralised the check in a single
`server_supports_ad_jid(Server)` helper and applied it in:
  * `parse_jid_meta` (via `Server::try_from`)
  * `write_jid_ref`, `write_jid_owned`
  * `owned_jid_encoded_size_with_cache`, `jid_ref_encoded_size_with_cache`

Codex (send.rs): tightened `persist_outbound_msg_secret`'s secret param
from `&[u8]` to `&[u8; reporting_token::MESSAGE_SECRET_SIZE]` so misuse
is a compile-time error. All call sites already pass a 32-byte array.

Claude P3 (dedup): after the parser started reading `edit_target_id`
unconditionally, a stanza that happens to set `edit_target_id == info.id`
would otherwise run two identical decrypt attempts before nacking.
`.filter(|fb| *fb != primary_msg_id)` collapses the duplicate.

Skipped (with reason):
  * Nack code for GCM tag failure stays `495` (`MissingMessageSecret`):
    matches whatsmeow `decryptMessages` exactly, which uses that code for
    every msmsg failure path. Diverging to 500 would split parity for a
    purely cosmetic gain.
  * HKDF info concatenation stays separator-less: matches WA Web's
    `v(msgId, target_jid, bot_jid)` literally. Changing it would break
    interop.

Tests:
  * `test_typed_non_ad_jid_with_device_round_trips_via_jid_pair`: typed
    `Jid` with `device > 0` for Bot/broadcast/newsletter must NOT emit
    AD_JID and must round-trip with the server preserved.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cadb0d9fe1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread storages/sqlite-storage/src/sqlite_store.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/message.rs (2)

184-191: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Normalize msmsg JIDs exactly like the outbound secret store path.

This path still feeds to_non_ad().to_string() into the secret lookup and HKDF context. Outbound persistence strips device IDs, so a reply that round-trips as user:NN@bot or target_sender_jid=...:NN@lid can miss the stored secret or derive the wrong key and get nacked even though we persisted the right secret. We need one canonical form on both sides.

Suggested fix
+        let normalize_msmsg_jid = |jid: &Jid| {
+            let mut jid = jid.to_non_ad();
+            jid.device = 0;
+            jid.to_string()
+        };
+
         let chat_for_lookup = info
             .meta_info
             .target_chat
             .as_ref()
             .unwrap_or(&info.source.chat)
-            .to_non_ad()
-            .to_string();
-        let target_sender_str = target_sender.to_non_ad().to_string();
+            .pipe(normalize_msmsg_jid);
+        let target_sender_str = normalize_msmsg_jid(&target_sender);
@@
-        let bot_user_jid = info.source.sender.to_non_ad().to_string();
+        let bot_user_jid = normalize_msmsg_jid(&info.source.sender);

Also applies to: 208-211, 233-275

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/message.rs` around lines 184 - 191, The chat_for_lookup and
target_sender_str JID normalization currently uses to_non_ad().to_string(),
which differs from the canonical form used by outbound secret-store paths and
can cause missed lookups or wrong HKDF keys; update the normalization in
chat_for_lookup, target_sender_str (and the other mentioned sites) to use the
exact same helper used when constructing outbound secret-store paths (the
canonical secret-path JID normalizer used by the outbound persistence code)
instead of to_non_ad().to_string() so both sides strip device IDs and produce
the identical canonical JID string for secret lookup and HKDF context.

816-829: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't treat msmsg plus an unknown sibling as “unknown-only”.

This guard ignores bot_payloads. So a stanza with one valid msmsg and one unknown/empty <enc> hits the fallback transport-ack path and never reaches handle_msmsg_payload. That's silent message loss on a real bot reply.

Suggested fix
         if session_payloads.is_empty()
             && group_payloads.is_empty()
+            && bot_payloads.is_empty()
             && had_unknown_enc
             && !had_custom_handler
         {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/message.rs` around lines 816 - 829, The early-return condition treats
stanzas with a valid msmsg (bot_payloads) plus an unknown sibling as
“unknown-only”; update the guard that currently checks
session_payloads.is_empty(), group_payloads.is_empty(), had_unknown_enc, and
!had_custom_handler to also require bot_payloads.is_empty() (or otherwise ensure
there are no msmsg/bot payloads) so that messages with a valid msmsg still reach
handle_msmsg_payload instead of taking the transport-ack path
(spawn_node_transport_ack); keep the rest of the logic intact and reference
variables session_payloads, group_payloads, bot_payloads, had_unknown_enc,
had_custom_handler, handle_msmsg_payload, and spawn_node_transport_ack when
making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/message.rs`:
- Around line 184-191: The chat_for_lookup and target_sender_str JID
normalization currently uses to_non_ad().to_string(), which differs from the
canonical form used by outbound secret-store paths and can cause missed lookups
or wrong HKDF keys; update the normalization in chat_for_lookup,
target_sender_str (and the other mentioned sites) to use the exact same helper
used when constructing outbound secret-store paths (the canonical secret-path
JID normalizer used by the outbound persistence code) instead of
to_non_ad().to_string() so both sides strip device IDs and produce the identical
canonical JID string for secret lookup and HKDF context.
- Around line 816-829: The early-return condition treats stanzas with a valid
msmsg (bot_payloads) plus an unknown sibling as “unknown-only”; update the guard
that currently checks session_payloads.is_empty(), group_payloads.is_empty(),
had_unknown_enc, and !had_custom_handler to also require bot_payloads.is_empty()
(or otherwise ensure there are no msmsg/bot payloads) so that messages with a
valid msmsg still reach handle_msmsg_payload instead of taking the transport-ack
path (spawn_node_transport_ack); keep the rest of the logic intact and reference
variables session_payloads, group_payloads, bot_payloads, had_unknown_enc,
had_custom_handler, handle_msmsg_payload, and spawn_node_transport_ack when
making the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e792793a-0680-42ca-900d-2993901d3b89

📥 Commits

Reviewing files that changed from the base of the PR and between e8bdfd0 and cadb0d9.

📒 Files selected for processing (3)
  • src/message.rs
  • src/send.rs
  • wacore/binary/src/encoder.rs

@github-actions

Copy link
Copy Markdown

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

Inbound fanout capture (gap noted in earlier review): WA Web
`processRenderableMessages` caches `messageSecret` from any bot-targeted
renderable (`$ && (P || N || w || A) && !isForwarded`), regardless of
fromMe. Mirrored in `Client::maybe_capture_inbound_msg_secret`, called
from `dispatch_parsed_message`. Without it phone-initiated Meta AI
conversations would still nack 495 even though the fanout from the
primary carries the outbound secret.

Performance: 32-byte stack copy + `Arc<MessageInfo>` clone (refcount
bump) + a single `outbound_flush.spawn`. No heap alloc for the secret.

Retention (Codex P2): the `created_at` column existed but no path used
it, so long-running deployments accumulated one row per outbound. Added
`MsgSecretStore::delete_expired_msg_secrets(cutoff)` to the trait, wired
the SQLite impl through `with_retry`, and plugged the call into the
keepalive cleanup loop (next to `delete_expired_sent_messages`) with a
14-day TTL — covers bot edits / late reactions / poll votes without
unbounded growth.

Codex inline P2 (msmsg sibling of an unknown enc): the unknown-only
fallback guard ignored `bot_payloads`, so a stanza carrying BOTH a
valid msmsg AND an unknown enc would short-circuit through
`spawn_node_transport_ack` and silently drop the msmsg. Added
`bot_payloads.is_empty()` to the guard.

Codex inline (chat/sender canonical form) — skipped: outbound and inbound
both use `.to_non_ad().to_string()` against the same identity family
(verified at src/send.rs:1484-1485 vs src/message.rs:224/231). The
canonical forms already match.

Tests:
- `maybe_capture_inbound_msg_secret_persists_for_bot_chats`
- `maybe_capture_inbound_msg_secret_skips_non_bot_chats`
- `maybe_capture_inbound_msg_secret_skips_when_secret_absent`
- `fanout_capture_lets_subsequent_msmsg_decrypt` (end-to-end)
- `mixed_msmsg_and_unknown_enc_still_decrypts_msmsg`
- `delete_expired_msg_secrets_removes_only_old_rows` (in-memory)
- `delete_expired_msg_secrets_deletes_only_below_cutoff` (sqlite)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 91b61a22e2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread wacore/src/messages.rs
// target_chat_jid) to look up the parent messageSecret.
meta_info.target_id = ma.optional_string("target_id").map(|s| s.into_owned());
meta_info.target_sender = ma.optional_jid("target_sender_jid");
meta_info.target_chat = ma.optional_jid("target_chat_jid");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve target chat for bot-sourced replies

When a bot reply carries <meta target_chat_jid> (for example a Meta AI reply in a group), this only records the value in meta_info; source.chat was already set to the bot JID earlier and is never corrected. The new msmsg lookup uses target_chat, so decryption can succeed, but the dispatched Event::Message is attributed to the bot DM instead of the target group/chat, which breaks consumers that route messages by info.source.chat. After parsing this attr, bot-sourced stanzas should update MessageSource (and group context as needed) before returning MessageInfo.

Useful? React with 👍 / 👎.

@github-actions

Copy link
Copy Markdown

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/keepalive.rs (1)

153-181: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Decouple msg_secrets retention from the sent-message TTL gate.

I need this fixed: Line 153 currently gates both cleanups on sent_msg_ttl > 0. When sent-message retention is disabled, delete_expired_msg_secrets never runs and msg_secrets can grow unbounded.

Suggested fix
-                            if sent_msg_ttl > 0 && cleanup_counter >= 12 {
+                            if cleanup_counter >= 12 {
                                 cleanup_counter = 0;
-                                let backend = self.persistence_manager.backend();
-                                let cutoff = wacore::time::now_secs()
-                                    - sent_msg_ttl as i64;
-                                self.runtime.spawn(Box::pin(async move {
-                                    if let Err(e) = backend.delete_expired_sent_messages(cutoff).await {
-                                        log::debug!(target: "Client/Keepalive", "Sent message cleanup error: {e}");
-                                    }
-                                })).detach();
+                                if sent_msg_ttl > 0 {
+                                    let backend = self.persistence_manager.backend();
+                                    let cutoff = wacore::time::now_secs() - sent_msg_ttl as i64;
+                                    self.runtime.spawn(Box::pin(async move {
+                                        if let Err(e) = backend.delete_expired_sent_messages(cutoff).await {
+                                            log::debug!(target: "Client/Keepalive", "Sent message cleanup error: {e}");
+                                        }
+                                    })).detach();
+                                }
                                 // msg_secrets retention: 14 days covers bot
                                 // edits / late reactions / poll votes while
                                 // bounding growth on long-running deployments.
                                 const MSG_SECRETS_TTL_SECS: i64 = 14 * 86_400;
                                 let backend = self.persistence_manager.backend();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/keepalive.rs` around lines 153 - 181, The sent-message cleanup block
currently guarded by sent_msg_ttl > 0 also contains the msg_secrets cleanup,
causing delete_expired_msg_secrets to be skipped when sent_msg_ttl is 0;
separate the two: keep the existing condition and runtime.spawn call for
backend.delete_expired_sent_messages(cutoff) inside the sent_msg_ttl > 0 &&
cleanup_counter >= 12 branch (and reset cleanup_counter there), then
unconditionally (or under its own schedule if desired) create a separate
runtime.spawn that calls
persistence_manager.backend().delete_expired_msg_secrets(secret_cutoff) using
MSG_SECRETS_TTL_SECS and the same logging on error so msg_secrets cleanup runs
independent of sent_msg_ttl.
src/send.rs (1)

1410-1424: 🧹 Nitpick | 🔵 Trivial | 💤 Low value

Consider a trace log when sender identity is unavailable.

Look, the connection's working, the stanza's going out, but if we can't resolve a sender identity here, the bot's reply is gonna fail to decrypt and nobody's gonna know why. That's not a great user experience. A quick log::trace! when sender is None would help us track down those "why can't I decrypt the Meta AI reply?" issues without spamming production logs.

That said, this is a narrow edge case since bot interactions happen after LID is typically set. Not blocking.

📝 Optional trace log
 if let Some(secret) = outbound_msg_secret.as_ref() {
     let sender = match outbound_group_sender_identity {
         Some(s) => Some(s),
         None => self.dm_sender_identity_for(&tc_issue_target).await,
     };
     if let Some(sender) = sender {
         self.persist_outbound_msg_secret(
             &tc_issue_target,
             &sender,
             &outbound_id_clone,
             secret,
         )
         .await;
+    } else {
+        log::trace!(
+            "Skipping messageSecret persistence for {}: sender identity unavailable",
+            outbound_id_clone
+        );
     }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/send.rs` around lines 1410 - 1424, Add a trace log when no sender
identity can be resolved so we can track decryption failures: inside the branch
handling outbound_msg_secret where you compute sender (using
outbound_group_sender_identity and dm_sender_identity_for), when the final
`sender` is None, call log::trace! with context (e.g., tc_issue_target and
outbound_id_clone) before skipping persist_outbound_msg_secret; leave existing
behavior unchanged and keep the log at trace level to avoid spamming production.
♻️ Duplicate comments (1)
src/message.rs (1)

164-170: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Normalize the bot-secret sender key on both write and read.

This now persists under dm_sender_identity_for(bot_chat), but handle_msmsg_payload() still looks up with the raw target_sender_jid/fallback. If the stanza echoes our PN while the secret was stored under our LID, the lookup misses and we nack a decryptable reply with 495.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/message.rs` around lines 164 - 170, The write path stores the secret
under the normalized DM sender identity returned by
client.dm_sender_identity_for(...) in persist_outbound_msg_secret, but the read
path in handle_msmsg_payload still looks up using the raw
target_sender_jid/fallback and can miss matches; update the read-side lookup in
handle_msmsg_payload to first normalize the sender JID via
client.dm_sender_identity_for(&chat) (or equivalent normalization helper) and
try that key before falling back to the raw target_sender_jid, or alternatively
make both persist_outbound_msg_secret and the lookup use the same normalization
function so the secret is always written and read under the same normalized
sender key.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/message.rs`:
- Around line 164-170: The outbound persist call is being detached via
outbound_flush.spawn (using self.runtime) which makes bot-secret persistence
eventually consistent and allows a subsequent inbound processed by
handle_msmsg_payload() to miss the secret; instead perform the
persist_outbound_msg_secret write inline (await it) under the appropriate
per-chat/per-sender lock so the state update is visible before returning: remove
the detached spawn of client.persist_outbound_msg_secret(&info.source.chat,
&sender, &info.id, &secret).await and ensure you hold the relevant
session_locks/message_enqueue_locks while awaiting the persist so secret capture
is synchronous for the incoming message path.

---

Outside diff comments:
In `@src/keepalive.rs`:
- Around line 153-181: The sent-message cleanup block currently guarded by
sent_msg_ttl > 0 also contains the msg_secrets cleanup, causing
delete_expired_msg_secrets to be skipped when sent_msg_ttl is 0; separate the
two: keep the existing condition and runtime.spawn call for
backend.delete_expired_sent_messages(cutoff) inside the sent_msg_ttl > 0 &&
cleanup_counter >= 12 branch (and reset cleanup_counter there), then
unconditionally (or under its own schedule if desired) create a separate
runtime.spawn that calls
persistence_manager.backend().delete_expired_msg_secrets(secret_cutoff) using
MSG_SECRETS_TTL_SECS and the same logging on error so msg_secrets cleanup runs
independent of sent_msg_ttl.

In `@src/send.rs`:
- Around line 1410-1424: Add a trace log when no sender identity can be resolved
so we can track decryption failures: inside the branch handling
outbound_msg_secret where you compute sender (using
outbound_group_sender_identity and dm_sender_identity_for), when the final
`sender` is None, call log::trace! with context (e.g., tc_issue_target and
outbound_id_clone) before skipping persist_outbound_msg_secret; leave existing
behavior unchanged and keep the log at trace level to avoid spamming production.

---

Duplicate comments:
In `@src/message.rs`:
- Around line 164-170: The write path stores the secret under the normalized DM
sender identity returned by client.dm_sender_identity_for(...) in
persist_outbound_msg_secret, but the read path in handle_msmsg_payload still
looks up using the raw target_sender_jid/fallback and can miss matches; update
the read-side lookup in handle_msmsg_payload to first normalize the sender JID
via client.dm_sender_identity_for(&chat) (or equivalent normalization helper)
and try that key before falling back to the raw target_sender_jid, or
alternatively make both persist_outbound_msg_secret and the lookup use the same
normalization function so the secret is always written and read under the same
normalized sender key.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 89848ade-701f-4b64-b1fe-4f956583080e

📥 Commits

Reviewing files that changed from the base of the PR and between e8bdfd0 and 91b61a2.

📒 Files selected for processing (8)
  • src/appstate_sync.rs
  • src/keepalive.rs
  • src/message.rs
  • src/send.rs
  • storages/sqlite-storage/src/sqlite_store.rs
  • wacore/binary/src/encoder.rs
  • wacore/src/store/in_memory.rs
  • wacore/src/store/traits.rs

Comment thread src/message.rs Outdated
The msg_secrets prune was nested inside `if sent_msg_ttl > 0`, so a
deployment that disabled sent-message pruning (sent_message_ttl_secs = 0)
but opted into msg_secret_ttl_secs would never run the secret cleanup —
the advertised opt-in was a no-op in that combination.

Share the ~5min tick gate but give each retention setting its own
`ttl > 0` guard so they enable/disable independently.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (1)
src/keepalive.rs (1)

159-175: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

This cutoff math can break retention correctness under large TTL values.

Line 161 and Line 174 cast u64 TTLs with as i64; if config exceeds i64::MAX, the cast wraps and computes a bogus cutoff, so cleanup can target the wrong rows. This needs a safe conversion before subtraction.

Suggested fix
                                 if sent_msg_ttl > 0 {
-                                    let backend = self.persistence_manager.backend();
-                                    let cutoff = now - sent_msg_ttl as i64;
-                                    self.runtime.spawn(Box::pin(async move {
-                                        if let Err(e) = backend.delete_expired_sent_messages(cutoff).await {
-                                            log::debug!(target: "Client/Keepalive", "Sent message cleanup error: {e}");
-                                        }
-                                    })).detach();
+                                    if let Ok(ttl_i64) = i64::try_from(sent_msg_ttl) {
+                                        let backend = self.persistence_manager.backend();
+                                        let cutoff = now.saturating_sub(ttl_i64);
+                                        self.runtime.spawn(Box::pin(async move {
+                                            if let Err(e) = backend.delete_expired_sent_messages(cutoff).await {
+                                                log::debug!(target: "Client/Keepalive", "Sent message cleanup error: {e}");
+                                            }
+                                        })).detach();
+                                    } else {
+                                        log::warn!(target: "Client/Keepalive", "sent_message_ttl_secs exceeds i64::MAX; skipping cleanup tick");
+                                    }
                                 }
@@
                                 let secret_ttl = self.cache_config.msg_secret_ttl_secs;
                                 if secret_ttl > 0 {
-                                    let backend = self.persistence_manager.backend();
-                                    let secret_cutoff = now - secret_ttl as i64;
-                                    self.runtime.spawn(Box::pin(async move {
-                                        if let Err(e) = backend
-                                            .delete_expired_msg_secrets(secret_cutoff)
-                                            .await
-                                        {
-                                            log::debug!(
-                                                target: "Client/Keepalive",
-                                                "msg_secrets cleanup error: {e}"
-                                            );
-                                        }
-                                    }))
-                                    .detach();
+                                    if let Ok(ttl_i64) = i64::try_from(secret_ttl) {
+                                        let backend = self.persistence_manager.backend();
+                                        let secret_cutoff = now.saturating_sub(ttl_i64);
+                                        self.runtime.spawn(Box::pin(async move {
+                                            if let Err(e) = backend
+                                                .delete_expired_msg_secrets(secret_cutoff)
+                                                .await
+                                            {
+                                                log::debug!(
+                                                    target: "Client/Keepalive",
+                                                    "msg_secrets cleanup error: {e}"
+                                                );
+                                            }
+                                        }))
+                                        .detach();
+                                    } else {
+                                        log::warn!(target: "Client/Keepalive", "msg_secret_ttl_secs exceeds i64::MAX; skipping cleanup tick");
+                                    }
                                 }

Use this read-only check to confirm the type mismatch and risky cast sites:

#!/bin/bash
set -euo pipefail

echo "== CacheConfig TTL field types =="
rg -n --type=rust -C3 'struct\s+CacheConfig|sent_message_ttl_secs|msg_secret_ttl_secs'

echo
echo "== now_secs signature =="
rg -n --type=rust -C2 'fn\s+now_secs\s*\('

echo
echo "== Keepalive cutoff cast sites =="
rg -n --type=rust -C2 'now\s*-\s*[a-zA-Z_][a-zA-Z0-9_]*\s+as\s+i64|as\s+i64'

Expected confirmation: TTL fields are unsigned (typically u64) while cutoff math in this block uses as i64, proving wrap risk for oversized config values.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/keepalive.rs` around lines 159 - 175, The TTL-to-cutoff subtraction
currently casts unsigned TTLs with "as i64" (for sent_msg_ttl and
msg_secret_ttl), which can wrap on large values; change to a checked conversion
(use TryInto::<i64>() on sent_msg_ttl and secret_ttl), handle the Err case by
saturating to i64::MAX or skipping the cleanup, then compute cutoff = now -
ttl_i64; update both places that spawn async tasks around
backend.delete_expired_sent_messages(...) and the msg-secret cleanup to use the
safe ttl_i64 conversion and avoid direct "as i64" casts.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@src/keepalive.rs`:
- Around line 159-175: The TTL-to-cutoff subtraction currently casts unsigned
TTLs with "as i64" (for sent_msg_ttl and msg_secret_ttl), which can wrap on
large values; change to a checked conversion (use TryInto::<i64>() on
sent_msg_ttl and secret_ttl), handle the Err case by saturating to i64::MAX or
skipping the cleanup, then compute cutoff = now - ttl_i64; update both places
that spawn async tasks around backend.delete_expired_sent_messages(...) and the
msg-secret cleanup to use the safe ttl_i64 conversion and avoid direct "as i64"
casts.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c9286008-7516-4ba4-8e53-65f939741716

📥 Commits

Reviewing files that changed from the base of the PR and between 23a4766 and b8dc241.

📒 Files selected for processing (7)
  • src/client.rs
  • src/features/message_edit.rs
  • src/features/polls.rs
  • src/keepalive.rs
  • src/message.rs
  • src/send.rs
  • wacore/binary/src/jid.rs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b8dc241bc0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/message.rs Outdated
Comment thread src/keepalive.rs Outdated
@github-actions

Copy link
Copy Markdown

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

jlucaso1 added 2 commits May 28, 2026 15:27
… TTL hardening

Codex P2 (fanout ordering): the inbound messageSecret capture spawned the
DB write on outbound_flush and returned, releasing the per-chat worker to
dequeue the next stanza. A bot <enc type="msmsg"> reply queued right behind
its own fanout (offline replay) could run handle_msmsg_payload before the
write landed → MissingMessageSecret. Made the capture awaited inline:
`maybe_capture_inbound_msg_secret` and `dispatch_parsed_message` are now
async, so the write completes within the per-chat worker's serial section
before the next stanza is processed. Also drops the spawn's 32-byte stack
copy + Arc clones — the secret is borrowed straight through.

Codex P2 (retention only after ping): the cleanup lived in the keepalive
ping-success branch, but busy connections skip the ping when recent traffic
already proves liveness, so they never reached the 12-tick gate. Moved the
sweep to fire on the interval tick itself (before the idle early-return),
extracted into `spawn_retention_cleanup`.

Codex (TTL `as i64` wrap): converted the u64→i64 TTL cast to
`now.saturating_sub(i64::try_from(ttl).unwrap_or(i64::MAX))` so an absurd
TTL clamps (pruning nothing) instead of wrapping the cutoff negative.
- CacheConfig's manual Debug impl omitted the new msg_secret_ttl_secs
  field; added it so the runtime switch shows up in logs.
- mentions_any_bot compared the JID domain to the literal "bot", missing
  the legacy PN-form Meta bot. Parse each mentioned_jid and use the
  canonical Jid::is_bot() contract (matches WA Web jid.isBot()). Only runs
  on the rare group-mention path with a short list.

Skipped (already fixed in 2380433 / stale review commit):
- keepalive TTL u64→i64 overflow: now i64::try_from(..).unwrap_or(MAX)
  + saturating_sub in spawn_retention_cleanup.
- persist_outbound_msg_secret on outbound_flush.spawn race: now awaited
  inline in maybe_capture_inbound_msg_secret / dispatch_parsed_message.

Skipped (not feasible by design):
- "normalize PUT sender to handle_msmsg_payload's target_sender_jid": the
  capture runs on our outbound prompt, which carries no target_sender_jid
  meta (that exists only on the bot's reply). dm_sender_identity_for is the
  capture-time identity; alternate_msg_secret_lookup (WA Web getAlternateMsgKey
  mirror) reconciles LID↔PN skew at GET time, with e2e coverage.

Test: mentions_any_bot_true_for_legacy_pn_form_bot.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/message.rs (1)

165-176: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

This cache key breaks real group bot traffic.

Line 172 always persists the secret under our own sender identity, and the gate at Lines 165-167 skips bot-authored replies in groups. That means flows like peer mentions bot -> bot reply/edit via msmsg won't match the later get_msg_secret(chat, meta.target_sender_jid, target_id) lookup and we end up nack'ing a decryptable reply with 495.

Possible fix
-        let chat_is_bot = info.source.chat.server == wacore_binary::Server::Bot;
-        if !chat_is_bot && !msg.mentions_any_bot() {
+        let chat_is_bot = info.source.chat.server == wacore_binary::Server::Bot;
+        let sender_is_bot = info.source.sender.server == wacore_binary::Server::Bot;
+        if !chat_is_bot && !sender_is_bot && !msg.mentions_any_bot() {
             return;
         }
         if msg.is_forwarded() {
             return;
         }
-        let Some(sender) = self.dm_sender_identity_for(&info.source.chat).await else {
+        let sender = if !info.source.is_from_me && info.source.is_group && !sender_is_bot {
+            Some(info.source.sender.to_non_ad())
+        } else {
+            self.dm_sender_identity_for(&info.source.chat).await
+        };
+        let Some(sender) = sender else {
             return;
         };
         self.persist_outbound_msg_secret(&info.source.chat, &sender, &info.id, secret_arr)
             .await;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/message.rs` around lines 165 - 176, The secret is being cached under our
own DM sender identity (dm_sender_identity_for) which, combined with the early
skip for non-bot messages (msg.mentions_any_bot), causes later lookups
(get_msg_secret(chat, meta.target_sender_jid, target_id)) to miss replies
authored by the bot in group flows; change the persist path in message.rs so
persist_outbound_msg_secret stores the secret under the actual target sender JID
used by recipients (use meta.target_sender_jid / original message sender
identity) instead of the value returned by dm_sender_identity_for, and ensure
the store key aligns with get_msg_secret's lookup key; keep the existing guards
(mentions_any_bot/is_forwarded) but update the identity passed to
persist_outbound_msg_secret to match get_msg_secret.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/message.rs`:
- Around line 165-176: The secret is being cached under our own DM sender
identity (dm_sender_identity_for) which, combined with the early skip for
non-bot messages (msg.mentions_any_bot), causes later lookups
(get_msg_secret(chat, meta.target_sender_jid, target_id)) to miss replies
authored by the bot in group flows; change the persist path in message.rs so
persist_outbound_msg_secret stores the secret under the actual target sender JID
used by recipients (use meta.target_sender_jid / original message sender
identity) instead of the value returned by dm_sender_identity_for, and ensure
the store key aligns with get_msg_secret's lookup key; keep the existing guards
(mentions_any_bot/is_forwarded) but update the identity passed to
persist_outbound_msg_secret to match get_msg_secret.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e55c4b32-f049-4aad-9e75-eb938b376711

📥 Commits

Reviewing files that changed from the base of the PR and between b8dc241 and 2380433.

📒 Files selected for processing (2)
  • src/keepalive.rs
  • src/message.rs

The contents API only returns .content (base64) for files up to 1MB.
dev/bench/data.js grew past that (~1.3MB), so .content came back empty,
base64 -d produced an empty file, and bench-comment.py crashed on
json.loads. Use Accept: application/vnd.github.raw instead (works to
100MB) and guard load_baseline against empty input.
@github-actions

Copy link
Copy Markdown

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown

Actionable comments posted: 0

jlucaso1 added 2 commits May 28, 2026 15:44
Two WA Web group-bot divergences, both surfaced by review:

1. Secret capture identity (Codex P2 + outside-diff): the secret was
   always keyed under our own identity (dm_sender_identity_for). When
   ANOTHER group participant invokes the bot, their prompt carries the
   messageSecret but the bot reply's <meta target_sender_jid> echoes that
   participant — so the lookup missed and the reply nacked 495. Now key
   non-self captures under info.source.sender (the prompt author), keeping
   the self path on dm_sender_identity_for (bot DM echoes our LID). Mirrors
   WA Web's msgKey, which keys the cache by author/participant.

2. Receipt shape (review divergence): WA Web `sendAggregateReceipts` emits
   a bare `<ack class="message">` (sendBotInvokeResponseAcks) for a DELIVERY
   where the chat is not a bot but the author is — i.e. a bot reply inside a
   group. A 1:1 bot chat keeps the normal `<receipt>` (its `v` gate is
   false). ack_received_message now routes bot-authored non-bot-chat
   messages to the transport ack instead of a delivery receipt.

Tests:
- maybe_capture_inbound_msg_secret_keys_under_other_participant
- bot_reply_in_group_acks_with_bare_ack_not_receipt
- bot_dm_reply_keeps_delivery_receipt (regression: 1:1 bot keeps receipt)
The msmsg happy path was silent (only warns on failure). Add a debug line
when the bot messageSecret is cached and an info line on successful msmsg
decrypt, matching the existing "Successfully decrypted" style, so a
production run can confirm the full flow from the logs.
@github-actions

Copy link
Copy Markdown

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/message.rs`:
- Around line 199-203: The logs are currently emitting raw JIDs
(sender.to_non_ad_string()) which leaks identifiers; replace direct use of
sender.to_non_ad_string() in the log::debug! call (and the similar
decrypt-success info-level log around the decrypt path referenced at lines
407-411) with a redacted or hashed representation (e.g., compute hashed_sender =
hash_or_redact_jid(sender.to_non_ad_string()) via a new helper like
redact_jid/hash_jid) and log that instead; also update the decrypt-success log
site (where info.id and sender are used) to use the same redacted/hashed helper
(and consider lowering its level if it’s currently info) so raw JIDs are never
written to logs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 252cbf89-ca80-435b-b026-6667d05f27b3

📥 Commits

Reviewing files that changed from the base of the PR and between 4e81233 and 97c8ed7.

📒 Files selected for processing (3)
  • .github/scripts/bench-comment.py
  • .github/workflows/benchmark-comment.yml
  • src/message.rs

Comment thread src/message.rs
Comment on lines +199 to +203
log::debug!(
"[msg:{}] cached bot messageSecret under sender={}",
info.id,
sender.to_non_ad_string()
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don’t ship raw JIDs into these logs.

This leaks full sender identifiers on the inbound msmsg path. The capture log can record another participant’s PN/LID, and the decrypt-success log does it at info level on every hit. Redact or hash the JID before logging.

Also applies to: 407-411

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/message.rs` around lines 199 - 203, The logs are currently emitting raw
JIDs (sender.to_non_ad_string()) which leaks identifiers; replace direct use of
sender.to_non_ad_string() in the log::debug! call (and the similar
decrypt-success info-level log around the decrypt path referenced at lines
407-411) with a redacted or hashed representation (e.g., compute hashed_sender =
hash_or_redact_jid(sender.to_non_ad_string()) via a new helper like
redact_jid/hash_jid) and log that instead; also update the decrypt-success log
site (where info.id and sender are used) to use the same redacted/hashed helper
(and consider lowering its level if it’s currently info) so raw JIDs are never
written to logs.

@github-actions

Copy link
Copy Markdown

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

1 similar comment
@github-actions

Copy link
Copy Markdown

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

jlucaso1 added 2 commits May 28, 2026 16:16
A group bot invocation initiated by the PRIMARY device carries its
messageSecret only in the bot-addressed copy the primary sends directly
to the bot; that copy is not mirrored to companions (we only receive the
group skmsg, which lacks the secret). So a companion legitimately never
holds the secret for such replies — the lookup miss is expected and we
nack 495 (confirmed in prod: no replay loop, no disconnect). Log it at
debug for group chats; keep warn for a 1:1 bot chat where a miss is
genuinely unexpected.

Verified in production: DM with Meta AI decrypts end-to-end including the
streaming edit chain; group-from-phone gracefully nacks without any
stream:error or reconnect.
WA Web is itself a companion and DOES decrypt group bot replies, so the
secret must be reachable by companions — our group capture is missing it.
Add a temporary INFO log dumping has_secret / is_from_me / is_group /
chat_is_bot / mentions_bot / forwarded / mentioned_jids at the capture
gate so a prod repro reveals exactly which condition fails (likely the
bot mentioned as a LID, which is_bot() doesn't recognize). To be reverted
once the root cause is fixed.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

if let Err(e) = self.send_node(stanza_to_send).await {

P2 Badge Cache the msmsg secret before sending the stanza

When send_message targets a bot, this writes the encrypted stanza to the socket before persisting outbound_msg_secret a few lines later. A fast bot reply can be read and processed by the inbound loop during that await/scheduling window, so handle_msmsg_payload looks up the original target_id before the row exists and nacks 495 even though this send generated the correct secret. Persist the generated secret before exposing the stanza to the server, or otherwise make the write visible before replies can be handled.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/message.rs Outdated
Comment on lines +171 to +172
log::info!(
"[msg:{}] msmsg-capture gate: has_secret={} secret_len={} is_from_me={} is_group={} chat={} sender={} chat_is_bot={} mentions_bot={} forwarded={} mentioned_jids={:?}",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove info-level PII capture diagnostics

In production this path runs for every successfully parsed message before the secret/mention gates return, and the info log includes real chat JIDs, sender JIDs, message ids, and mentioned JIDs. That makes ordinary message traffic and group membership data land in default application logs even when no msmsg secret is captured; this temporary diagnostic should be removed or dropped to a gated trace/debug log with redaction.

Useful? React with 👍 / 👎.

Prod diagnostic on a group @metaai prompt showed: has_secret=true,
mentions_bot=false, mentioned_jids=[] — our own group bot prompt carries
the messageSecret but no mention, so the (P||N) gate dropped it and the
reply nacked 495. WA Web caches it via the `w`/`A` group-participant gates.

- Add `message_context_info.bot_metadata` (the bot-invocation envelope WA
  Web reads) as a capture trigger, covering bot prompts with no mentioned
  JID. Gate is now P || N || bot_metadata.
- Key the secret by chat type, not is_from_me: bot DM resolves to our LID
  (sender there is our PN device JID); group/regular use info.source.sender,
  which already equals the addressing identity the reply echoes in
  <meta target_sender_jid> (our LID in a LID group; the other participant's
  JID for their prompt). Removes reliance on the alternate lookup for the
  common group path.

Diagnostic gate log still in place (now includes has_bot_metadata) to
confirm the next prod run caches + decrypts the group reply.

Tests:
- maybe_capture_inbound_msg_secret_via_bot_metadata_without_mention

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f4a744daf7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/send.rs
return Err(e.into());
}

if let Some(secret) = outbound_msg_secret.as_ref() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist secrets for bot-invoke wrappers

When the outbound bot prompt is encoded as a bot_invoke_message wrapper (protobuf tag 67, which the send path already unwraps for stanza typing), prepare_dm_stanza/prepare_group_stanza return message_secret: None because wacore/src/reporting_token.rs's REPORTING_FIELDS does not include field 67. This new guard then skips persistence entirely, so the ciphertext contains no cached secret for a subsequent <enc type="msmsg"> reply referencing that prompt and the inbound lookup will nack with MissingMessageSecret; add the bot-invoke future-proof field to reporting-token extraction or otherwise ensure these bot prompts still produce and persist a messageSecret.

Useful? React with 👍 / 👎.

@github-actions

Copy link
Copy Markdown

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

Prod confirmed the group bot flow works end-to-end: our own prompt caches
via bot_metadata (keyed under our LID), another participant's prompt caches
via mention, and all bot replies decrypt — 12 successes, 0 nack 495, 0
stream errors. Remove the temporary capture-gate INFO log; keep the concise
"cached bot messageSecret" debug and "Successfully decrypted msmsg" info.
@jlucaso1
jlucaso1 merged commit f7a8afb into main May 28, 2026
9 of 10 checks passed
@jlucaso1
jlucaso1 deleted the feat/msmsg-meta-ai-bot branch May 28, 2026 20:42
@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown

Actionable comments posted: 0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant