use wacore::WireEnum;
use crate::client::{Client, ClientError};
use crate::features::mex::{MexError, mex_request};
use crate::request::IqError;
use thiserror::Error;
use wacore::iq::mex_operations::{
create_newsletter, fetch_all_newsletters_metadata, fetch_newsletter, join_newsletter,
leave_newsletter, update_newsletter, update_newsletter_user_setting,
};
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;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum NewsletterError {
#[error("{0}")]
Mex(#[from] MexError),
#[error("{0}")]
Iq(#[from] IqError),
#[error("{0}")]
Client(#[from] ClientError),
#[error("invalid newsletter request: {0}")]
InvalidRequest(String),
#[error("{0}")]
Internal(#[from] anyhow::Error),
}
impl NewsletterError {
pub(crate) fn from_anyhow(err: anyhow::Error) -> Self {
match err.downcast::<ClientError>() {
Ok(ClientError::Iq(iq)) => NewsletterError::Iq(iq),
Ok(client) => NewsletterError::Client(client),
Err(other) => match other.downcast::<IqError>() {
Ok(iq) => NewsletterError::Iq(iq),
Err(other) => NewsletterError::Internal(other),
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, WireEnum)]
#[non_exhaustive]
pub enum NewsletterMessageType {
#[wire = "text"]
Text,
#[wire = "media"]
Media,
#[wire = "reaction"]
Reaction,
#[wire = "revoke"]
Revoke,
#[wire = "poll_creation"]
PollCreation,
#[wire = "poll_vote"]
PollVote,
#[wire = "edit"]
Edit,
#[wire_fallback]
Other(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum NewsletterVerification {
Verified,
Unverified,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum NewsletterState {
Active,
Suspended,
Geosuspended,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum NewsletterRole {
Owner,
Admin,
Subscriber,
Guest,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewsletterMetadata {
pub jid: Jid,
pub name: String,
pub description: Option<String>,
pub subscriber_count: u64,
pub verification: NewsletterVerification,
pub state: NewsletterState,
pub picture_url: Option<String>,
pub preview_url: Option<String>,
pub invite_code: Option<String>,
pub role: Option<NewsletterRole>,
pub creation_time: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct NewsletterReactionCount {
pub code: String,
pub count: u64,
}
#[derive(Debug, Clone)]
pub struct NewsletterMessage {
pub message_id: String,
pub server_id: u64,
pub timestamp: u64,
pub message_type: NewsletterMessageType,
pub is_sender: bool,
pub message: Option<wa::Message>,
pub reactions: Vec<NewsletterReactionCount>,
}
pub struct Newsletter<'a> {
client: &'a Client,
}
impl<'a> Newsletter<'a> {
pub(crate) fn new(client: &'a Client) -> Self {
Self { client }
}
pub async fn list_subscribed(&self) -> Result<Vec<NewsletterMetadata>, NewsletterError> {
let response = self
.client
.mex()
.query(mex_request!(fetch_all_newsletters_metadata {
..Default::default()
}))
.await?;
let data = response
.data
.ok_or_else(|| NewsletterError::InvalidRequest("missing data".into()))?;
let newsletters = data["xwa2_newsletter_subscribed"]
.as_array()
.ok_or_else(|| {
NewsletterError::InvalidRequest("missing xwa2_newsletter_subscribed array".into())
})?;
newsletters.iter().map(parse_newsletter_metadata).collect()
}
pub async fn get_metadata(&self, jid: &Jid) -> Result<NewsletterMetadata, NewsletterError> {
let response = self
.client
.mex()
.query(mex_request!(fetch_newsletter {
input: Some(fetch_newsletter::Input {
key: Some(jid.to_string()),
r#type: Some("JID".into()),
view_role: Some("GUEST".into()),
}),
fetch_viewer_metadata: Some(true),
fetch_full_image: Some(true),
fetch_creation_time: Some(true),
..Default::default()
}))
.await?;
let data = response
.data
.ok_or_else(|| NewsletterError::InvalidRequest("missing data".into()))?;
let newsletter = &data["xwa2_newsletter"];
if newsletter.is_null() {
return Err(NewsletterError::InvalidRequest(format!(
"newsletter not found: {}",
jid
)));
}
parse_newsletter_metadata(newsletter)
}
pub async fn create(
&self,
name: &str,
description: Option<&str>,
) -> Result<NewsletterMetadata, NewsletterError> {
let response = self
.client
.mex()
.mutate(mex_request!(create_newsletter {
input: Some(create_newsletter::Input {
name: Some(name.to_string()),
description: description.map(str::to_string),
picture: None,
}),
}))
.await?;
let data = response
.data
.ok_or_else(|| NewsletterError::InvalidRequest("missing data".into()))?;
let newsletter = &data["xwa2_newsletter_create"];
if newsletter.is_null() {
return Err(NewsletterError::InvalidRequest(
"newsletter creation failed".into(),
));
}
parse_newsletter_metadata(newsletter)
}
pub async fn join(&self, jid: &Jid) -> Result<NewsletterMetadata, NewsletterError> {
let response = self
.client
.mex()
.mutate(mex_request!(join_newsletter {
newsletter_id: Some(jid.to_string()),
}))
.await?;
let data = response
.data
.ok_or_else(|| NewsletterError::InvalidRequest("missing data".into()))?;
let newsletter = &data["xwa2_newsletter_join_v2"];
if newsletter.is_null() {
return Err(NewsletterError::InvalidRequest(format!(
"failed to join newsletter: {}",
jid
)));
}
parse_newsletter_metadata(newsletter)
}
pub async fn leave(&self, jid: &Jid) -> Result<(), NewsletterError> {
let response = self
.client
.mex()
.mutate(mex_request!(leave_newsletter {
newsletter_id: Some(jid.to_string()),
}))
.await?;
let data = response
.data
.ok_or_else(|| NewsletterError::InvalidRequest("missing data".into()))?;
if data["xwa2_newsletter_leave_v2"].is_null() {
return Err(NewsletterError::InvalidRequest(format!(
"failed to leave newsletter: {}",
jid
)));
}
Ok(())
}
pub async fn update(
&self,
jid: &Jid,
name: Option<&str>,
description: Option<&str>,
) -> Result<NewsletterMetadata, NewsletterError> {
let response = self
.client
.mex()
.mutate(mex_request!(update_newsletter {
newsletter_id: Some(jid.to_string()),
updates: Some(update_newsletter::Updates {
name: name.map(str::to_string),
description: description.map(str::to_string),
picture: None,
settings: None,
}),
}))
.await?;
let data = response
.data
.ok_or_else(|| NewsletterError::InvalidRequest("missing data".into()))?;
let newsletter = &data["xwa2_newsletter_update"];
if newsletter.is_null() {
return Err(NewsletterError::InvalidRequest(format!(
"failed to update newsletter: {}",
jid
)));
}
parse_newsletter_metadata(newsletter)
}
pub async fn set_follower_mute(&self, jid: &Jid, muted: bool) -> Result<(), NewsletterError> {
self.set_user_setting_mute(jid, "MUTE_FOLLOWER_ACTIVITY", muted)
.await
}
pub async fn set_admin_mute(&self, jid: &Jid, muted: bool) -> Result<(), NewsletterError> {
self.set_user_setting_mute(jid, "MUTE_ADMIN_ACTIVITY", muted)
.await
}
async fn set_user_setting_mute(
&self,
jid: &Jid,
mute_type: &str,
muted: bool,
) -> Result<(), NewsletterError> {
let response = self
.client
.mex()
.mutate(mex_request!(
update_newsletter_user_setting,
mute_user_setting_variables(jid, mute_type, muted)
))
.await?;
let data = response
.data
.ok_or_else(|| NewsletterError::InvalidRequest("missing data".into()))?;
if data["xwa2_newsletter_update_user_setting"].is_null() {
return Err(NewsletterError::InvalidRequest(format!(
"failed to update newsletter user setting: {jid}"
)));
}
Ok(())
}
pub async fn get_metadata_by_invite(
&self,
invite_code: &str,
) -> Result<NewsletterMetadata, NewsletterError> {
let response = self
.client
.mex()
.query(mex_request!(fetch_newsletter {
input: Some(fetch_newsletter::Input {
key: Some(invite_code.to_string()),
r#type: Some("INVITE".into()),
view_role: Some("GUEST".into()),
}),
fetch_viewer_metadata: Some(true),
fetch_full_image: Some(true),
fetch_creation_time: Some(true),
..Default::default()
}))
.await?;
let data = response
.data
.ok_or_else(|| NewsletterError::InvalidRequest("missing data".into()))?;
let newsletter = &data["xwa2_newsletter"];
if newsletter.is_null() {
return Err(NewsletterError::InvalidRequest(format!(
"newsletter not found for invite: {}",
invite_code
)));
}
parse_newsletter_metadata(newsletter)
}
pub async fn subscribe_live_updates(
&self,
jid: impl Into<Jid>,
) -> Result<u64, NewsletterError> {
let jid = &jid.into();
let iq = InfoQuery::set(
NEWSLETTER_XMLNS,
jid.clone(),
Some(NodeContent::Nodes(vec![
NodeBuilder::new("live_updates").build(),
])),
);
let response = self.client.send_iq(iq).await?;
let nr = response.get();
let duration = nr
.get_optional_child("live_updates")
.and_then(|n| n.get_attr("duration"))
.map(|v| v.as_str())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(300);
Ok(duration)
}
pub async fn send_reaction(
&self,
jid: &Jid,
server_id: u64,
reaction: &str,
) -> Result<(), NewsletterError> {
self.client
.send_server_reaction(jid, server_id, reaction)
.await
.map_err(NewsletterError::from_anyhow)?;
Ok(())
}
pub async fn edit_message(
&self,
jid: &Jid,
message_id: impl Into<String>,
new_content: wa::Message,
) -> Result<(), NewsletterError> {
if !jid.is_newsletter() {
return Err(NewsletterError::InvalidRequest(
"edit_message is only valid for newsletter (channel) JIDs; use Client::edit_message for DM/group".into(),
));
}
let id = message_id.into();
if id.is_empty() {
return Err(NewsletterError::InvalidRequest(
"newsletter edit needs a target message_id (NewsletterMessage.message_id is empty when the server omits the id)".into(),
));
}
let node = crate::send::build_newsletter_edit_node(
jid,
&id,
crate::send::NewsletterEdit::Edit(&new_content),
);
self.client.send_node(node).await?;
Ok(())
}
pub async fn revoke_message(
&self,
jid: &Jid,
message_id: impl Into<String>,
) -> Result<(), NewsletterError> {
if !jid.is_newsletter() {
return Err(NewsletterError::InvalidRequest(
"revoke_message is only valid for newsletter (channel) JIDs; use Client::revoke_message for DM/group".into(),
));
}
let id = message_id.into();
if id.is_empty() {
return Err(NewsletterError::InvalidRequest(
"newsletter revoke needs a target message_id (NewsletterMessage.message_id is empty when the server omits the id)".into(),
));
}
let node =
crate::send::build_newsletter_edit_node(jid, &id, crate::send::NewsletterEdit::Revoke);
self.client.send_node(node).await?;
Ok(())
}
pub async fn get_messages(
&self,
jid: impl Into<Jid>,
count: u32,
before: Option<u64>,
) -> Result<Vec<NewsletterMessage>, NewsletterError> {
let jid = &jid.into();
let mut messages_node = NodeBuilder::new("messages").attr("count", count);
if let Some(before_id) = before {
messages_node = messages_node.attr("before", before_id);
}
let iq = InfoQuery::get(
NEWSLETTER_XMLNS,
jid.clone(),
Some(NodeContent::Nodes(vec![messages_node.build()])),
);
let response = self.client.send_iq(iq).await?;
parse_newsletter_messages_response(response.get())
}
}
impl Client {
#[inline]
pub fn newsletter(&self) -> Newsletter<'_> {
Newsletter::new(self)
}
}
fn parse_newsletter_metadata(
value: &serde_json::Value,
) -> Result<NewsletterMetadata, NewsletterError> {
let jid_str = value["id"]
.as_str()
.ok_or_else(|| NewsletterError::InvalidRequest("missing newsletter id".into()))?;
let jid: Jid = jid_str
.parse()
.map_err(|e| NewsletterError::InvalidRequest(format!("invalid newsletter id: {e}")))?;
let thread = &value["thread_metadata"];
let name = thread["name"]["text"].as_str().unwrap_or("").to_string();
let description = thread["description"]["text"]
.as_str()
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let subscriber_count = thread["subscribers_count"]
.as_str()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
let verification = match thread["verification"].as_str() {
Some("VERIFIED") => NewsletterVerification::Verified,
_ => NewsletterVerification::Unverified,
};
let state = match value["state"]["type"].as_str() {
Some("suspended") => NewsletterState::Suspended,
Some("geosuspended") => NewsletterState::Geosuspended,
_ => NewsletterState::Active,
};
let picture_url = thread["picture"]["direct_path"]
.as_str()
.map(|s| s.to_string());
let preview_url = thread["preview"]["direct_path"]
.as_str()
.map(|s| s.to_string());
let invite_code = thread["invite"].as_str().map(|s| s.to_string());
let creation_time = thread["creation_time"]
.as_str()
.and_then(|s| s.parse::<u64>().ok());
let role = value["viewer_metadata"]["role"]
.as_str()
.and_then(|r| match r {
"owner" => Some(NewsletterRole::Owner),
"admin" => Some(NewsletterRole::Admin),
"subscriber" => Some(NewsletterRole::Subscriber),
"guest" => Some(NewsletterRole::Guest),
_ => None,
});
Ok(NewsletterMetadata {
jid,
name,
description,
subscriber_count,
verification,
state,
picture_url,
preview_url,
invite_code,
role,
creation_time,
})
}
pub(crate) fn parse_reaction_counts(node: &NodeRef<'_>) -> Vec<NewsletterReactionCount> {
let mut reactions = Vec::new();
if let Some(reactions_node) = node.get_optional_child("reactions")
&& let Some(children) = reactions_node.children()
{
for r in children.iter().filter(|n| n.tag.as_ref() == "reaction") {
let Some(code) = r
.get_attr("code")
.map(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.into_owned())
else {
continue;
};
let count = r
.get_attr("count")
.map(|v| v.as_str())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
reactions.push(NewsletterReactionCount { code, count });
}
}
reactions
}
fn parse_newsletter_messages_response(
response: &NodeRef<'_>,
) -> Result<Vec<NewsletterMessage>, NewsletterError> {
let messages_node = response.get_optional_child("messages").ok_or_else(|| {
NewsletterError::InvalidRequest("missing <messages> in newsletter response".into())
})?;
let children = match messages_node.children() {
Some(c) => c,
None => return Ok(vec![]),
};
let mut result = Vec::with_capacity(children.len());
for msg_node in children.iter().filter(|n| n.tag.as_ref() == "message") {
let Some(server_id) = msg_node
.get_attr("server_id")
.map(|v| v.as_str())
.and_then(|s| s.parse::<u64>().ok())
else {
continue;
};
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())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
let message_type = msg_node
.get_attr("type")
.map(|v| v.as_str())
.map(|s| NewsletterMessageType::from(s.as_ref()))
.unwrap_or(NewsletterMessageType::Text);
let is_sender = msg_node
.get_attr("is_sender")
.is_some_and(|v| v.as_str() == "true");
let message =
msg_node
.get_optional_child("plaintext")
.and_then(|pt| match pt.content.as_ref() {
Some(NodeContentRef::Bytes(bytes)) => {
waproto::codec::message_decode(bytes.as_ref()).ok()
}
_ => None,
});
let reactions = parse_reaction_counts(msg_node);
result.push(NewsletterMessage {
message_id,
server_id,
timestamp,
message_type,
is_sender,
message,
reactions,
});
}
Ok(result)
}
fn mute_user_setting_variables(jid: &Jid, mute_type: &str, muted: bool) -> serde_json::Value {
serde_json::json!({
"input": {
"newsletter_id": jid.to_string(),
"type": mute_type,
"value": if muted { "ON" } else { "OFF" },
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use wacore_binary::builder::NodeBuilder;
#[test]
fn mute_variables_match_wa_web_shape() {
let jid: Jid = "111222333@newsletter".parse().unwrap();
let on = mute_user_setting_variables(&jid, "MUTE_FOLLOWER_ACTIVITY", true);
assert_eq!(on["input"]["newsletter_id"], "111222333@newsletter");
assert_eq!(on["input"]["type"], "MUTE_FOLLOWER_ACTIVITY");
assert_eq!(on["input"]["value"], "ON");
let off = mute_user_setting_variables(&jid, "MUTE_ADMIN_ACTIVITY", false);
assert_eq!(off["input"]["type"], "MUTE_ADMIN_ACTIVITY");
assert_eq!(off["input"]["value"], "OFF");
}
#[test]
fn test_missing_type_attribute_defaults_to_text() {
let response = NodeBuilder::new("iq")
.children([NodeBuilder::new("messages")
.children([NodeBuilder::new("message")
.attr("server_id", "42")
.attr("t", "1700000000")
.build()])
.build()])
.build();
let msgs = parse_newsletter_messages_response(&response.as_node_ref()).unwrap();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].message_type, NewsletterMessageType::Text);
}
#[test]
fn test_explicit_type_attribute_parsed() {
let response = NodeBuilder::new("iq")
.children([NodeBuilder::new("messages")
.children([NodeBuilder::new("message")
.attr("server_id", "1")
.attr("t", "1700000000")
.attr("type", "media")
.build()])
.build()])
.build();
let msgs = parse_newsletter_messages_response(&response.as_node_ref()).unwrap();
assert_eq!(msgs[0].message_type, NewsletterMessageType::Media);
}
}