Skip to main content

whatsapp_rust/features/
chat_actions.rs

1//! Chat management actions: archive, pin, mute, and starred messages.
2//!
3//! These features work through WhatsApp's app state sync mechanism (syncd).
4//! Each action is encoded as a mutation and sent to the appropriate collection.
5//!
6//! ## Collections (from WhatsApp Web JS)
7//! - Archive: `regular_low` (WAWebArchiveChatSync)
8//! - Pin: `regular_low` (WAWebPinChatSync)
9//! - Mute: `regular_high` (WAWebMuteChatSync)
10//! - Star: `regular_high` (WAWebStarMessageSync)
11//!
12//! ## Wire format (app state mutation)
13//! - Index: JSON array, e.g. `["pin_v1", "jid@s.whatsapp.net"]`
14//! - Value: protobuf `SyncActionValue` with the corresponding action field
15//! - Operation: `SET`
16
17use crate::appstate_sync::Mutation;
18use crate::client::Client;
19use anyhow::Result;
20use chrono::DateTime;
21use log::debug;
22use std::sync::Arc;
23use wacore::appstate::patch_decode::WAPatchName;
24use wacore::types::events::{
25    ArchiveUpdate, ContactUpdate, Event, MarkChatAsReadUpdate, MuteUpdate, PinUpdate, StarUpdate,
26};
27use wacore_binary::jid::{Jid, JidExt};
28use waproto::whatsapp as wa;
29
30/// Mute end timestamp value for indefinite mute (matches WhatsApp Web's `-1` sentinel).
31const MUTE_INDEFINITE: i64 = -1;
32
33// ── Event dispatch from incoming app state mutations ─────────────────
34
35/// Dispatch events for chat-related app state mutations.
36///
37/// Handles: mute, pin, pin_v1, archive, star, contact, mark_chat_as_read.
38/// Returns `true` if the mutation was handled, `false` if unknown.
39pub(crate) fn dispatch_chat_mutation(
40    event_bus: &wacore::types::events::CoreEventBus,
41    m: &Mutation,
42    full_sync: bool,
43) -> bool {
44    if m.operation != wa::syncd_mutation::SyncdOperation::Set || m.index.is_empty() {
45        return false;
46    }
47
48    let kind = &m.index[0];
49
50    // Only handle known chat mutation types. Return false for unknown kinds
51    // so other handlers (e.g. setting_pushName) can process them.
52    if !matches!(
53        kind.as_str(),
54        "mute"
55            | "pin"
56            | "pin_v1"
57            | "archive"
58            | "star"
59            | "contact"
60            | "mark_chat_as_read"
61            | "markChatAsRead"
62    ) {
63        return false;
64    }
65
66    let ts = m
67        .action_value
68        .as_ref()
69        .and_then(|v| v.timestamp)
70        .unwrap_or(0);
71    let time = DateTime::from_timestamp_millis(ts).unwrap_or_else(wacore::time::now_utc);
72    let jid: Jid = if m.index.len() > 1 {
73        match m.index[1].parse() {
74            Ok(j) => j,
75            Err(_) => {
76                log::warn!(
77                    "Skipping chat mutation '{}': malformed JID '{}'",
78                    kind,
79                    m.index[1]
80                );
81                return true; // consumed but not dispatched
82            }
83        }
84    } else {
85        log::warn!("Skipping chat mutation '{}': missing JID in index", kind);
86        return true;
87    };
88
89    match kind.as_str() {
90        "mute" => {
91            if let Some(val) = &m.action_value
92                && let Some(act) = &val.mute_action
93            {
94                event_bus.dispatch(&Event::MuteUpdate(MuteUpdate {
95                    jid,
96                    timestamp: time,
97                    action: Box::new(*act),
98                    from_full_sync: full_sync,
99                }));
100            }
101            true
102        }
103        "pin" | "pin_v1" => {
104            if let Some(val) = &m.action_value
105                && let Some(act) = &val.pin_action
106            {
107                event_bus.dispatch(&Event::PinUpdate(PinUpdate {
108                    jid,
109                    timestamp: time,
110                    action: Box::new(*act),
111                    from_full_sync: full_sync,
112                }));
113            }
114            true
115        }
116        "archive" => {
117            if let Some(val) = &m.action_value
118                && let Some(act) = &val.archive_chat_action
119            {
120                event_bus.dispatch(&Event::ArchiveUpdate(ArchiveUpdate {
121                    jid,
122                    timestamp: time,
123                    action: Box::new(act.clone()),
124                    from_full_sync: full_sync,
125                }));
126            }
127            true
128        }
129        "star" => {
130            // Star index: ["star", chatJid, messageId, fromMe, participant]
131            // See WAWebSyncdUtils.constructMsgKeySegmentsFromMsgKey
132            if let Some(val) = &m.action_value
133                && let Some(act) = &val.star_action
134                && m.index.len() >= 5
135            {
136                let chat_jid: Jid = match m.index[1].parse() {
137                    Ok(j) => j,
138                    Err(_) => {
139                        log::warn!(
140                            "Skipping star mutation: malformed chat JID '{}'",
141                            m.index[1]
142                        );
143                        return true;
144                    }
145                };
146                let message_id = m.index[2].clone();
147                let from_me = m.index[3] == "1";
148                // Participant is the actual sender for group messages from others.
149                // "0" means self-authored or 1-on-1 → None.
150                let participant_jid: Option<Jid> = if m.index[4] != "0" {
151                    match m.index[4].parse() {
152                        Ok(j) => Some(j),
153                        Err(_) => {
154                            log::warn!(
155                                "Skipping star mutation: malformed participant JID '{}'",
156                                m.index[4]
157                            );
158                            return true;
159                        }
160                    }
161                } else {
162                    None
163                };
164
165                event_bus.dispatch(&Event::StarUpdate(StarUpdate {
166                    chat_jid,
167                    participant_jid,
168                    message_id,
169                    from_me,
170                    timestamp: time,
171                    action: Box::new(*act),
172                    from_full_sync: full_sync,
173                }));
174            }
175            true
176        }
177        "contact" => {
178            if let Some(val) = &m.action_value
179                && let Some(act) = &val.contact_action
180            {
181                event_bus.dispatch(&Event::ContactUpdate(ContactUpdate {
182                    jid,
183                    timestamp: time,
184                    action: Box::new(act.clone()),
185                    from_full_sync: full_sync,
186                }));
187            }
188            true
189        }
190        "mark_chat_as_read" | "markChatAsRead" => {
191            if let Some(val) = &m.action_value
192                && let Some(act) = &val.mark_chat_as_read_action
193            {
194                event_bus.dispatch(&Event::MarkChatAsReadUpdate(MarkChatAsReadUpdate {
195                    jid,
196                    timestamp: time,
197                    action: Box::new(act.clone()),
198                    from_full_sync: full_sync,
199                }));
200            }
201            true
202        }
203        _ => false,
204    }
205}
206
207// ── Public API ───────────────────────────────────────────────────────
208
209/// Feature handle for chat management actions.
210///
211/// Access via `client.chat_actions()` (requires `Arc<Client>`).
212pub struct ChatActions<'a> {
213    client: &'a Arc<Client>,
214}
215
216impl<'a> ChatActions<'a> {
217    pub(crate) fn new(client: &'a Arc<Client>) -> Self {
218        Self { client }
219    }
220
221    // ── Archive ──────────────────────────────────────────────────────
222
223    /// Archive a chat.
224    pub async fn archive_chat(&self, jid: &Jid) -> Result<()> {
225        debug!("Archiving chat {jid}");
226        self.send_archive_mutation(jid, true).await
227    }
228
229    /// Unarchive a chat.
230    pub async fn unarchive_chat(&self, jid: &Jid) -> Result<()> {
231        debug!("Unarchiving chat {jid}");
232        self.send_archive_mutation(jid, false).await
233    }
234
235    // ── Pin ──────────────────────────────────────────────────────────
236
237    /// Pin a chat.
238    pub async fn pin_chat(&self, jid: &Jid) -> Result<()> {
239        debug!("Pinning chat {jid}");
240        self.send_pin_mutation(jid, true).await
241    }
242
243    /// Unpin a chat.
244    pub async fn unpin_chat(&self, jid: &Jid) -> Result<()> {
245        debug!("Unpinning chat {jid}");
246        self.send_pin_mutation(jid, false).await
247    }
248
249    // ── Mute ─────────────────────────────────────────────────────────
250
251    /// Mute a chat indefinitely.
252    pub async fn mute_chat(&self, jid: &Jid) -> Result<()> {
253        debug!("Muting chat {jid} indefinitely");
254        self.send_mute_mutation(jid, true, MUTE_INDEFINITE).await
255    }
256
257    /// Mute a chat until a specific timestamp (Unix milliseconds).
258    ///
259    /// The timestamp must be in the future. Use [`mute_chat`](Self::mute_chat)
260    /// for indefinite muting.
261    pub async fn mute_chat_until(&self, jid: &Jid, mute_end_timestamp_ms: i64) -> Result<()> {
262        if mute_end_timestamp_ms <= 0 {
263            anyhow::bail!(
264                "mute_end_timestamp_ms must be a positive future timestamp (use mute_chat() for indefinite)"
265            );
266        }
267        let now_ms = wacore::time::now_millis();
268        if mute_end_timestamp_ms <= now_ms {
269            anyhow::bail!(
270                "mute_end_timestamp_ms is in the past ({mute_end_timestamp_ms} <= {now_ms})"
271            );
272        }
273        debug!("Muting chat {jid} until {mute_end_timestamp_ms}");
274        self.send_mute_mutation(jid, true, mute_end_timestamp_ms)
275            .await
276    }
277
278    /// Unmute a chat.
279    pub async fn unmute_chat(&self, jid: &Jid) -> Result<()> {
280        debug!("Unmuting chat {jid}");
281        self.send_mute_mutation(jid, false, 0).await
282    }
283
284    // ── Star ─────────────────────────────────────────────────────────
285
286    /// Star a message.
287    ///
288    /// - `chat_jid`: The chat containing the message.
289    /// - `participant_jid`: For group messages from others, pass `Some(&sender_jid)`.
290    ///   For 1-on-1 or own messages, pass `None` (the protocol uses `"0"`).
291    /// - `message_id`: The message ID to star.
292    /// - `from_me`: Whether the message was sent by us.
293    pub async fn star_message(
294        &self,
295        chat_jid: &Jid,
296        participant_jid: Option<&Jid>,
297        message_id: &str,
298        from_me: bool,
299    ) -> Result<()> {
300        debug!("Starring message {message_id} in {chat_jid}");
301        self.send_star_mutation(chat_jid, participant_jid, message_id, from_me, true)
302            .await
303    }
304
305    /// Unstar a message.
306    ///
307    /// Parameters are the same as [`star_message`](Self::star_message).
308    pub async fn unstar_message(
309        &self,
310        chat_jid: &Jid,
311        participant_jid: Option<&Jid>,
312        message_id: &str,
313        from_me: bool,
314    ) -> Result<()> {
315        debug!("Unstarring message {message_id} in {chat_jid}");
316        self.send_star_mutation(chat_jid, participant_jid, message_id, from_me, false)
317            .await
318    }
319
320    // ── Internal helpers ─────────────────────────────────────────────
321
322    async fn send_archive_mutation(&self, jid: &Jid, archived: bool) -> Result<()> {
323        let index = serde_json::to_vec(&["archive", &jid.to_string()])?;
324        let value = wa::SyncActionValue {
325            archive_chat_action: Some(wa::sync_action_value::ArchiveChatAction {
326                archived: Some(archived),
327                message_range: None,
328            }),
329            timestamp: Some(wacore::time::now_millis()),
330            ..Default::default()
331        };
332        self.send_mutation(WAPatchName::RegularLow, &index, &value)
333            .await
334    }
335
336    async fn send_pin_mutation(&self, jid: &Jid, pinned: bool) -> Result<()> {
337        let index = serde_json::to_vec(&["pin_v1", &jid.to_string()])?;
338        let value = wa::SyncActionValue {
339            pin_action: Some(wa::sync_action_value::PinAction {
340                pinned: Some(pinned),
341            }),
342            timestamp: Some(wacore::time::now_millis()),
343            ..Default::default()
344        };
345        self.send_mutation(WAPatchName::RegularLow, &index, &value)
346            .await
347    }
348
349    async fn send_mute_mutation(
350        &self,
351        jid: &Jid,
352        muted: bool,
353        mute_end_timestamp_ms: i64,
354    ) -> Result<()> {
355        let index = serde_json::to_vec(&["mute", &jid.to_string()])?;
356        // WhatsApp Web requires muteEndTimestamp to always be present when muted=true.
357        // -1 means indefinite, 0 means unmuted, positive means expiry in milliseconds.
358        let mute_end = if muted {
359            Some(mute_end_timestamp_ms)
360        } else {
361            Some(0)
362        };
363        let value = wa::SyncActionValue {
364            mute_action: Some(wa::sync_action_value::MuteAction {
365                muted: Some(muted),
366                mute_end_timestamp: mute_end,
367                auto_muted: None,
368            }),
369            timestamp: Some(wacore::time::now_millis()),
370            ..Default::default()
371        };
372        self.send_mutation(WAPatchName::RegularHigh, &index, &value)
373            .await
374    }
375
376    async fn send_star_mutation(
377        &self,
378        chat_jid: &Jid,
379        participant_jid: Option<&Jid>,
380        message_id: &str,
381        from_me: bool,
382        starred: bool,
383    ) -> Result<()> {
384        if chat_jid.is_group() && !from_me && participant_jid.is_none() {
385            anyhow::bail!(
386                "participant_jid is required when starring a group message not sent by us"
387            );
388        }
389        // WhatsApp Web star index order: ["star", chatJid, messageId, fromMe, participant]
390        // participant = sender JID for group messages from others, "0" otherwise.
391        // See WAWebSyncdUtils.constructMsgKeySegmentsFromMsgKey + extractParticipantForSync
392        let from_me_str = if from_me { "1" } else { "0" };
393        let participant = participant_jid
394            .map(|j| j.to_string())
395            .unwrap_or_else(|| "0".to_string());
396        let index = serde_json::to_vec(&[
397            "star",
398            &chat_jid.to_string(),
399            message_id,
400            from_me_str,
401            &participant,
402        ])?;
403        let value = wa::SyncActionValue {
404            star_action: Some(wa::sync_action_value::StarAction {
405                starred: Some(starred),
406            }),
407            timestamp: Some(wacore::time::now_millis()),
408            ..Default::default()
409        };
410        self.send_mutation(WAPatchName::RegularHigh, &index, &value)
411            .await
412    }
413
414    /// Encode and send an app state mutation to the given collection.
415    async fn send_mutation(
416        &self,
417        collection: WAPatchName,
418        index: &[u8],
419        value: &wa::SyncActionValue,
420    ) -> Result<()> {
421        use rand::Rng;
422        use wacore::appstate::encode::encode_record;
423
424        let proc = self.client.get_app_state_processor().await;
425        let key_id = proc
426            .backend
427            .get_latest_sync_key_id()
428            .await
429            .map_err(|e| anyhow::anyhow!(e))?
430            .ok_or_else(|| anyhow::anyhow!("No app state sync key available"))?;
431        let keys = proc.get_app_state_key(&key_id).await?;
432
433        let mut iv = [0u8; 16];
434        rand::make_rng::<rand::rngs::StdRng>().fill_bytes(&mut iv);
435
436        let (mutation, value_mac) = encode_record(
437            wa::syncd_mutation::SyncdOperation::Set,
438            index,
439            value,
440            &keys,
441            &key_id,
442            &iv,
443        );
444
445        self.client
446            .send_app_state_patch(collection.as_str(), vec![(mutation, value_mac)])
447            .await
448    }
449}
450
451impl Client {
452    /// Access chat management actions (archive, pin, mute, star).
453    ///
454    /// Requires `Arc<Client>` because app state mutations need key access.
455    pub fn chat_actions(self: &Arc<Self>) -> ChatActions<'_> {
456        ChatActions::new(self)
457    }
458}