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