diff --git a/src/features/newsletter.rs b/src/features/newsletter.rs index eec257668..47b806bf0 100644 --- a/src/features/newsletter.rs +++ b/src/features/newsletter.rs @@ -14,6 +14,7 @@ use wacore::iq::mex_ids::newsletter as newsletter_docs; use wacore::iq::newsletter::NEWSLETTER_XMLNS; use wacore::request::InfoQuery; use wacore_binary::Jid; +use wacore_binary::JidExt as _; use wacore_binary::builder::NodeBuilder; use wacore_binary::{NodeContent, NodeContentRef, NodeRef}; use waproto::whatsapp as wa; @@ -94,6 +95,9 @@ pub struct NewsletterReactionCount { /// A message from a newsletter's history. #[derive(Debug, Clone)] pub struct NewsletterMessage { + /// Wire message id (the stanza `id`). This is what edit_message / revoke_message + /// key on (NOT `server_id`). Empty if the server omitted it. + pub message_id: String, /// Server-assigned message ID (monotonic, used for pagination cursors). pub server_id: u64, /// Message timestamp (Unix seconds). @@ -380,6 +384,65 @@ impl<'a> Newsletter<'a> { .await } + /// Edit a message in a newsletter (channel). Channels are plaintext (not E2E). + /// + /// `message_id` is the target message's id (the `message_id` from + /// [`NewsletterMessage`] / the id returned when it was sent), NOT its + /// `server_id` (edit/revoke key on the message id, unlike reactions which use + /// `server_id`). `new_content` is the replacement body (e.g. + /// `wa::Message { conversation: Some(..), .. }`). + pub async fn edit_message( + &self, + jid: &Jid, + message_id: impl Into, + new_content: wa::Message, + ) -> Result<(), anyhow::Error> { + if !jid.is_newsletter() { + return Err(anyhow::anyhow!( + "edit_message is only valid for newsletter (channel) JIDs; use Client::edit_message for DM/group" + )); + } + let id = message_id.into(); + if id.is_empty() { + return Err(anyhow::anyhow!( + "newsletter edit needs a target message_id (NewsletterMessage.message_id is empty when the server omits the id)" + )); + } + let node = crate::send::build_newsletter_edit_node( + jid, + &id, + crate::send::NewsletterEdit::Edit(&new_content), + ); + self.client.send_node(node).await?; + Ok(()) + } + + /// Revoke (delete) a message in a newsletter (channel). + /// + /// `message_id` is the target message's id (the `message_id` from + /// [`NewsletterMessage`]), NOT its `server_id`. + pub async fn revoke_message( + &self, + jid: &Jid, + message_id: impl Into, + ) -> Result<(), anyhow::Error> { + if !jid.is_newsletter() { + return Err(anyhow::anyhow!( + "revoke_message is only valid for newsletter (channel) JIDs; use Client::revoke_message for DM/group" + )); + } + let id = message_id.into(); + if id.is_empty() { + return Err(anyhow::anyhow!( + "newsletter revoke needs a target message_id (NewsletterMessage.message_id is empty when the server omits the id)" + )); + } + let node = + crate::send::build_newsletter_edit_node(jid, &id, crate::send::NewsletterEdit::Revoke); + self.client.send_node(node).await?; + Ok(()) + } + /// Fetch message history from a newsletter. /// /// Returns up to `count` messages. Use `before` with a `server_id` from a previous @@ -549,6 +612,13 @@ fn parse_newsletter_messages_response( continue; }; + // The wire `id` (string) is what edit/revoke key on; keep it alongside + // server_id (which is used for pagination/reactions). + let message_id = msg_node + .get_attr("id") + .map(|v| v.as_str().into_owned()) + .unwrap_or_default(); + let timestamp = msg_node .get_attr("t") .map(|v| v.as_str()) @@ -577,6 +647,7 @@ fn parse_newsletter_messages_response( let reactions = parse_reaction_counts(msg_node); result.push(NewsletterMessage { + message_id, server_id, timestamp, message_type, diff --git a/src/send.rs b/src/send.rs index 714a4fbc9..3c783175e 100644 --- a/src/send.rs +++ b/src/send.rs @@ -302,6 +302,49 @@ fn build_revoke_message( } } +/// A newsletter (channel) admin op on an existing message: edit (with the +/// replacement body) or revoke. Keeping content tied to the variant makes the +/// invalid edit-without-body / revoke-with-body states unrepresentable. +pub(crate) enum NewsletterEdit<'a> { + Edit(&'a wa::Message), + Revoke, +} + +/// Build a newsletter (channel) plaintext edit/revoke stanza. The target is keyed +/// by `message_id` (the original message's stanza id string, the wire `id`), NOT +/// by `server_id`: WA Web (mergeNewsletterClientIDMixin -> `id`) and whatsmeow +/// (sendNewsletter, req.ID = protocolMessage.key.id) both reference edit/revoke by +/// the message id and emit no `server_id` (that attr is reaction-only). +pub(crate) fn build_newsletter_edit_node( + to: &Jid, + message_id: &str, + op: NewsletterEdit<'_>, +) -> Node { + use crate::types::message::EditAttribute; + use prost::Message as _; + let mut plaintext = NodeBuilder::new("plaintext"); + let (edit, stanza_type, body) = match op { + NewsletterEdit::Edit(m) => { + if let Some(mt) = wacore::send::media_type_from_message(m) { + plaintext = plaintext.attr("mediatype", mt); + } + ( + EditAttribute::AdminEdit, + wacore::send::stanza_type_from_message(m), + m.encode_to_vec(), + ) + } + NewsletterEdit::Revoke => (EditAttribute::AdminRevoke, "text", Vec::new()), + }; + NodeBuilder::new("message") + .attr("to", to) + .attr("id", message_id) + .attr("type", stanza_type) + .attr("edit", edit.to_string_val()) + .children([plaintext.bytes(body).build()]) + .build() +} + /// Build a message edit in WA Web's wire shape: a top-level /// protocolMessage(type=MESSAGE_EDIT) carrying the new content under /// editedMessage, same as build_revoke_message and our own receive path. The @@ -1064,6 +1107,18 @@ impl Client { extra_stanza_nodes: Vec, stanza_type_override: Option, ) -> Result<(), anyhow::Error> { + // Newsletters are plaintext channels and never use the E2E path. Text + // sends go through the branch in send_message_with_options; + // edit/revoke have dedicated plaintext methods (newsletter().edit_message + // / revoke_message). A newsletter JID here is a mis-routed pin/edit/revoke + // (pin is not a channel op), so reject it. + if to.is_newsletter() { + return Err(anyhow!( + "newsletter JIDs are not valid on the E2E send path; use \ + newsletter().edit_message/revoke_message (pin is unsupported on channels)" + )); + } + // status@broadcast reactions fan out pairwise to the author's devices; // status posts keep going through send_status_message (owns recipients). let (to, is_status_addon) = if to.is_status_broadcast() { @@ -3840,6 +3895,220 @@ mod tests { ); } + /// Newsletter JIDs must be rejected at the E2E send path root (covers the + /// mis-routed pin/edit/revoke producers that call send_message_impl directly). + #[tokio::test] + async fn newsletter_jid_rejected_on_e2e_send_path() { + let client = crate::test_utils::create_test_client_with_name("newsletter_e2e_guard").await; + let channel: Jid = "120363000000000001@newsletter".parse().unwrap(); + let msg = wa::Message { + conversation: Some("x".to_string()), + ..Default::default() + }; + let err = client + .send_message_impl(channel, &msg, None, false, false, None, vec![], None) + .await + .expect_err("newsletter JID must be rejected on the E2E send path"); + assert!( + err.to_string().to_lowercase().contains("newsletter"), + "error should name the newsletter mis-route, got: {err}" + ); + } + + /// The pin producer routes through send_message_impl, so a newsletter pin is + /// rejected rather than building an encrypted fanout against a channel. + #[tokio::test] + async fn pin_message_rejects_newsletter() { + let client = crate::test_utils::create_test_client_with_name("newsletter_pin_guard").await; + let channel: Jid = "120363000000000002@newsletter".parse().unwrap(); + let key = wa::MessageKey { + remote_jid: Some(channel.to_string()), + from_me: Some(true), + id: Some("MID".to_string()), + participant: None, + }; + let err = client + .pin_message(channel, key, PinDuration::Days7) + .await + .expect_err("pinning a newsletter message must be rejected"); + assert!( + err.to_string().to_lowercase().contains("newsletter"), + "error should name the newsletter mis-route, got: {err}" + ); + } + + /// Newsletter edit: plaintext `<message edit="3">` keyed by server_id, with the + /// new content in `<plaintext>`. Keyed by the message id STRING (not server_id), + /// and a text edit carries no mediatype. + #[test] + fn build_newsletter_edit_node_emits_plaintext_edit() { + use prost::Message as _; + let to: Jid = "120363000000000001@newsletter".parse().unwrap(); + let content = wa::Message { + conversation: Some("edited text".to_string()), + ..Default::default() + }; + let node = + build_newsletter_edit_node(&to, "3EB0EDITTARGET", NewsletterEdit::Edit(&content)); + + let mut a = node.attrs(); + assert_eq!(a.optional_string("id").unwrap().as_ref(), "3EB0EDITTARGET"); + assert_eq!(a.optional_string("type").unwrap().as_ref(), "text"); + assert_eq!(a.optional_string("edit").unwrap().as_ref(), "3"); + + let pt = node + .get_optional_child("plaintext") + .expect("plaintext child"); + assert!( + pt.attrs().optional_string("mediatype").is_none(), + "a text edit must not carry a mediatype attr" + ); + let bytes = match pt.content.as_ref() { + Some(wacore_binary::NodeContent::Bytes(b)) => b.clone(), + other => panic!("expected plaintext bytes, got {other:?}"), + }; + let decoded = wa::Message::decode(bytes.as_slice()).expect("decode plaintext"); + assert_eq!(decoded.conversation.as_deref(), Some("edited text")); + } + + /// Media newsletter edit: type="media" + `<plaintext mediatype="image">`. + #[test] + fn build_newsletter_edit_node_media_edit() { + let to: Jid = "120363000000000001@newsletter".parse().unwrap(); + let content = wa::Message { + image_message: Some(Box::new(wa::message::ImageMessage { + caption: Some("new caption".to_string()), + ..Default::default() + })), + ..Default::default() + }; + let node = build_newsletter_edit_node(&to, "3EB0MEDIA", NewsletterEdit::Edit(&content)); + + let mut a = node.attrs(); + assert_eq!(a.optional_string("id").unwrap().as_ref(), "3EB0MEDIA"); + assert_eq!(a.optional_string("type").unwrap().as_ref(), "media"); + assert_eq!(a.optional_string("edit").unwrap().as_ref(), "3"); + let pt = node + .get_optional_child("plaintext") + .expect("plaintext child"); + assert_eq!( + pt.attrs().optional_string("mediatype").unwrap().as_ref(), + "image" + ); + } + + /// Newsletter revoke: plaintext `<message type="text" edit="8">` keyed by the + /// message id STRING, with an empty `<plaintext>`. + #[test] + fn build_newsletter_edit_node_revoke_is_empty_plaintext() { + let to: Jid = "120363000000000002@newsletter".parse().unwrap(); + let node = build_newsletter_edit_node(&to, "3EB0REVOKETARGET", NewsletterEdit::Revoke); + + let mut a = node.attrs(); + assert_eq!( + a.optional_string("id").unwrap().as_ref(), + "3EB0REVOKETARGET" + ); + assert_eq!(a.optional_string("type").unwrap().as_ref(), "text"); + assert_eq!(a.optional_string("edit").unwrap().as_ref(), "8"); + + let pt = node + .get_optional_child("plaintext") + .expect("plaintext child"); + let empty = match pt.content.as_ref() { + None => true, + Some(wacore_binary::NodeContent::Bytes(b)) => b.is_empty(), + _ => false, + }; + assert!(empty, "revoke must carry an empty plaintext"); + } + + /// The public newsletter().edit_message wrapper emits the plaintext edit stanza + /// keyed by the message id it was given. + #[tokio::test] + async fn newsletter_edit_message_wrapper_sends_plaintext_edit() { + let client = crate::test_utils::create_test_client_with_name("nl_edit_wrap").await; + let channel: Jid = "120363000000000001@newsletter".parse().unwrap(); + let waiter = + client.wait_for_sent_node(crate::client::NodeFilter::tag("message").attr("edit", "3")); + let content = wa::Message { + conversation: Some("edited".to_string()), + ..Default::default() + }; + // No socket on the test client: send_node captures the node, then errors. + let _ = client + .newsletter() + .edit_message(&channel, "TARGETMID", content) + .await; + + let node = tokio::time::timeout(std::time::Duration::from_secs(1), waiter) + .await + .expect("sent node captured") + .expect("waiter resolves"); + let mut a = node.attrs(); + assert_eq!(a.optional_string("id").unwrap().as_ref(), "TARGETMID"); + assert_eq!(a.optional_string("edit").unwrap().as_ref(), "3"); + } + + /// The newsletter edit/revoke methods reject non-newsletter JIDs, so a misuse + /// cannot send plaintext content to a DM/group (it would not be E2E-encrypted). + #[tokio::test] + async fn newsletter_edit_revoke_reject_non_newsletter_jid() { + let client = crate::test_utils::create_test_client_with_name("nl_reject_nonchannel").await; + let dm: Jid = "5511999999999@s.whatsapp.net".parse().unwrap(); + let group: Jid = "120363000000000009@g.us".parse().unwrap(); + + let e1 = client + .newsletter() + .edit_message( + &dm, + "MID", + wa::Message { + conversation: Some("x".to_string()), + ..Default::default() + }, + ) + .await + .expect_err("edit_message must reject a DM JID"); + assert!(e1.to_string().to_lowercase().contains("newsletter")); + + let e2 = client + .newsletter() + .revoke_message(&group, "MID") + .await + .expect_err("revoke_message must reject a group JID"); + assert!(e2.to_string().to_lowercase().contains("newsletter")); + } + + /// An empty message_id (NewsletterMessage.message_id may be empty if the server + /// omitted the id) is rejected rather than sending a target-less id="" stanza. + #[tokio::test] + async fn newsletter_edit_revoke_reject_empty_message_id() { + let client = crate::test_utils::create_test_client_with_name("nl_reject_empty_id").await; + let channel: Jid = "120363000000000001@newsletter".parse().unwrap(); + + let e1 = client + .newsletter() + .edit_message( + &channel, + "", + wa::Message { + conversation: Some("x".to_string()), + ..Default::default() + }, + ) + .await + .expect_err("edit_message must reject an empty message_id"); + assert!(e1.to_string().to_lowercase().contains("message_id")); + + let e2 = client + .newsletter() + .revoke_message(&channel, "") + .await + .expect_err("revoke_message must reject an empty message_id"); + assert!(e2.to_string().to_lowercase().contains("message_id")); + } + #[tokio::test] async fn persist_outbound_msg_secret_writes_under_chat_sender_id() { let client = crate::test_utils::create_test_client_with_name("secret_chat_id").await;