Skip to main content

whatsapp_rust/features/
newsletter.rs

1//! Newsletter (Channel) feature.
2//!
3//! Provides methods for listing, fetching, and managing newsletter channels.
4//! Uses MEX (GraphQL) for metadata/management and standard IQ for message operations.
5//! Newsletter messages are plaintext (no Signal E2E encryption).
6
7use wacore::WireEnum;
8
9use crate::client::Client;
10use crate::features::mex::{MexError, MexRequest};
11use prost::Message as ProtoMessage;
12use serde_json::json;
13use wacore::iq::mex_ids::newsletter as newsletter_docs;
14use wacore::iq::newsletter::NEWSLETTER_XMLNS;
15use wacore::request::InfoQuery;
16use wacore_binary::Jid;
17use wacore_binary::builder::NodeBuilder;
18use wacore_binary::{NodeContent, NodeContentRef, NodeRef};
19use waproto::whatsapp as wa;
20
21// Types
22
23#[derive(Debug, Clone, PartialEq, Eq, WireEnum)]
24#[non_exhaustive]
25pub enum NewsletterMessageType {
26    #[wire = "text"]
27    Text,
28    #[wire = "media"]
29    Media,
30    #[wire = "reaction"]
31    Reaction,
32    #[wire = "revoke"]
33    Revoke,
34    #[wire = "poll_creation"]
35    PollCreation,
36    #[wire = "poll_vote"]
37    PollVote,
38    #[wire = "edit"]
39    Edit,
40    #[wire_fallback]
41    Other(String),
42}
43
44/// Newsletter verification status.
45#[derive(Debug, Clone, PartialEq, Eq)]
46#[non_exhaustive]
47pub enum NewsletterVerification {
48    Verified,
49    Unverified,
50}
51
52/// Newsletter state.
53#[derive(Debug, Clone, PartialEq, Eq)]
54#[non_exhaustive]
55pub enum NewsletterState {
56    Active,
57    Suspended,
58    Geosuspended,
59}
60
61/// The viewer's role in a newsletter.
62#[derive(Debug, Clone, PartialEq, Eq)]
63#[non_exhaustive]
64pub enum NewsletterRole {
65    Owner,
66    Admin,
67    Subscriber,
68    Guest,
69}
70
71/// Metadata for a newsletter (channel).
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct NewsletterMetadata {
74    pub jid: Jid,
75    pub name: String,
76    pub description: Option<String>,
77    pub subscriber_count: u64,
78    pub verification: NewsletterVerification,
79    pub state: NewsletterState,
80    pub picture_url: Option<String>,
81    pub preview_url: Option<String>,
82    pub invite_code: Option<String>,
83    pub role: Option<NewsletterRole>,
84    pub creation_time: Option<u64>,
85}
86
87/// A reaction count on a newsletter message.
88#[derive(Debug, Clone)]
89pub struct NewsletterReactionCount {
90    pub code: String,
91    pub count: u64,
92}
93
94/// A message from a newsletter's history.
95#[derive(Debug, Clone)]
96pub struct NewsletterMessage {
97    /// Server-assigned message ID (monotonic, used for pagination cursors).
98    pub server_id: u64,
99    /// Message timestamp (Unix seconds).
100    pub timestamp: u64,
101    /// Message type (text, media, reaction, etc.).
102    pub message_type: NewsletterMessageType,
103    /// Whether the viewer is the sender.
104    pub is_sender: bool,
105    /// Decoded protobuf message (from `<plaintext>` bytes).
106    pub message: Option<wa::Message>,
107    /// Reaction counts on this message.
108    pub reactions: Vec<NewsletterReactionCount>,
109}
110
111/// Feature handle for newsletter (channel) operations.
112pub struct Newsletter<'a> {
113    client: &'a Client,
114}
115
116impl<'a> Newsletter<'a> {
117    pub(crate) fn new(client: &'a Client) -> Self {
118        Self { client }
119    }
120
121    /// List all newsletters the user is subscribed to.
122    pub async fn list_subscribed(&self) -> Result<Vec<NewsletterMetadata>, MexError> {
123        let response = self
124            .client
125            .mex()
126            .query(MexRequest {
127                doc: newsletter_docs::LIST_SUBSCRIBED,
128                variables: json!({}),
129            })
130            .await?;
131
132        let data = response
133            .data
134            .ok_or_else(|| MexError::PayloadParsing("missing data".into()))?;
135        let newsletters = data["xwa2_newsletter_subscribed"]
136            .as_array()
137            .ok_or_else(|| {
138                MexError::PayloadParsing("missing xwa2_newsletter_subscribed array".into())
139            })?;
140
141        newsletters.iter().map(parse_newsletter_metadata).collect()
142    }
143
144    /// Fetch metadata for a newsletter by its JID.
145    pub async fn get_metadata(&self, jid: &Jid) -> Result<NewsletterMetadata, MexError> {
146        let response = self
147            .client
148            .mex()
149            .query(MexRequest {
150                doc: newsletter_docs::FETCH_METADATA,
151                variables: json!({
152                    "input": {
153                        "key": jid.to_string(),
154                        "type": "JID",
155                        "view_role": "GUEST"
156                    },
157                    "fetch_viewer_metadata": true,
158                    "fetch_full_image": true,
159                    "fetch_creation_time": true
160                }),
161            })
162            .await?;
163
164        let data = response
165            .data
166            .ok_or_else(|| MexError::PayloadParsing("missing data".into()))?;
167        let newsletter = &data["xwa2_newsletter"];
168        if newsletter.is_null() {
169            return Err(MexError::PayloadParsing(format!(
170                "newsletter not found: {}",
171                jid
172            )));
173        }
174        parse_newsletter_metadata(newsletter)
175    }
176
177    /// Create a new newsletter.
178    ///
179    /// Returns the metadata of the newly created newsletter.
180    pub async fn create(
181        &self,
182        name: &str,
183        description: Option<&str>,
184    ) -> Result<NewsletterMetadata, MexError> {
185        let mut input = json!({ "name": name });
186        if let Some(desc) = description {
187            input["description"] = json!(desc);
188        }
189
190        let response = self
191            .client
192            .mex()
193            .mutate(MexRequest {
194                doc: newsletter_docs::CREATE,
195                variables: json!({ "input": input }),
196            })
197            .await?;
198
199        let data = response
200            .data
201            .ok_or_else(|| MexError::PayloadParsing("missing data".into()))?;
202        let newsletter = &data["xwa2_newsletter_create"];
203        if newsletter.is_null() {
204            return Err(MexError::PayloadParsing(
205                "newsletter creation failed".into(),
206            ));
207        }
208        parse_newsletter_metadata(newsletter)
209    }
210
211    /// Join (subscribe to) a newsletter.
212    ///
213    /// Returns the newsletter metadata with the viewer's role set to `Subscriber`.
214    pub async fn join(&self, jid: &Jid) -> Result<NewsletterMetadata, MexError> {
215        let response = self
216            .client
217            .mex()
218            .mutate(MexRequest {
219                doc: newsletter_docs::JOIN,
220                variables: json!({
221                    "newsletter_id": jid.to_string()
222                }),
223            })
224            .await?;
225
226        let data = response
227            .data
228            .ok_or_else(|| MexError::PayloadParsing("missing data".into()))?;
229        let newsletter = &data["xwa2_newsletter_join_v2"];
230        if newsletter.is_null() {
231            return Err(MexError::PayloadParsing(format!(
232                "failed to join newsletter: {}",
233                jid
234            )));
235        }
236        parse_newsletter_metadata(newsletter)
237    }
238
239    /// Leave (unsubscribe from) a newsletter.
240    pub async fn leave(&self, jid: &Jid) -> Result<(), MexError> {
241        let response = self
242            .client
243            .mex()
244            .mutate(MexRequest {
245                doc: newsletter_docs::LEAVE,
246                variables: json!({
247                    "newsletter_id": jid.to_string()
248                }),
249            })
250            .await?;
251
252        let data = response
253            .data
254            .ok_or_else(|| MexError::PayloadParsing("missing data".into()))?;
255        if data["xwa2_newsletter_leave_v2"].is_null() {
256            return Err(MexError::PayloadParsing(format!(
257                "failed to leave newsletter: {}",
258                jid
259            )));
260        }
261        Ok(())
262    }
263
264    /// Update a newsletter's name and/or description.
265    pub async fn update(
266        &self,
267        jid: &Jid,
268        name: Option<&str>,
269        description: Option<&str>,
270    ) -> Result<NewsletterMetadata, MexError> {
271        let mut updates = json!({});
272        if let Some(name) = name {
273            updates["name"] = json!(name);
274        }
275        if let Some(desc) = description {
276            updates["description"] = json!(desc);
277        }
278
279        let response = self
280            .client
281            .mex()
282            .mutate(MexRequest {
283                doc: newsletter_docs::UPDATE,
284                variables: json!({
285                    "newsletter_id": jid.to_string(),
286                    "updates": updates
287                }),
288            })
289            .await?;
290
291        let data = response
292            .data
293            .ok_or_else(|| MexError::PayloadParsing("missing data".into()))?;
294        let newsletter = &data["xwa2_newsletter_update"];
295        if newsletter.is_null() {
296            return Err(MexError::PayloadParsing(format!(
297                "failed to update newsletter: {}",
298                jid
299            )));
300        }
301        parse_newsletter_metadata(newsletter)
302    }
303
304    /// Fetch metadata for a newsletter by its invite code.
305    pub async fn get_metadata_by_invite(
306        &self,
307        invite_code: &str,
308    ) -> Result<NewsletterMetadata, MexError> {
309        let response = self
310            .client
311            .mex()
312            .query(MexRequest {
313                doc: newsletter_docs::FETCH_METADATA,
314                variables: json!({
315                    "input": {
316                        "key": invite_code,
317                        "type": "INVITE",
318                        "view_role": "GUEST"
319                    },
320                    "fetch_viewer_metadata": true,
321                    "fetch_full_image": true,
322                    "fetch_creation_time": true
323                }),
324            })
325            .await?;
326
327        let data = response
328            .data
329            .ok_or_else(|| MexError::PayloadParsing("missing data".into()))?;
330        let newsletter = &data["xwa2_newsletter"];
331        if newsletter.is_null() {
332            return Err(MexError::PayloadParsing(format!(
333                "newsletter not found for invite: {}",
334                invite_code
335            )));
336        }
337        parse_newsletter_metadata(newsletter)
338    }
339
340    // ─── Live updates ───────────────────────────────────────────────────
341
342    /// Subscribe to live updates for a newsletter (reaction counts, message changes).
343    ///
344    /// The server will send `<notification type="newsletter">` stanzas with
345    /// `<live_updates>` children, dispatched as `Event::NewsletterLiveUpdate`.
346    /// Returns the subscription duration in seconds.
347    pub async fn subscribe_live_updates(&self, jid: &Jid) -> Result<u64, anyhow::Error> {
348        let iq = InfoQuery::set(
349            NEWSLETTER_XMLNS,
350            jid.clone(),
351            Some(NodeContent::Nodes(vec![
352                NodeBuilder::new("live_updates").build(),
353            ])),
354        );
355
356        let response = self.client.send_iq(iq).await?;
357        let nr = response.get();
358        let duration = nr
359            .get_optional_child("live_updates")
360            .and_then(|n| n.get_attr("duration"))
361            .map(|v| v.as_str())
362            .and_then(|s| s.parse::<u64>().ok())
363            .unwrap_or(300);
364
365        Ok(duration)
366    }
367
368    /// Send a reaction to a newsletter message.
369    ///
370    /// `server_id` is the server-assigned ID of the message to react to.
371    /// `reaction` is the emoji code (e.g., "👍", "❤️"), or empty to remove.
372    pub async fn send_reaction(
373        &self,
374        jid: &Jid,
375        server_id: u64,
376        reaction: &str,
377    ) -> Result<(), anyhow::Error> {
378        self.client
379            .send_server_reaction(jid, server_id, reaction)
380            .await
381    }
382
383    /// Fetch message history from a newsletter.
384    ///
385    /// Returns up to `count` messages. Use `before` with a `server_id` from a previous
386    /// response to paginate backwards through history.
387    pub async fn get_messages(
388        &self,
389        jid: &Jid,
390        count: u32,
391        before: Option<u64>,
392    ) -> Result<Vec<NewsletterMessage>, anyhow::Error> {
393        let mut messages_node = NodeBuilder::new("messages").attr("count", count);
394        if let Some(before_id) = before {
395            messages_node = messages_node.attr("before", before_id);
396        }
397
398        let iq = InfoQuery::get(
399            NEWSLETTER_XMLNS,
400            jid.clone(),
401            Some(NodeContent::Nodes(vec![messages_node.build()])),
402        );
403
404        let response = self.client.send_iq(iq).await?;
405        parse_newsletter_messages_response(response.get())
406    }
407}
408
409impl Client {
410    /// Access newsletter (channel) operations.
411    #[inline]
412    pub fn newsletter(&self) -> Newsletter<'_> {
413        Newsletter::new(self)
414    }
415}
416
417// JSON parsing helper
418
419fn parse_newsletter_metadata(value: &serde_json::Value) -> Result<NewsletterMetadata, MexError> {
420    let jid_str = value["id"]
421        .as_str()
422        .ok_or_else(|| MexError::PayloadParsing("missing newsletter id".into()))?;
423    let jid: Jid = jid_str.parse()?;
424
425    let thread = &value["thread_metadata"];
426
427    let name = thread["name"]["text"].as_str().unwrap_or("").to_string();
428    let description = thread["description"]["text"]
429        .as_str()
430        .filter(|s| !s.is_empty())
431        .map(|s| s.to_string());
432
433    let subscriber_count = thread["subscribers_count"]
434        .as_str()
435        .and_then(|s| s.parse::<u64>().ok())
436        .unwrap_or(0);
437
438    let verification = match thread["verification"].as_str() {
439        Some("VERIFIED") => NewsletterVerification::Verified,
440        _ => NewsletterVerification::Unverified,
441    };
442
443    let state = match value["state"]["type"].as_str() {
444        Some("suspended") => NewsletterState::Suspended,
445        Some("geosuspended") => NewsletterState::Geosuspended,
446        _ => NewsletterState::Active,
447    };
448
449    let picture_url = thread["picture"]["direct_path"]
450        .as_str()
451        .map(|s| s.to_string());
452    let preview_url = thread["preview"]["direct_path"]
453        .as_str()
454        .map(|s| s.to_string());
455    let invite_code = thread["invite"].as_str().map(|s| s.to_string());
456
457    let creation_time = thread["creation_time"]
458        .as_str()
459        .and_then(|s| s.parse::<u64>().ok());
460
461    let role = value["viewer_metadata"]["role"]
462        .as_str()
463        .and_then(|r| match r {
464            "owner" => Some(NewsletterRole::Owner),
465            "admin" => Some(NewsletterRole::Admin),
466            "subscriber" => Some(NewsletterRole::Subscriber),
467            "guest" => Some(NewsletterRole::Guest),
468            _ => None,
469        });
470
471    Ok(NewsletterMetadata {
472        jid,
473        name,
474        description,
475        subscriber_count,
476        verification,
477        state,
478        picture_url,
479        preview_url,
480        invite_code,
481        role,
482        creation_time,
483    })
484}
485
486// ─── Shared parsing helpers ────────────────────────────────────────────
487
488/// Parse reaction counts from a `<reactions>` node.
489/// Used by both message history parsing and notification handling.
490pub(crate) fn parse_reaction_counts(node: &NodeRef<'_>) -> Vec<NewsletterReactionCount> {
491    let mut reactions = Vec::new();
492    if let Some(reactions_node) = node.get_optional_child("reactions")
493        && let Some(children) = reactions_node.children()
494    {
495        for r in children.iter().filter(|n| n.tag.as_ref() == "reaction") {
496            let Some(code) = r
497                .get_attr("code")
498                .map(|v| v.as_str())
499                .filter(|s| !s.is_empty())
500                .map(|s| s.into_owned())
501            else {
502                continue;
503            };
504            let count = r
505                .get_attr("count")
506                .map(|v| v.as_str())
507                .and_then(|s| s.parse::<u64>().ok())
508                .unwrap_or(0);
509            reactions.push(NewsletterReactionCount { code, count });
510        }
511    }
512    reactions
513}
514
515// Node response parsing helpers
516
517/// Parse the IQ response for newsletter message history.
518///
519/// Response format:
520/// ```xml
521/// <messages jid="NL_JID" t="TS">
522///   <message id="..." server_id="123" t="TS" type="text" [is_sender="true"]>
523///     <plaintext>...</plaintext>
524///     <reactions><reaction code="👍" count="3"/></reactions>
525///   </message>
526/// </messages>
527/// ```
528fn parse_newsletter_messages_response(
529    response: &NodeRef<'_>,
530) -> Result<Vec<NewsletterMessage>, anyhow::Error> {
531    // Response is the IQ result node; find <messages> child
532    let messages_node = response
533        .get_optional_child("messages")
534        .ok_or_else(|| anyhow::anyhow!("missing <messages> in newsletter response"))?;
535
536    let children = match messages_node.children() {
537        Some(c) => c,
538        None => return Ok(vec![]),
539    };
540
541    let mut result = Vec::with_capacity(children.len());
542    for msg_node in children.iter().filter(|n| n.tag.as_ref() == "message") {
543        // Skip nodes without a valid server_id (required for pagination/correlation)
544        let Some(server_id) = msg_node
545            .get_attr("server_id")
546            .map(|v| v.as_str())
547            .and_then(|s| s.parse::<u64>().ok())
548        else {
549            continue;
550        };
551
552        let timestamp = msg_node
553            .get_attr("t")
554            .map(|v| v.as_str())
555            .and_then(|s| s.parse::<u64>().ok())
556            .unwrap_or(0);
557
558        let message_type = msg_node
559            .get_attr("type")
560            .map(|v| v.as_str())
561            .map(|s| NewsletterMessageType::from(s.as_ref()))
562            .unwrap_or(NewsletterMessageType::Text);
563
564        let is_sender = msg_node
565            .get_attr("is_sender")
566            .is_some_and(|v| v.as_str() == "true");
567
568        // Decode <plaintext> protobuf bytes
569        let message =
570            msg_node
571                .get_optional_child("plaintext")
572                .and_then(|pt| match pt.content.as_deref() {
573                    Some(NodeContentRef::Bytes(bytes)) => wa::Message::decode(bytes.as_ref()).ok(),
574                    _ => None,
575                });
576
577        let reactions = parse_reaction_counts(msg_node);
578
579        result.push(NewsletterMessage {
580            server_id,
581            timestamp,
582            message_type,
583            is_sender,
584            message,
585            reactions,
586        });
587    }
588
589    Ok(result)
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595    use wacore_binary::builder::NodeBuilder;
596
597    #[test]
598    fn test_missing_type_attribute_defaults_to_text() {
599        let response = NodeBuilder::new("iq")
600            .children([NodeBuilder::new("messages")
601                .children([NodeBuilder::new("message")
602                    .attr("server_id", "42")
603                    .attr("t", "1700000000")
604                    .build()])
605                .build()])
606            .build();
607
608        let msgs = parse_newsletter_messages_response(&response.as_node_ref()).unwrap();
609        assert_eq!(msgs.len(), 1);
610        assert_eq!(msgs[0].message_type, NewsletterMessageType::Text);
611    }
612
613    #[test]
614    fn test_explicit_type_attribute_parsed() {
615        let response = NodeBuilder::new("iq")
616            .children([NodeBuilder::new("messages")
617                .children([NodeBuilder::new("message")
618                    .attr("server_id", "1")
619                    .attr("t", "1700000000")
620                    .attr("type", "media")
621                    .build()])
622                .build()])
623            .build();
624
625        let msgs = parse_newsletter_messages_response(&response.as_node_ref()).unwrap();
626        assert_eq!(msgs[0].message_type, NewsletterMessageType::Media);
627    }
628}