From 1e64c4a89287ad314fb137d1e59cafad9bb77ba9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <55464917+jlucaso1@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:47:13 -0300 Subject: [PATCH 1/3] docs: encrypted CAG reactions and channel comments (PR #830) --- .../2026-06-10-cag-enc-reactions-comments.mdx | 93 ++++++++++++++++ docs.json | 1 + guides/communities.mdx | 103 +++++++++++++++++- guides/receiving-messages.mdx | 40 ++++++- guides/sending-messages.mdx | 75 ++++++++++++- 5 files changed, 304 insertions(+), 8 deletions(-) create mode 100644 changelog/2026-06-10-cag-enc-reactions-comments.mdx diff --git a/changelog/2026-06-10-cag-enc-reactions-comments.mdx b/changelog/2026-06-10-cag-enc-reactions-comments.mdx new file mode 100644 index 00000000..783b49c2 --- /dev/null +++ b/changelog/2026-06-10-cag-enc-reactions-comments.mdx @@ -0,0 +1,93 @@ +--- +title: "June 10, 2026 — Encrypted CAG reactions and channel comments" +description: "Reactions to Community Announcement Group posts are now encrypted automatically. A new comments() API lets you send and receive encrypted threaded replies on CAG channel posts." +--- + +## New features + +**Encrypted reactions in Community Announcement Groups** + +`Client::send_reaction` now transparently detects Community Announcement Groups (CAG — the default announcement subgroup of a community) and encrypts the reaction before sending. No API change is needed; the same call works for DMs, regular groups, and CAGs: + +```rust +// Works identically for DMs, regular groups, and CAGs. +client.send_reaction(&cag_jid, target_key, "🔥").await?; +``` + +For a CAG chat the library checks `GroupInfo::is_community_announce` (cached from group metadata). When true, the reaction emoji and timestamp are encrypted with the target post's `messageSecret` under the `"Enc Reaction"` HKDF use-case and shipped as an `enc_reaction_message` envelope — matching WA Web's `WAWebReactionEncryptMsgData` flow. If the parent secret was not captured (message received before session started, or `msg_secret_policy` disabled without a resolver) the call fails with a descriptive error rather than silently emitting a plaintext reaction the channel would reject. + +Incoming encrypted reactions are decrypted transparently by the receive path and dispatched in the same plaintext `reaction_message` shape as an ordinary group reaction. The `key` field is filled from the envelope's `target_message_key` so event handlers require no changes. + +--- + +**Channel comments via `client.comments()`** + +A new `Comments` feature handle lets you post encrypted threaded replies under a CAG post. Comments require the parent post's `messageSecret` (captured during receive) and are authored under the LID identity, matching WA Web's `getMeLidUserOrThrow`: + +```rust +let parent_key = wa::MessageKey { + remote_jid: Some(cag_jid.to_string()), + from_me: Some(false), + id: Some("3EB0POSTID".to_string()), + // Must be the post author so receivers can derive the HKDF key. + participant: Some(post_author_jid.to_string()), +}; + +// Text comment (extended_text_message body) +let result = client.comments() + .send_text(&cag_jid, parent_key.clone(), "Great post!") + .await?; + +// Arbitrary message body +let body = wa::Message { + extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + text: Some("Great post!".to_string()), + ..Default::default() + })), + ..Default::default() +}; +let result = client.comments() + .send_message(&cag_jid, parent_key, body) + .await?; +``` + +The comment carries a fresh `messageSecret` of its own so it can receive encrypted reactions. Both the comment body secret and the parent-post secret are persisted under the correct ids. + +Incoming encrypted comments are decrypted transparently. The decrypted body is dispatched as a normal `Event::Message`. The parent post key surfaces on the new `MessageInfo::comment_target` field, since the inner `Message` proto has no slot for the threading link: + +```rust +Event::Message(msg, info) => { + if let Some(parent_key) = &info.comment_target { + println!("Comment on post: {:?}", parent_key.id); + if let Some(text) = msg.text_content() { + println!("Comment text: {}", text); + } + } +} +``` + +The comment's own `messageSecret` (from the outer envelope) is persisted under the comment's id and sender so that future encrypted reactions to the comment can be decrypted. + +## Breaking changes + +Pre-1.0, additive surface. The following structs and enums gain new fields/variants. If you construct them with exhaustive struct-literal syntax (without `..Default::default()`), add the new fields: + +**`MessageInfo`** gains `comment_target: Option`: + +```rust +// Add the field or use ..Default::default(): +let info = MessageInfo { + id: "...".to_string(), + // ... + peer_recipient_pn: None, + comment_target: None, // new + bcl_participants: vec![], +}; +``` + +**`GroupInfo`** gains `is_community_announce: Option`. Blobs persisted before this field deserialize with `None` ("unknown") and trigger one metadata re-query when the value is first needed. No migration is required — existing serialized blobs remain valid. + +**`SecretEncKind`** gains two variants that must be covered in exhaustive `match` arms: + +- `SecretEncKind::EncReaction` — an `enc_reaction_message` envelope +- `SecretEncKind::EncComment` — an `enc_comment_message` envelope diff --git a/docs.json b/docs.json index f06472b2..5b10c6c8 100644 --- a/docs.json +++ b/docs.json @@ -138,6 +138,7 @@ "group": "Changelog", "pages": [ "changelog/overview", + "changelog/2026-06-10-cag-enc-reactions-comments", "changelog/2026-06-10-hrtb-closures", "changelog/2026-06-10-server-aware-lookup-probe", "changelog/2026-06-10-phash-arena-sort", diff --git a/guides/communities.mdx b/guides/communities.mdx index 114d5aec..477044f8 100644 --- a/guides/communities.mdx +++ b/guides/communities.mdx @@ -1,13 +1,13 @@ --- title: Community management -description: Learn how to create communities, manage subgroups, and query community metadata in whatsapp-rust +description: Learn how to create communities, manage subgroups, query community metadata, send encrypted CAG reactions, and post channel comments in whatsapp-rust --- ## Overview Communities are parent groups that contain linked subgroups. They use the `w:g2` IQ namespace for mutations and MEX (GraphQL) for metadata queries. -This guide covers creating communities, linking and unlinking subgroups, querying subgroup metadata, and identifying group types within a community hierarchy. +This guide covers creating communities, linking and unlinking subgroups, querying subgroup metadata, identifying group types, sending encrypted reactions to Community Announcement Groups, and posting channel comments. ## Accessing the Community API @@ -224,6 +224,103 @@ The classification is based on these `GroupMetadata` fields: See [Groups API reference](/api/groups#get_metadata) for all `GroupMetadata` fields. +## Community Announcement Group (CAG) reactions + +The default announcement subgroup of a community — the one where `is_default_sub_group` is `true` — is a Community Announcement Group (CAG). CAGs require encrypted reactions; plaintext reactions are silently dropped by the server. + +`client.send_reaction()` handles this transparently. The same call works for DMs, regular groups, and CAGs with no change to your code: + +```rust +let target_key = wa::MessageKey { + remote_jid: Some(cag_jid.to_string()), + from_me: Some(false), + id: Some("3EB0POSTID".to_string()), + // participant must be the post author. + participant: Some(post_author_jid.to_string()), +}; + +client.send_reaction(&cag_jid, target_key, "🔥").await?; + +// Remove a reaction with an empty emoji: +client.send_reaction(&cag_jid, target_key, "").await?; +``` + +For CAG chats the library checks `GroupInfo::is_community_announce` (populated from metadata and cached). When `true`, the reaction is encrypted with the target post's `messageSecret` (captured when the post was received) and shipped as an `enc_reaction_message` envelope. If the parent secret is not available the call fails with a descriptive error rather than emitting a plaintext reaction the channel would drop. + +Incoming encrypted reactions from CAG posts are decrypted transparently by the receive path and surfaced as a normal `reaction_message` event. The `key` field is filled from the envelope's `target_message_key`, so your event handler looks identical to a regular group reaction: + +```rust +Event::Message(msg, info) => { + if let Some(reaction) = &msg.reaction_message { + println!("Reaction: {:?}", reaction.text); + println!("On post: {:?}", reaction.key.as_ref().and_then(|k| k.id.as_deref())); + } +} +``` + +## Channel comments + +Post encrypted threaded replies under a CAG post using `client.comments()`: + +```rust +use whatsapp_rust::features::Comments; + +let parent_key = wa::MessageKey { + remote_jid: Some(cag_jid.to_string()), + from_me: Some(false), + id: Some("3EB0POSTID".to_string()), + // participant must point to the post author so receivers can derive the + // decryption key from the envelope. The library resolves the author + // automatically when from_me is true and participant is omitted. + participant: Some(post_author_jid.to_string()), +}; + +// Send a text comment: +let result = client.comments() + .send_text(&cag_jid, parent_key.clone(), "Great post!") + .await?; +println!("Comment sent: {}", result.message_id); +``` + +For arbitrary message bodies, use `send_message`: + +```rust +let body = wa::Message { + extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + text: Some("Great post!".to_string()), + ..Default::default() + })), + ..Default::default() +}; + +let result = client.comments() + .send_message(&cag_jid, parent_key, body) + .await?; +``` + +Comments require the parent post's `messageSecret` to have been captured when the post was received (via `msg_secret_policy`). If it was not captured the call returns an error explaining that the secret is missing. + +Each comment carries a fresh `messageSecret` of its own so it can receive encrypted reactions. The comment's secret is persisted under the comment's own id and sender. + +### Receiving comments + +Incoming encrypted comments are decrypted transparently on the receive path. The decrypted body is dispatched as a normal `Event::Message`. Because the inner `Message` proto has no slot for the parent post key, the threading link surfaces on `MessageInfo::comment_target`: + +```rust +Event::Message(msg, info) => { + if let Some(parent_key) = &info.comment_target { + // This is a channel comment. + println!("Comment on post: {:?}", parent_key.id); + + if let Some(text) = msg.text_content() { + println!("Comment text: {}", text); + } + } +} +``` + +`comment_target` is `None` for all other message types. + ## Error handling Community mutations return `anyhow::Error`, while MEX-based queries (`get_subgroups`, `get_subgroup_participant_counts`) return `MexError`: @@ -244,5 +341,7 @@ match client.community().get_subgroups(&community_jid).await { - [Community API reference](/api/community) — Full API details and types - [Group management](/guides/group-management) — Manage individual groups +- [Sending messages](/guides/sending-messages#reactions) — Reactions API overview +- [Receiving messages](/guides/receiving-messages) — Handle message events including comments - [Events](/concepts/events) — Handle group update events - [MEX API](/api/mex) — Understand the GraphQL layer used by community queries diff --git a/guides/receiving-messages.mdx b/guides/receiving-messages.mdx index 47246355..fcb15e45 100644 --- a/guides/receiving-messages.mdx +++ b/guides/receiving-messages.mdx @@ -98,12 +98,19 @@ if let Some(expiration) = info.ephemeral_expiration { if let Some(request_id) = &info.unavailable_request_id { println!("Recovered via PDO request: {}", request_id); } + +// Check if this is a decrypted channel comment (CAG threaded reply) +if let Some(parent_key) = &info.comment_target { + println!("Comment on post: {:?}", parent_key.id); +} ``` The `ephemeral_expiration` field contains the disappearing messages timer in seconds, extracted from the message's `contextInfo.expiration`. This tells you how long the message will be visible before it auto-deletes. Use this value when sending replies to the same chat via [`SendOptions.ephemeral_expiration`](/api/send#sendoptions). The `unavailable_request_id` field is set when a message was recovered via PDO rather than normal decryption. It contains the PDO request message ID, which you can use to correlate recovered messages with the original `UndecryptableMessage` event. +The `comment_target` field is set when the dispatched message is a decrypted CAG channel comment. It contains the `MessageKey` of the parent post. The inner `Message` proto has no slot for the threading link, so it surfaces here instead. See [Channel comments](#channel-comments) below. + ### Message content extraction Use the `MessageExt` trait to extract content: @@ -217,12 +224,16 @@ See [Media Handling Guide](/guides/media-handling) for download details. ### Reactions +Incoming reactions — including encrypted CAG reactions — are dispatched in the same `reaction_message` shape. Encrypted reactions from Community Announcement Groups are decrypted transparently on the receive path; the `key` field is filled from the envelope's `target_message_key` before dispatch. + ```rust if let Some(reaction) = &message.reaction_message { if let Some(emoji) = &reaction.text { - println!("👍 Reaction: {}", emoji); - } else { - println!("Reaction removed"); + if emoji.is_empty() { + println!("Reaction removed"); + } else { + println!("👍 Reaction: {}", emoji); + } } if let Some(key) = &reaction.key { @@ -231,6 +242,26 @@ if let Some(reaction) = &message.reaction_message { } ``` +### Channel Comments + +Encrypted channel comments from Community Announcement Groups are decrypted transparently and dispatched as `Event::Message` carrying the comment body. The parent post key surfaces on `MessageInfo::comment_target` (the inner `Message` proto has no slot for the threading link): + +```rust +Event::Message(msg, info) => { + if let Some(parent_key) = &info.comment_target { + // This Event::Message is a decrypted CAG channel comment. + println!("Comment on post: {:?}", parent_key.id); + println!("Post author: {:?}", parent_key.participant); + + if let Some(text) = msg.text_content() { + println!("Comment text: {}", text); + } + } +} +``` + +The comment's own `messageSecret` (carried in the outer envelope) is persisted under the comment's id and sender, so that future encrypted reactions targeting the comment can be decrypted. `comment_target` is `None` for all other message types. + ### Quoted Messages ```rust @@ -685,6 +716,7 @@ Spawn tasks for long-running operations: ## Next Steps -- [Sending Messages](/guides/sending-messages) - Send text, reactions, and replies +- [Sending Messages](/guides/sending-messages) - Send text, reactions, channel comments, and replies - [Media Handling](/guides/media-handling) - Download and process media - [Group Management](/guides/group-management) - Handle group events +- [Community management](/guides/communities) - CAG reactions and channel comments diff --git a/guides/sending-messages.mdx b/guides/sending-messages.mdx index aaa72872..2360d2e5 100644 --- a/guides/sending-messages.mdx +++ b/guides/sending-messages.mdx @@ -1,11 +1,11 @@ --- title: Sending Messages -description: Learn how to send text messages, reactions, quoted replies, album messages, sticker packs, and edit messages in whatsapp-rust +description: Learn how to send text messages, reactions, channel comments, quoted replies, album messages, sticker packs, and edit messages in whatsapp-rust --- ## Overview -This guide covers sending messages, including text, reactions, quotes, album messages (grouped media), sticker packs, and message editing operations using the whatsapp-rust library. +This guide covers sending messages, including text, reactions, channel comments, quotes, album messages (grouped media), sticker packs, and message editing operations using the whatsapp-rust library. ## Sending text messages @@ -193,6 +193,19 @@ if let Some(ctx) = MessageContext::from_event(&event, client) { } ``` +### Reactions in Community Announcement Groups + +Community Announcement Groups (CAGs) — the default announcement subgroup of a community — require encrypted reactions. `send_reaction` handles this transparently: it detects CAG chats automatically and sends the reaction as an encrypted `enc_reaction_message` envelope instead of a plaintext stanza. + +No change to your call is needed. The only requirement is that the target post's `messageSecret` was captured when the post was received. If it was not captured (for example, `msg_secret_policy` is disabled without a resolver, or the post arrived before the current session), the call returns an error rather than emitting a plaintext reaction the channel would reject. + +```rust +// Works for DMs, regular groups, and CAGs — no API difference. +client.send_reaction(&cag_jid, target_key, "🔥").await?; +``` + +See [Community management — CAG reactions](/guides/communities#community-announcement-group-cag-reactions) for details. + ### Removing a reaction Pass an empty `emoji` to revoke a previous reaction (matches WhatsApp Web's empty-text-as-revoke semantics): @@ -205,6 +218,61 @@ client.send_reaction(&chat_jid, target_key, "").await?; Newsletter (channel) reactions use a different plaintext stanza format. Use [`client.newsletter().send_reaction()`](/api/newsletter#send_reaction) for newsletters instead. +## Channel Comments + +Channel comments are encrypted threaded replies under a Community Announcement Group (CAG) post. Use `client.comments()` to send them: + +```rust +use whatsapp_rust::features::Comments; + +let parent_key = wa::MessageKey { + remote_jid: Some(cag_jid.to_string()), + from_me: Some(false), + id: Some("3EB0POSTID".to_string()), + // participant must be the post author. + participant: Some(post_author_jid.to_string()), +}; + +// Text comment: +let result = client.comments() + .send_text(&cag_jid, parent_key, "Great post!") + .await?; +println!("Comment sent: {}", result.message_id); +``` + +For arbitrary message bodies: + +```rust +let body = wa::Message { + extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + text: Some("Great post!".to_string()), + ..Default::default() + })), + ..Default::default() +}; + +let result = client.comments() + .send_message(&cag_jid, parent_key, body) + .await?; +``` + +The `parent_key.participant` field must identify the post author so receivers can derive the HKDF decryption key from the envelope. When `from_me` is `true` and `participant` is absent the library resolves the author to your own identity. + +Incoming encrypted comments are decrypted transparently. The comment body is dispatched as `Event::Message` and the parent post key is available on `MessageInfo::comment_target`: + +```rust +Event::Message(msg, info) => { + if let Some(parent_key) = &info.comment_target { + println!("Comment on post: {:?}", parent_key.id); + if let Some(text) = msg.text_content() { + println!("Text: {}", text); + } + } +} +``` + +See [Community management — Channel comments](/guides/communities#channel-comments) for full details. + ## Editing messages Use [`client.edit_message`](/api/send#edit_message) to replace the content of a message you previously sent. Pass the chat JID, the original message ID, and the new content as a plain `wa::Message` — the client builds the correct wire envelope, resolves the participant JID (LID or PN) for groups, and sends the edit with a fresh stanza ID so the server does not deduplicate it against the original. @@ -798,6 +866,8 @@ async fn send_safe_message( - **Albums**: Use `album_message` parent + `wrap_as_album_child` for grouped media - **Sticker packs**: Use `create_sticker_pack_zip` + `build_sticker_pack_message` with two uploads (ZIP + thumbnail) - **Disappearing chats**: Use `send_message_with_options` with `ephemeral_expiration` matching the chat's timer +- **CAG reactions**: Use `send_reaction` — encryption is applied automatically for CAG chats +- **Channel comments**: Use `client.comments().send_text()` or `send_message()` ### Handle message IDs @@ -826,3 +896,4 @@ let key = result.message_key(); - [Media Handling](/guides/media-handling) - Upload and download media - [Polls](/guides/polls) - Create polls and process votes - [Group Management](/guides/group-management) - Work with group chats +- [Community management](/guides/communities) - CAG reactions and channel comments From b419ff5cde3f39d20846fa1920e5487e46ebd3b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <55464917+jlucaso1@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:51:22 -0300 Subject: [PATCH 2/3] fix: clone target_key before reuse in CAG reaction example --- guides/communities.mdx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/guides/communities.mdx b/guides/communities.mdx index 477044f8..23c3e9b9 100644 --- a/guides/communities.mdx +++ b/guides/communities.mdx @@ -239,7 +239,8 @@ let target_key = wa::MessageKey { participant: Some(post_author_jid.to_string()), }; -client.send_reaction(&cag_jid, target_key, "🔥").await?; +// send_reaction takes target_key by value; clone it to reuse for removal. +client.send_reaction(&cag_jid, target_key.clone(), "🔥").await?; // Remove a reaction with an empty emoji: client.send_reaction(&cag_jid, target_key, "").await?; From 4944e4f07c07c1cc30b6d4709b2ed9f18c8d3407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <55464917+jlucaso1@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:53:50 -0300 Subject: [PATCH 3/3] fix: clarify verb tense in GroupInfo changelog sentence --- changelog/2026-06-10-cag-enc-reactions-comments.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/2026-06-10-cag-enc-reactions-comments.mdx b/changelog/2026-06-10-cag-enc-reactions-comments.mdx index 783b49c2..f3f83450 100644 --- a/changelog/2026-06-10-cag-enc-reactions-comments.mdx +++ b/changelog/2026-06-10-cag-enc-reactions-comments.mdx @@ -85,7 +85,7 @@ let info = MessageInfo { }; ``` -**`GroupInfo`** gains `is_community_announce: Option`. Blobs persisted before this field deserialize with `None` ("unknown") and trigger one metadata re-query when the value is first needed. No migration is required — existing serialized blobs remain valid. +**`GroupInfo`** gains `is_community_announce: Option`. Blobs persisted before this field was added will deserialize with `None` ("unknown") and trigger one metadata re-query when the value is first needed. No migration is required — existing serialized blobs remain valid. **`SecretEncKind`** gains two variants that must be covered in exhaustive `match` arms: