Skip to main content

whatsapp_rust/features/
status.rs

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