Skip to main content

whatsapp_rust/features/
status.rs

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