Skip to main content

whatsapp_rust/features/
status.rs

1use wacore::WireEnum;
2use wacore_binary::Jid;
3use waproto::whatsapp as wa;
4
5use crate::cache::Freshness;
6use crate::client::Client;
7use crate::send::{SendError, SendResult};
8use crate::upload::UploadResponse;
9use wacore_binary::Node;
10
11/// Privacy setting sent in the `<meta>` node of the status stanza.
12/// Matches WhatsApp Web's `status_setting` attribute.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)]
14#[non_exhaustive]
15pub enum StatusPrivacySetting {
16    /// Send to all contacts in address book.
17    #[wire_default]
18    #[wire = "contacts"]
19    Contacts,
20    /// Send only to contacts in an allow list.
21    #[wire = "allowlist"]
22    AllowList,
23    /// Send to all contacts except those in a deny list.
24    #[wire = "denylist"]
25    DenyList,
26}
27
28/// Options for sending a status update.
29#[derive(Debug, Clone, Default)]
30pub struct StatusSendOptions {
31    /// Privacy setting for this status. Sent in the `<meta>` stanza node.
32    pub privacy: StatusPrivacySetting,
33    /// Override the generated message ID.
34    pub message_id: Option<String>,
35    /// Extra child nodes appended to the status stanza.
36    pub extra_stanza_nodes: Vec<Node>,
37    /// Freshness policy for the recipient device lists used by this send.
38    pub device_freshness: Freshness,
39}
40
41/// High-level API for WhatsApp status/story updates.
42pub struct Status<'a> {
43    client: &'a Client,
44}
45
46impl<'a> Status<'a> {
47    pub(crate) fn new(client: &'a Client) -> Self {
48        Self { client }
49    }
50
51    /// Send a text status update to the given recipients.
52    ///
53    /// `background_argb` is the background color as 0xAARRGGBB (e.g., `0xFF1E6E4F`).
54    /// `font` selects the status font; values outside the protocol enum can't be
55    /// passed (the prior `i32` form silently dropped them at encode time).
56    pub async fn send_text(
57        &self,
58        text: &str,
59        background_argb: u32,
60        font: wa::message::extended_text_message::FontType,
61        recipients: &[Jid],
62        options: StatusSendOptions,
63    ) -> Result<SendResult, SendError> {
64        let message = wa::Message {
65            extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage {
66                text: Some(text.to_string()),
67                background_argb: Some(background_argb),
68                font: Some(font),
69                ..Default::default()
70            }),
71            ..Default::default()
72        };
73
74        self.client
75            .send_status_message(message, recipients, options)
76            .await
77    }
78
79    /// Send an image status update.
80    ///
81    /// The caller must upload the media first via `client.upload()` and provide
82    /// the `UploadResponse`, JPEG thumbnail bytes, and optional caption.
83    pub async fn send_image(
84        &self,
85        upload: UploadResponse,
86        thumbnail: Vec<u8>,
87        caption: Option<&str>,
88        recipients: &[Jid],
89        options: StatusSendOptions,
90    ) -> Result<SendResult, SendError> {
91        let message = crate::media::image_message(
92            upload,
93            crate::media::ImageOptions {
94                caption: caption.map(|c| c.to_string()),
95                jpeg_thumbnail: Some(thumbnail),
96                ..Default::default()
97            },
98        );
99
100        self.client
101            .send_status_message(message, recipients, options)
102            .await
103    }
104
105    /// Send a video status update.
106    ///
107    /// The caller must upload the media first via `client.upload()` and provide
108    /// the `UploadResponse`, JPEG thumbnail bytes, duration in seconds, and optional caption.
109    pub async fn send_video(
110        &self,
111        upload: UploadResponse,
112        thumbnail: Vec<u8>,
113        duration_seconds: u32,
114        caption: Option<&str>,
115        recipients: &[Jid],
116        options: StatusSendOptions,
117    ) -> Result<SendResult, SendError> {
118        let message = crate::media::video_message(
119            upload,
120            crate::media::VideoOptions {
121                caption: caption.map(|c| c.to_string()),
122                jpeg_thumbnail: Some(thumbnail),
123                duration_seconds: Some(duration_seconds),
124                ..Default::default()
125            },
126        );
127
128        self.client
129            .send_status_message(message, recipients, options)
130            .await
131    }
132
133    /// Send a raw `wa::Message` as a status update.
134    ///
135    /// Use this for message types not covered by the convenience methods above.
136    pub async fn send_raw(
137        &self,
138        message: wa::Message,
139        recipients: &[Jid],
140        options: StatusSendOptions,
141    ) -> Result<SendResult, SendError> {
142        self.client
143            .send_status_message(message, recipients, options)
144            .await
145    }
146
147    /// Delete (revoke) a previously sent status update.
148    ///
149    /// `recipients` should be the same list used when posting the status,
150    /// since the revoke must be encrypted to the same set of devices.
151    pub async fn revoke(
152        &self,
153        message_id: impl Into<String>,
154        recipients: &[Jid],
155        options: StatusSendOptions,
156    ) -> Result<SendResult, SendError> {
157        let message_id = message_id.into();
158        let to = Jid::status_broadcast();
159
160        let revoke_message = wa::Message {
161            protocol_message: buffa::MessageField::some(wa::message::ProtocolMessage {
162                key: buffa::MessageField::some(wa::MessageKey {
163                    remote_jid: Some(to.to_string()),
164                    from_me: Some(true),
165                    id: Some(message_id),
166                    ..Default::default()
167                }),
168                r#type: Some(wa::message::protocol_message::Type::REVOKE),
169                ..Default::default()
170            }),
171            ..Default::default()
172        };
173
174        self.client
175            .send_status_message(revoke_message, recipients, options)
176            .await
177    }
178}
179
180impl Client {
181    /// Access the status/story API for posting, revoking, and managing status updates.
182    ///
183    /// # Example
184    /// ```no_run
185    /// # async fn example(client: &whatsapp_rust::Client) -> anyhow::Result<()> {
186    /// use waproto::whatsapp::message::extended_text_message::FontType;
187    /// let recipients = [whatsapp_rust::Jid::pn("15551234567")];
188    /// let id = client
189    ///     .status()
190    ///     .send_text("Hello!", 0xFF1E6E4F, FontType::SYSTEM, &recipients, Default::default())
191    ///     .await?;
192    /// # Ok(())
193    /// # }
194    /// ```
195    pub fn status(&self) -> Status<'_> {
196        Status::new(self)
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn test_status_privacy_setting_values() {
206        // Verify the string values match WhatsApp Web's status_setting attribute
207        assert_eq!(StatusPrivacySetting::Contacts.as_str(), "contacts");
208        assert_eq!(StatusPrivacySetting::AllowList.as_str(), "allowlist");
209        assert_eq!(StatusPrivacySetting::DenyList.as_str(), "denylist");
210    }
211
212    #[test]
213    fn test_status_privacy_default_is_contacts() {
214        let default = StatusPrivacySetting::default();
215        assert_eq!(default.as_str(), "contacts");
216    }
217
218    #[test]
219    fn test_status_send_options_default() {
220        let opts = StatusSendOptions::default();
221        assert_eq!(opts.privacy.as_str(), "contacts");
222    }
223
224    #[test]
225    fn test_status_text_message_structure() {
226        // Verify the message structure matches WhatsApp Web's extendedTextMessage format
227        use waproto::whatsapp::message::extended_text_message::FontType;
228        let text = "Hello from Rust!";
229        let bg = 0xFF1E6E4F_u32;
230        let font = FontType::FB_SCRIPT;
231
232        let message = waproto::whatsapp::Message {
233            extended_text_message: buffa::MessageField::some(
234                waproto::whatsapp::message::ExtendedTextMessage {
235                    text: Some(text.to_string()),
236                    background_argb: Some(bg),
237                    font: Some(font),
238                    ..Default::default()
239                },
240            ),
241            ..Default::default()
242        };
243
244        let ext = message.extended_text_message.as_option().unwrap();
245        assert_eq!(ext.text.as_deref(), Some(text));
246        assert_eq!(ext.background_argb, Some(bg));
247        assert_eq!(ext.font, Some(font));
248    }
249
250    #[test]
251    fn test_status_revoke_message_structure() {
252        use waproto::whatsapp as wa;
253
254        let original_id = "3EB06D00CAB92340790621";
255        let to = Jid::status_broadcast();
256
257        let revoke_message = wa::Message {
258            protocol_message: buffa::MessageField::some(wa::message::ProtocolMessage {
259                key: wa::MessageKey {
260                    remote_jid: Some(to.to_string()),
261                    from_me: Some(true),
262                    id: Some(original_id.to_string()),
263                    ..Default::default()
264                }
265                .into(),
266                r#type: Some(wa::message::protocol_message::Type::REVOKE),
267                ..Default::default()
268            }),
269            ..Default::default()
270        };
271
272        let pm = revoke_message.protocol_message.as_option().unwrap();
273        assert_eq!(pm.r#type, Some(wa::message::protocol_message::Type::REVOKE));
274        let key = pm.key.as_option().unwrap();
275        assert_eq!(key.remote_jid.as_deref(), Some("status@broadcast"));
276        assert_eq!(key.from_me, Some(true));
277        assert_eq!(key.id.as_deref(), Some(original_id));
278    }
279
280    #[test]
281    fn test_revoke_is_detected_as_revoke() {
282        use waproto::whatsapp as wa;
283
284        // Non-revoke message
285        let text_msg = wa::Message {
286            extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage {
287                text: Some("hello".to_string()),
288                ..Default::default()
289            }),
290            ..Default::default()
291        };
292        let is_revoke = text_msg
293            .protocol_message
294            .as_option()
295            .is_some_and(|pm| pm.r#type == Some(wa::message::protocol_message::Type::REVOKE));
296        assert!(!is_revoke, "text message should not be detected as revoke");
297
298        // Revoke message
299        let revoke_msg = wa::Message {
300            protocol_message: buffa::MessageField::some(wa::message::ProtocolMessage {
301                r#type: Some(wa::message::protocol_message::Type::REVOKE),
302                ..Default::default()
303            }),
304            ..Default::default()
305        };
306        let is_revoke = revoke_msg
307            .protocol_message
308            .as_option()
309            .is_some_and(|pm| pm.r#type == Some(wa::message::protocol_message::Type::REVOKE));
310        assert!(is_revoke, "revoke message should be detected as revoke");
311    }
312}