Skip to main content

whatsapp_rust/features/
status.rs

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