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, ClientError};
10use crate::features::mex::{MexError, mex_request};
11use crate::request::IqError;
12use thiserror::Error;
13use wacore::iq::mex_operations::{
14    create_newsletter, fetch_all_newsletters_metadata, fetch_newsletter, join_newsletter,
15    leave_newsletter, update_newsletter, update_newsletter_user_setting,
16};
17use wacore::iq::newsletter::NEWSLETTER_XMLNS;
18use wacore::request::InfoQuery;
19use wacore_binary::Jid;
20use wacore_binary::JidExt as _;
21use wacore_binary::builder::NodeBuilder;
22use wacore_binary::{NodeContent, NodeContentRef, NodeRef};
23use waproto::whatsapp as wa;
24
25/// Error returned by newsletter (channel) operations.
26#[derive(Debug, Error)]
27#[non_exhaustive]
28pub enum NewsletterError {
29    /// A MEX (GraphQL) query/mutation failed or returned malformed data.
30    #[error("{0}")]
31    Mex(#[from] MexError),
32    /// An IQ (message history, live updates) failed.
33    #[error("{0}")]
34    Iq(#[from] IqError),
35    /// Connection/transport failure sending a plaintext stanza (edit/revoke).
36    #[error("{0}")]
37    Client(#[from] ClientError),
38    /// The request was malformed (e.g. a non-newsletter JID, an empty target
39    /// message id, or a missing element in the server response).
40    #[error("invalid newsletter request: {0}")]
41    InvalidRequest(String),
42    /// Catch-all for internal failures with no dedicated variant.
43    #[error("{0}")]
44    Internal(#[from] anyhow::Error),
45}
46
47impl NewsletterError {
48    /// Recover the concrete typed error from an `anyhow` bubbled up by a helper
49    /// that still threads `anyhow` (e.g. `send_server_reaction`), so transport
50    /// failures stay matchable as `Client`/`Iq` instead of collapsing into the
51    /// `Internal` catch-all via the blanket `#[from] anyhow::Error`.
52    pub(crate) fn from_anyhow(err: anyhow::Error) -> Self {
53        match err.downcast::<ClientError>() {
54            Ok(ClientError::Iq(iq)) => NewsletterError::Iq(iq),
55            Ok(client) => NewsletterError::Client(client),
56            Err(other) => match other.downcast::<IqError>() {
57                Ok(iq) => NewsletterError::Iq(iq),
58                Err(other) => NewsletterError::Internal(other),
59            },
60        }
61    }
62}
63
64// Types
65
66#[derive(Debug, Clone, PartialEq, Eq, WireEnum)]
67#[non_exhaustive]
68pub enum NewsletterMessageType {
69    #[wire = "text"]
70    Text,
71    #[wire = "media"]
72    Media,
73    #[wire = "reaction"]
74    Reaction,
75    #[wire = "revoke"]
76    Revoke,
77    #[wire = "poll_creation"]
78    PollCreation,
79    #[wire = "poll_vote"]
80    PollVote,
81    #[wire = "edit"]
82    Edit,
83    #[wire_fallback]
84    Other(String),
85}
86
87/// Newsletter verification status.
88#[derive(Debug, Clone, PartialEq, Eq)]
89#[non_exhaustive]
90pub enum NewsletterVerification {
91    Verified,
92    Unverified,
93}
94
95/// Newsletter state.
96#[derive(Debug, Clone, PartialEq, Eq)]
97#[non_exhaustive]
98pub enum NewsletterState {
99    Active,
100    Suspended,
101    Geosuspended,
102}
103
104/// The viewer's role in a newsletter.
105#[derive(Debug, Clone, PartialEq, Eq)]
106#[non_exhaustive]
107pub enum NewsletterRole {
108    Owner,
109    Admin,
110    Subscriber,
111    Guest,
112}
113
114/// Metadata for a newsletter (channel).
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct NewsletterMetadata {
117    pub jid: Jid,
118    pub name: String,
119    pub description: Option<String>,
120    pub subscriber_count: u64,
121    pub verification: NewsletterVerification,
122    pub state: NewsletterState,
123    pub picture_url: Option<String>,
124    pub preview_url: Option<String>,
125    pub invite_code: Option<String>,
126    pub role: Option<NewsletterRole>,
127    pub creation_time: Option<u64>,
128}
129
130/// A reaction count on a newsletter message.
131#[derive(Debug, Clone)]
132pub struct NewsletterReactionCount {
133    pub code: String,
134    pub count: u64,
135}
136
137/// A message from a newsletter's history.
138#[derive(Debug, Clone)]
139pub struct NewsletterMessage {
140    /// Wire message id (the stanza `id`). This is what edit_message / revoke_message
141    /// key on (NOT `server_id`). Empty if the server omitted it.
142    pub message_id: String,
143    /// Server-assigned message ID (monotonic, used for pagination cursors).
144    pub server_id: u64,
145    /// Message timestamp (Unix seconds).
146    pub timestamp: u64,
147    /// Message type (text, media, reaction, etc.).
148    pub message_type: NewsletterMessageType,
149    /// Whether the viewer is the sender.
150    pub is_sender: bool,
151    /// Decoded protobuf message (from `<plaintext>` bytes).
152    pub message: Option<wa::Message>,
153    /// Reaction counts on this message.
154    pub reactions: Vec<NewsletterReactionCount>,
155}
156
157/// Feature handle for newsletter (channel) operations.
158pub struct Newsletter<'a> {
159    client: &'a Client,
160}
161
162impl<'a> Newsletter<'a> {
163    pub(crate) fn new(client: &'a Client) -> Self {
164        Self { client }
165    }
166
167    /// List all newsletters the user is subscribed to.
168    pub async fn list_subscribed(&self) -> Result<Vec<NewsletterMetadata>, NewsletterError> {
169        let response = self
170            .client
171            .mex()
172            .query(mex_request!(fetch_all_newsletters_metadata {
173                ..Default::default()
174            }))
175            .await?;
176
177        let data = response
178            .data
179            .ok_or_else(|| NewsletterError::InvalidRequest("missing data".into()))?;
180        let newsletters = data["xwa2_newsletter_subscribed"]
181            .as_array()
182            .ok_or_else(|| {
183                NewsletterError::InvalidRequest("missing xwa2_newsletter_subscribed array".into())
184            })?;
185
186        newsletters.iter().map(parse_newsletter_metadata).collect()
187    }
188
189    /// Fetch metadata for a newsletter by its JID.
190    pub async fn get_metadata(&self, jid: &Jid) -> Result<NewsletterMetadata, NewsletterError> {
191        let response = self
192            .client
193            .mex()
194            .query(mex_request!(fetch_newsletter {
195                input: Some(fetch_newsletter::Input {
196                    key: Some(jid.to_string()),
197                    r#type: Some("JID".into()),
198                    view_role: Some("GUEST".into()),
199                }),
200                fetch_viewer_metadata: Some(true),
201                fetch_full_image: Some(true),
202                fetch_creation_time: Some(true),
203                ..Default::default()
204            }))
205            .await?;
206
207        let data = response
208            .data
209            .ok_or_else(|| NewsletterError::InvalidRequest("missing data".into()))?;
210        let newsletter = &data["xwa2_newsletter"];
211        if newsletter.is_null() {
212            return Err(NewsletterError::InvalidRequest(format!(
213                "newsletter not found: {}",
214                jid
215            )));
216        }
217        parse_newsletter_metadata(newsletter)
218    }
219
220    /// Create a new newsletter.
221    ///
222    /// Returns the metadata of the newly created newsletter.
223    pub async fn create(
224        &self,
225        name: &str,
226        description: Option<&str>,
227    ) -> Result<NewsletterMetadata, NewsletterError> {
228        let response = self
229            .client
230            .mex()
231            .mutate(mex_request!(create_newsletter {
232                input: Some(create_newsletter::Input {
233                    name: Some(name.to_string()),
234                    description: description.map(str::to_string),
235                    picture: None,
236                }),
237            }))
238            .await?;
239
240        let data = response
241            .data
242            .ok_or_else(|| NewsletterError::InvalidRequest("missing data".into()))?;
243        let newsletter = &data["xwa2_newsletter_create"];
244        if newsletter.is_null() {
245            return Err(NewsletterError::InvalidRequest(
246                "newsletter creation failed".into(),
247            ));
248        }
249        parse_newsletter_metadata(newsletter)
250    }
251
252    /// Join (subscribe to) a newsletter.
253    ///
254    /// Returns the newsletter metadata with the viewer's role set to `Subscriber`.
255    pub async fn join(&self, jid: &Jid) -> Result<NewsletterMetadata, NewsletterError> {
256        let response = self
257            .client
258            .mex()
259            .mutate(mex_request!(join_newsletter {
260                newsletter_id: Some(jid.to_string()),
261            }))
262            .await?;
263
264        let data = response
265            .data
266            .ok_or_else(|| NewsletterError::InvalidRequest("missing data".into()))?;
267        let newsletter = &data["xwa2_newsletter_join_v2"];
268        if newsletter.is_null() {
269            return Err(NewsletterError::InvalidRequest(format!(
270                "failed to join newsletter: {}",
271                jid
272            )));
273        }
274        parse_newsletter_metadata(newsletter)
275    }
276
277    /// Leave (unsubscribe from) a newsletter.
278    pub async fn leave(&self, jid: &Jid) -> Result<(), NewsletterError> {
279        let response = self
280            .client
281            .mex()
282            .mutate(mex_request!(leave_newsletter {
283                newsletter_id: Some(jid.to_string()),
284            }))
285            .await?;
286
287        let data = response
288            .data
289            .ok_or_else(|| NewsletterError::InvalidRequest("missing data".into()))?;
290        if data["xwa2_newsletter_leave_v2"].is_null() {
291            return Err(NewsletterError::InvalidRequest(format!(
292                "failed to leave newsletter: {}",
293                jid
294            )));
295        }
296        Ok(())
297    }
298
299    /// Update a newsletter's name and/or description.
300    pub async fn update(
301        &self,
302        jid: &Jid,
303        name: Option<&str>,
304        description: Option<&str>,
305    ) -> Result<NewsletterMetadata, NewsletterError> {
306        let response = self
307            .client
308            .mex()
309            .mutate(mex_request!(update_newsletter {
310                newsletter_id: Some(jid.to_string()),
311                updates: Some(update_newsletter::Updates {
312                    name: name.map(str::to_string),
313                    description: description.map(str::to_string),
314                    picture: None,
315                    settings: None,
316                }),
317            }))
318            .await?;
319
320        let data = response
321            .data
322            .ok_or_else(|| NewsletterError::InvalidRequest("missing data".into()))?;
323        let newsletter = &data["xwa2_newsletter_update"];
324        if newsletter.is_null() {
325            return Err(NewsletterError::InvalidRequest(format!(
326                "failed to update newsletter: {}",
327                jid
328            )));
329        }
330        parse_newsletter_metadata(newsletter)
331    }
332
333    /// Mute or unmute a newsletter's follower-activity notifications
334    /// (WA Web's `MUTE_FOLLOWER_ACTIVITY`). `muted = true` silences them.
335    pub async fn set_follower_mute(&self, jid: &Jid, muted: bool) -> Result<(), NewsletterError> {
336        self.set_user_setting_mute(jid, "MUTE_FOLLOWER_ACTIVITY", muted)
337            .await
338    }
339
340    /// Mute or unmute a newsletter's admin-activity notifications
341    /// (WA Web's `MUTE_ADMIN_ACTIVITY`). Only meaningful for owners/admins.
342    pub async fn set_admin_mute(&self, jid: &Jid, muted: bool) -> Result<(), NewsletterError> {
343        self.set_user_setting_mute(jid, "MUTE_ADMIN_ACTIVITY", muted)
344            .await
345    }
346
347    async fn set_user_setting_mute(
348        &self,
349        jid: &Jid,
350        mute_type: &str,
351        muted: bool,
352    ) -> Result<(), NewsletterError> {
353        let response = self
354            .client
355            .mex()
356            .mutate(mex_request!(
357                update_newsletter_user_setting,
358                mute_user_setting_variables(jid, mute_type, muted)
359            ))
360            .await?;
361
362        let data = response
363            .data
364            .ok_or_else(|| NewsletterError::InvalidRequest("missing data".into()))?;
365        if data["xwa2_newsletter_update_user_setting"].is_null() {
366            return Err(NewsletterError::InvalidRequest(format!(
367                "failed to update newsletter user setting: {jid}"
368            )));
369        }
370        Ok(())
371    }
372
373    /// Fetch metadata for a newsletter by its invite code.
374    pub async fn get_metadata_by_invite(
375        &self,
376        invite_code: &str,
377    ) -> Result<NewsletterMetadata, NewsletterError> {
378        let response = self
379            .client
380            .mex()
381            .query(mex_request!(fetch_newsletter {
382                input: Some(fetch_newsletter::Input {
383                    key: Some(invite_code.to_string()),
384                    r#type: Some("INVITE".into()),
385                    view_role: Some("GUEST".into()),
386                }),
387                fetch_viewer_metadata: Some(true),
388                fetch_full_image: Some(true),
389                fetch_creation_time: Some(true),
390                ..Default::default()
391            }))
392            .await?;
393
394        let data = response
395            .data
396            .ok_or_else(|| NewsletterError::InvalidRequest("missing data".into()))?;
397        let newsletter = &data["xwa2_newsletter"];
398        if newsletter.is_null() {
399            return Err(NewsletterError::InvalidRequest(format!(
400                "newsletter not found for invite: {}",
401                invite_code
402            )));
403        }
404        parse_newsletter_metadata(newsletter)
405    }
406
407    // ─── Live updates ───────────────────────────────────────────────────
408
409    /// Subscribe to live updates for a newsletter (reaction counts, message changes).
410    ///
411    /// The server will send `<notification type="newsletter">` stanzas with
412    /// `<live_updates>` children, dispatched as `Event::NewsletterLiveUpdate`.
413    /// Returns the subscription duration in seconds.
414    pub async fn subscribe_live_updates(
415        &self,
416        jid: impl Into<Jid>,
417    ) -> Result<u64, NewsletterError> {
418        let jid = &jid.into();
419        let iq = InfoQuery::set(
420            NEWSLETTER_XMLNS,
421            jid.clone(),
422            Some(NodeContent::Nodes(vec![
423                NodeBuilder::new("live_updates").build(),
424            ])),
425        );
426
427        let response = self.client.send_iq(iq).await?;
428        let nr = response.get();
429        let duration = nr
430            .get_optional_child("live_updates")
431            .and_then(|n| n.get_attr("duration"))
432            .map(|v| v.as_str())
433            .and_then(|s| s.parse::<u64>().ok())
434            .unwrap_or(300);
435
436        Ok(duration)
437    }
438
439    /// Send a reaction to a newsletter message.
440    ///
441    /// `server_id` is the server-assigned ID of the message to react to.
442    /// `reaction` is the emoji code (e.g., "👍", "❤️"), or empty to remove.
443    pub async fn send_reaction(
444        &self,
445        jid: &Jid,
446        server_id: u64,
447        reaction: &str,
448    ) -> Result<(), NewsletterError> {
449        self.client
450            .send_server_reaction(jid, server_id, reaction)
451            .await
452            .map_err(NewsletterError::from_anyhow)?;
453        Ok(())
454    }
455
456    /// Edit a message in a newsletter (channel). Channels are plaintext (not E2E).
457    ///
458    /// `message_id` is the target message's id (the `message_id` from
459    /// [`NewsletterMessage`] / the id returned when it was sent), NOT its
460    /// `server_id` (edit/revoke key on the message id, unlike reactions which use
461    /// `server_id`). `new_content` is the replacement body (e.g.
462    /// `wa::Message { conversation: Some(..), .. }`).
463    pub async fn edit_message(
464        &self,
465        jid: &Jid,
466        message_id: impl Into<String>,
467        new_content: wa::Message,
468    ) -> Result<(), NewsletterError> {
469        if !jid.is_newsletter() {
470            return Err(NewsletterError::InvalidRequest(
471                "edit_message is only valid for newsletter (channel) JIDs; use Client::edit_message for DM/group".into(),
472            ));
473        }
474        let id = message_id.into();
475        if id.is_empty() {
476            return Err(NewsletterError::InvalidRequest(
477                "newsletter edit needs a target message_id (NewsletterMessage.message_id is empty when the server omits the id)".into(),
478            ));
479        }
480        let node = crate::send::build_newsletter_edit_node(
481            jid,
482            &id,
483            crate::send::NewsletterEdit::Edit(&new_content),
484        );
485        self.client.send_node(node).await?;
486        Ok(())
487    }
488
489    /// Revoke (delete) a message in a newsletter (channel).
490    ///
491    /// `message_id` is the target message's id (the `message_id` from
492    /// [`NewsletterMessage`]), NOT its `server_id`.
493    pub async fn revoke_message(
494        &self,
495        jid: &Jid,
496        message_id: impl Into<String>,
497    ) -> Result<(), NewsletterError> {
498        if !jid.is_newsletter() {
499            return Err(NewsletterError::InvalidRequest(
500                "revoke_message is only valid for newsletter (channel) JIDs; use Client::revoke_message for DM/group".into(),
501            ));
502        }
503        let id = message_id.into();
504        if id.is_empty() {
505            return Err(NewsletterError::InvalidRequest(
506                "newsletter revoke needs a target message_id (NewsletterMessage.message_id is empty when the server omits the id)".into(),
507            ));
508        }
509        let node =
510            crate::send::build_newsletter_edit_node(jid, &id, crate::send::NewsletterEdit::Revoke);
511        self.client.send_node(node).await?;
512        Ok(())
513    }
514
515    /// Fetch message history from a newsletter.
516    ///
517    /// Returns up to `count` messages. Use `before` with a `server_id` from a previous
518    /// response to paginate backwards through history.
519    pub async fn get_messages(
520        &self,
521        jid: impl Into<Jid>,
522        count: u32,
523        before: Option<u64>,
524    ) -> Result<Vec<NewsletterMessage>, NewsletterError> {
525        let jid = &jid.into();
526        let mut messages_node = NodeBuilder::new("messages").attr("count", count);
527        if let Some(before_id) = before {
528            messages_node = messages_node.attr("before", before_id);
529        }
530
531        let iq = InfoQuery::get(
532            NEWSLETTER_XMLNS,
533            jid.clone(),
534            Some(NodeContent::Nodes(vec![messages_node.build()])),
535        );
536
537        let response = self.client.send_iq(iq).await?;
538        parse_newsletter_messages_response(response.get())
539    }
540}
541
542impl Client {
543    /// Access newsletter (channel) operations.
544    #[inline]
545    pub fn newsletter(&self) -> Newsletter<'_> {
546        Newsletter::new(self)
547    }
548}
549
550// JSON parsing helper
551
552fn parse_newsletter_metadata(
553    value: &serde_json::Value,
554) -> Result<NewsletterMetadata, NewsletterError> {
555    let jid_str = value["id"]
556        .as_str()
557        .ok_or_else(|| NewsletterError::InvalidRequest("missing newsletter id".into()))?;
558    let jid: Jid = jid_str
559        .parse()
560        .map_err(|e| NewsletterError::InvalidRequest(format!("invalid newsletter id: {e}")))?;
561
562    let thread = &value["thread_metadata"];
563
564    let name = thread["name"]["text"].as_str().unwrap_or("").to_string();
565    let description = thread["description"]["text"]
566        .as_str()
567        .filter(|s| !s.is_empty())
568        .map(|s| s.to_string());
569
570    let subscriber_count = thread["subscribers_count"]
571        .as_str()
572        .and_then(|s| s.parse::<u64>().ok())
573        .unwrap_or(0);
574
575    let verification = match thread["verification"].as_str() {
576        Some("VERIFIED") => NewsletterVerification::Verified,
577        _ => NewsletterVerification::Unverified,
578    };
579
580    let state = match value["state"]["type"].as_str() {
581        Some("suspended") => NewsletterState::Suspended,
582        Some("geosuspended") => NewsletterState::Geosuspended,
583        _ => NewsletterState::Active,
584    };
585
586    let picture_url = thread["picture"]["direct_path"]
587        .as_str()
588        .map(|s| s.to_string());
589    let preview_url = thread["preview"]["direct_path"]
590        .as_str()
591        .map(|s| s.to_string());
592    let invite_code = thread["invite"].as_str().map(|s| s.to_string());
593
594    let creation_time = thread["creation_time"]
595        .as_str()
596        .and_then(|s| s.parse::<u64>().ok());
597
598    let role = value["viewer_metadata"]["role"]
599        .as_str()
600        .and_then(|r| match r {
601            "owner" => Some(NewsletterRole::Owner),
602            "admin" => Some(NewsletterRole::Admin),
603            "subscriber" => Some(NewsletterRole::Subscriber),
604            "guest" => Some(NewsletterRole::Guest),
605            _ => None,
606        });
607
608    Ok(NewsletterMetadata {
609        jid,
610        name,
611        description,
612        subscriber_count,
613        verification,
614        state,
615        picture_url,
616        preview_url,
617        invite_code,
618        role,
619        creation_time,
620    })
621}
622
623// ─── Shared parsing helpers ────────────────────────────────────────────
624
625/// Parse reaction counts from a `<reactions>` node.
626/// Used by both message history parsing and notification handling.
627pub(crate) fn parse_reaction_counts(node: &NodeRef<'_>) -> Vec<NewsletterReactionCount> {
628    let mut reactions = Vec::new();
629    if let Some(reactions_node) = node.get_optional_child("reactions")
630        && let Some(children) = reactions_node.children()
631    {
632        for r in children.iter().filter(|n| n.tag.as_ref() == "reaction") {
633            let Some(code) = r
634                .get_attr("code")
635                .map(|v| v.as_str())
636                .filter(|s| !s.is_empty())
637                .map(|s| s.into_owned())
638            else {
639                continue;
640            };
641            let count = r
642                .get_attr("count")
643                .map(|v| v.as_str())
644                .and_then(|s| s.parse::<u64>().ok())
645                .unwrap_or(0);
646            reactions.push(NewsletterReactionCount { code, count });
647        }
648    }
649    reactions
650}
651
652// Node response parsing helpers
653
654/// Parse the IQ response for newsletter message history.
655///
656/// Response format:
657/// ```xml
658/// <messages jid="NL_JID" t="TS">
659///   <message id="..." server_id="123" t="TS" type="text" [is_sender="true"]>
660///     <plaintext>...</plaintext>
661///     <reactions><reaction code="👍" count="3"/></reactions>
662///   </message>
663/// </messages>
664/// ```
665fn parse_newsletter_messages_response(
666    response: &NodeRef<'_>,
667) -> Result<Vec<NewsletterMessage>, NewsletterError> {
668    // Response is the IQ result node; find <messages> child
669    let messages_node = response.get_optional_child("messages").ok_or_else(|| {
670        NewsletterError::InvalidRequest("missing <messages> in newsletter response".into())
671    })?;
672
673    let children = match messages_node.children() {
674        Some(c) => c,
675        None => return Ok(vec![]),
676    };
677
678    let mut result = Vec::with_capacity(children.len());
679    for msg_node in children.iter().filter(|n| n.tag.as_ref() == "message") {
680        // Skip nodes without a valid server_id (required for pagination/correlation)
681        let Some(server_id) = msg_node
682            .get_attr("server_id")
683            .map(|v| v.as_str())
684            .and_then(|s| s.parse::<u64>().ok())
685        else {
686            continue;
687        };
688
689        // The wire `id` (string) is what edit/revoke key on; keep it alongside
690        // server_id (which is used for pagination/reactions).
691        let message_id = msg_node
692            .get_attr("id")
693            .map(|v| v.as_str().into_owned())
694            .unwrap_or_default();
695
696        let timestamp = msg_node
697            .get_attr("t")
698            .map(|v| v.as_str())
699            .and_then(|s| s.parse::<u64>().ok())
700            .unwrap_or(0);
701
702        let message_type = msg_node
703            .get_attr("type")
704            .map(|v| v.as_str())
705            .map(|s| NewsletterMessageType::from(s.as_ref()))
706            .unwrap_or(NewsletterMessageType::Text);
707
708        let is_sender = msg_node
709            .get_attr("is_sender")
710            .is_some_and(|v| v.as_str() == "true");
711
712        // Decode <plaintext> protobuf bytes
713        let message =
714            msg_node
715                .get_optional_child("plaintext")
716                .and_then(|pt| match pt.content.as_ref() {
717                    Some(NodeContentRef::Bytes(bytes)) => {
718                        waproto::codec::message_decode(bytes.as_ref()).ok()
719                    }
720                    _ => None,
721                });
722
723        let reactions = parse_reaction_counts(msg_node);
724
725        result.push(NewsletterMessage {
726            message_id,
727            server_id,
728            timestamp,
729            message_type,
730            is_sender,
731            message,
732            reactions,
733        });
734    }
735
736    Ok(result)
737}
738
739/// Build the MEX variables for `update_newsletter_user_setting`. WA Web
740/// (WAWebNewsletterUpdateUserSettingJob) sends `{ input: { newsletter_id, type, value } }`
741/// with value ON/OFF; the mute-expiration is local DB state, never on the wire. The
742/// generated op's input type is opaque (a bare string), so the structured object is
743/// passed directly as variables.
744fn mute_user_setting_variables(jid: &Jid, mute_type: &str, muted: bool) -> serde_json::Value {
745    serde_json::json!({
746        "input": {
747            "newsletter_id": jid.to_string(),
748            "type": mute_type,
749            "value": if muted { "ON" } else { "OFF" },
750        }
751    })
752}
753
754#[cfg(test)]
755mod tests {
756    use super::*;
757    use wacore_binary::builder::NodeBuilder;
758
759    #[test]
760    fn mute_variables_match_wa_web_shape() {
761        let jid: Jid = "111222333@newsletter".parse().unwrap();
762        let on = mute_user_setting_variables(&jid, "MUTE_FOLLOWER_ACTIVITY", true);
763        assert_eq!(on["input"]["newsletter_id"], "111222333@newsletter");
764        assert_eq!(on["input"]["type"], "MUTE_FOLLOWER_ACTIVITY");
765        assert_eq!(on["input"]["value"], "ON");
766
767        let off = mute_user_setting_variables(&jid, "MUTE_ADMIN_ACTIVITY", false);
768        assert_eq!(off["input"]["type"], "MUTE_ADMIN_ACTIVITY");
769        assert_eq!(off["input"]["value"], "OFF");
770    }
771
772    #[test]
773    fn test_missing_type_attribute_defaults_to_text() {
774        let response = NodeBuilder::new("iq")
775            .children([NodeBuilder::new("messages")
776                .children([NodeBuilder::new("message")
777                    .attr("server_id", "42")
778                    .attr("t", "1700000000")
779                    .build()])
780                .build()])
781            .build();
782
783        let msgs = parse_newsletter_messages_response(&response.as_node_ref()).unwrap();
784        assert_eq!(msgs.len(), 1);
785        assert_eq!(msgs[0].message_type, NewsletterMessageType::Text);
786    }
787
788    #[test]
789    fn test_explicit_type_attribute_parsed() {
790        let response = NodeBuilder::new("iq")
791            .children([NodeBuilder::new("messages")
792                .children([NodeBuilder::new("message")
793                    .attr("server_id", "1")
794                    .attr("t", "1700000000")
795                    .attr("type", "media")
796                    .build()])
797                .build()])
798            .build();
799
800        let msgs = parse_newsletter_messages_response(&response.as_node_ref()).unwrap();
801        assert_eq!(msgs[0].message_type, NewsletterMessageType::Media);
802    }
803}