Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions src/features/newsletter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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<String>,
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<String>,
) -> 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(())
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Fetch message history from a newsletter.
///
/// Returns up to `count` messages. Use `before` with a `server_id` from a previous
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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,
Expand Down
269 changes: 269 additions & 0 deletions src/send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
jlucaso1 marked this conversation as resolved.
.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
Expand Down Expand Up @@ -1064,6 +1107,18 @@ impl Client {
extra_stanza_nodes: Vec<Node>,
stanza_type_override: Option<StanzaType>,
) -> Result<(), anyhow::Error> {
// Newsletters are plaintext channels and never use the E2E path. Text
// sends go through the <plaintext> 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() {
Expand Down Expand Up @@ -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;
Expand Down
Loading