Skip to main content

whatsapp_rust/features/
chat_actions.rs

1//! Chat management via app state sync (syncd).
2//!
3//! ## Collections (from WhatsApp Web JS)
4//! - `regular_low`: archive, pin, markChatAsRead
5//! - `regular_high`: mute, star, deleteChat, deleteMessageForMe
6
7use crate::appstate_sync::Mutation;
8use crate::client::Client;
9use anyhow::Result;
10use chrono::DateTime;
11use log::debug;
12use wacore::appstate::patch_decode::WAPatchName;
13use wacore::types::events::{
14    ArchiveUpdate, ContactUpdate, DeleteChatUpdate, DeleteMessageForMeUpdate, Event,
15    MarkChatAsReadUpdate, MuteUpdate, PinUpdate, StarUpdate,
16};
17use wacore_binary::jid::{Jid, JidExt};
18use waproto::whatsapp as wa;
19
20/// WA Web uses `-1` for indefinite mute.
21const MUTE_INDEFINITE: i64 = -1;
22
23pub type SyncActionMessageRange = wa::sync_action_value::SyncActionMessageRange;
24
25/// Enables multi-device conflict resolution. `None` is safe (matches whatsmeow/Baileys).
26/// Only WA Web (with a full message DB) populates this.
27pub fn message_range(
28    last_message_timestamp: i64,
29    last_system_message_timestamp: Option<i64>,
30    messages: Vec<(wa::MessageKey, i64)>,
31) -> SyncActionMessageRange {
32    SyncActionMessageRange {
33        last_message_timestamp: Some(last_message_timestamp),
34        last_system_message_timestamp,
35        messages: messages
36            .into_iter()
37            .map(|(key, ts)| wa::sync_action_value::SyncActionMessage {
38                key: Some(key),
39                timestamp: Some(ts),
40            })
41            .collect(),
42    }
43}
44
45pub fn message_key(
46    id: impl Into<String>,
47    remote_jid: &Jid,
48    from_me: bool,
49    participant: Option<&Jid>,
50) -> wa::MessageKey {
51    wa::MessageKey {
52        id: Some(id.into()),
53        remote_jid: Some(remote_jid.to_string()),
54        from_me: Some(from_me),
55        participant: participant.map(|j| j.to_string()),
56    }
57}
58
59/// Returns `true` if handled, `false` if unknown (so other handlers can try).
60pub(crate) fn dispatch_chat_mutation(
61    event_bus: &wacore::types::events::CoreEventBus,
62    m: &Mutation,
63    full_sync: bool,
64) -> bool {
65    if m.operation != wa::syncd_mutation::SyncdOperation::Set || m.index.is_empty() {
66        return false;
67    }
68
69    let kind = &m.index[0];
70
71    if !matches!(
72        kind.as_str(),
73        "mute"
74            | "pin"
75            | "pin_v1"
76            | "archive"
77            | "star"
78            | "contact"
79            | "mark_chat_as_read"
80            | "markChatAsRead"
81            | "deleteChat"
82            | "deleteMessageForMe"
83    ) {
84        return false;
85    }
86
87    let ts = m
88        .action_value
89        .as_ref()
90        .and_then(|v| v.timestamp)
91        .unwrap_or(0);
92    let time = DateTime::from_timestamp_millis(ts).unwrap_or_else(wacore::time::now_utc);
93    let jid: Jid = if m.index.len() > 1 {
94        match m.index[1].parse() {
95            Ok(j) => j,
96            Err(_) => {
97                log::warn!(
98                    "Skipping chat mutation '{}': malformed JID '{}'",
99                    kind,
100                    m.index[1]
101                );
102                return true;
103            }
104        }
105    } else {
106        log::warn!("Skipping chat mutation '{}': missing JID in index", kind);
107        return true;
108    };
109
110    match kind.as_str() {
111        "mute" => {
112            if let Some(val) = &m.action_value
113                && let Some(act) = &val.mute_action
114            {
115                event_bus.dispatch(&Event::MuteUpdate(MuteUpdate {
116                    jid,
117                    timestamp: time,
118                    action: Box::new(*act),
119                    from_full_sync: full_sync,
120                }));
121            }
122            true
123        }
124        "pin" | "pin_v1" => {
125            if let Some(val) = &m.action_value
126                && let Some(act) = &val.pin_action
127            {
128                event_bus.dispatch(&Event::PinUpdate(PinUpdate {
129                    jid,
130                    timestamp: time,
131                    action: Box::new(*act),
132                    from_full_sync: full_sync,
133                }));
134            }
135            true
136        }
137        "archive" => {
138            if let Some(val) = &m.action_value
139                && let Some(act) = &val.archive_chat_action
140            {
141                event_bus.dispatch(&Event::ArchiveUpdate(ArchiveUpdate {
142                    jid,
143                    timestamp: time,
144                    action: Box::new(act.clone()),
145                    from_full_sync: full_sync,
146                }));
147            }
148            true
149        }
150        "star" => {
151            if let Some(val) = &m.action_value
152                && let Some(act) = &val.star_action
153                && let Some((message_id, from_me, participant_jid)) =
154                    parse_message_key_fields(kind, &m.index)
155            {
156                event_bus.dispatch(&Event::StarUpdate(StarUpdate {
157                    chat_jid: jid,
158                    participant_jid,
159                    message_id,
160                    from_me,
161                    timestamp: time,
162                    action: Box::new(*act),
163                    from_full_sync: full_sync,
164                }));
165            }
166            true
167        }
168        "contact" => {
169            if let Some(val) = &m.action_value
170                && let Some(act) = &val.contact_action
171            {
172                event_bus.dispatch(&Event::ContactUpdate(ContactUpdate {
173                    jid,
174                    timestamp: time,
175                    action: Box::new(act.clone()),
176                    from_full_sync: full_sync,
177                }));
178            }
179            true
180        }
181        "mark_chat_as_read" | "markChatAsRead" => {
182            if let Some(val) = &m.action_value
183                && let Some(act) = &val.mark_chat_as_read_action
184            {
185                event_bus.dispatch(&Event::MarkChatAsReadUpdate(MarkChatAsReadUpdate {
186                    jid,
187                    timestamp: time,
188                    action: Box::new(act.clone()),
189                    from_full_sync: full_sync,
190                }));
191            }
192            true
193        }
194        "deleteChat" => {
195            if let Some(val) = &m.action_value
196                && let Some(act) = &val.delete_chat_action
197            {
198                // delete_media is in index[2], not in the proto (which only has messageRange)
199                let delete_media = m.index.get(2).is_none_or(|v| v != "0");
200                event_bus.dispatch(&Event::DeleteChatUpdate(DeleteChatUpdate {
201                    jid,
202                    delete_media,
203                    timestamp: time,
204                    action: Box::new(act.clone()),
205                    from_full_sync: full_sync,
206                }));
207            }
208            true
209        }
210        "deleteMessageForMe" => {
211            if let Some(val) = &m.action_value
212                && let Some(act) = &val.delete_message_for_me_action
213                && let Some((message_id, from_me, participant_jid)) =
214                    parse_message_key_fields(kind, &m.index)
215            {
216                event_bus.dispatch(&Event::DeleteMessageForMeUpdate(DeleteMessageForMeUpdate {
217                    chat_jid: jid,
218                    participant_jid,
219                    message_id,
220                    from_me,
221                    timestamp: time,
222                    action: Box::new(*act),
223                    from_full_sync: full_sync,
224                }));
225            }
226            true
227        }
228        _ => false,
229    }
230}
231
232/// Parse message-key fields (messageId, fromMe, participant) from index positions 2-4.
233/// Returns `None` (with a warning log) if the index is too short or participant is malformed.
234fn parse_message_key_fields(kind: &str, index: &[String]) -> Option<(String, bool, Option<Jid>)> {
235    if index.len() < 5 {
236        log::warn!(
237            "Skipping {kind} mutation: expected 5 index elements, got {}",
238            index.len()
239        );
240        return None;
241    }
242    let message_id = index[2].clone();
243    let from_me = index[3] == "1";
244    let participant_jid = if index[4] != "0" {
245        match index[4].parse() {
246            Ok(j) => Some(j),
247            Err(_) => {
248                log::warn!(
249                    "Skipping {kind} mutation: malformed participant JID '{}'",
250                    index[4]
251                );
252                return None;
253            }
254        }
255    } else {
256        None
257    };
258    Some((message_id, from_me, participant_jid))
259}
260
261/// Mirrors WAWebSyncdActionUtils.buildMessageKey.
262fn build_message_key_index(
263    action: &str,
264    chat_jid: &Jid,
265    participant_jid: Option<&Jid>,
266    message_id: &str,
267    from_me: bool,
268) -> Result<Vec<u8>> {
269    // syncKeyToMsgKey rejects group non-fromMe without valid participant
270    if chat_jid.is_group() && !from_me && participant_jid.is_none() {
271        anyhow::bail!(
272            "participant_jid is required for group messages not sent by us (action: {action})"
273        );
274    }
275    let from_me_str = if from_me { "1" } else { "0" };
276    let participant = participant_jid
277        .map(|j| j.to_string())
278        .unwrap_or_else(|| "0".to_string());
279    Ok(serde_json::to_vec(&[
280        action,
281        &chat_jid.to_string(),
282        message_id,
283        from_me_str,
284        &participant,
285    ])?)
286}
287
288/// Access via `client.chat_actions()`.
289pub struct ChatActions<'a> {
290    client: &'a Client,
291}
292
293impl<'a> ChatActions<'a> {
294    pub(crate) fn new(client: &'a Client) -> Self {
295        Self { client }
296    }
297
298    pub async fn archive_chat(
299        &self,
300        jid: &Jid,
301        message_range: Option<SyncActionMessageRange>,
302    ) -> Result<()> {
303        debug!("Archiving chat {jid}");
304        self.send_archive_mutation(jid, true, message_range).await
305    }
306
307    pub async fn unarchive_chat(
308        &self,
309        jid: &Jid,
310        message_range: Option<SyncActionMessageRange>,
311    ) -> Result<()> {
312        debug!("Unarchiving chat {jid}");
313        self.send_archive_mutation(jid, false, message_range).await
314    }
315
316    pub async fn pin_chat(&self, jid: &Jid) -> Result<()> {
317        debug!("Pinning chat {jid}");
318        self.send_pin_mutation(jid, true).await
319    }
320
321    pub async fn unpin_chat(&self, jid: &Jid) -> Result<()> {
322        debug!("Unpinning chat {jid}");
323        self.send_pin_mutation(jid, false).await
324    }
325
326    pub async fn mute_chat(&self, jid: &Jid) -> Result<()> {
327        debug!("Muting chat {jid} indefinitely");
328        self.send_mute_mutation(jid, true, MUTE_INDEFINITE).await
329    }
330
331    /// Must be in the future. Use [`mute_chat`](Self::mute_chat) for indefinite.
332    pub async fn mute_chat_until(&self, jid: &Jid, mute_end_timestamp_ms: i64) -> Result<()> {
333        if mute_end_timestamp_ms <= 0 {
334            anyhow::bail!(
335                "mute_end_timestamp_ms must be a positive future timestamp (use mute_chat() for indefinite)"
336            );
337        }
338        let now_ms = wacore::time::now_millis();
339        if mute_end_timestamp_ms <= now_ms {
340            anyhow::bail!(
341                "mute_end_timestamp_ms is in the past ({mute_end_timestamp_ms} <= {now_ms})"
342            );
343        }
344        debug!("Muting chat {jid} until {mute_end_timestamp_ms}");
345        self.send_mute_mutation(jid, true, mute_end_timestamp_ms)
346            .await
347    }
348
349    pub async fn unmute_chat(&self, jid: &Jid) -> Result<()> {
350        debug!("Unmuting chat {jid}");
351        self.send_mute_mutation(jid, false, 0).await
352    }
353
354    /// `participant_jid`: required for group messages from others, `None` otherwise.
355    pub async fn star_message(
356        &self,
357        chat_jid: &Jid,
358        participant_jid: Option<&Jid>,
359        message_id: &str,
360        from_me: bool,
361    ) -> Result<()> {
362        debug!("Starring message {message_id} in {chat_jid}");
363        self.send_star_mutation(chat_jid, participant_jid, message_id, from_me, true)
364            .await
365    }
366
367    pub async fn unstar_message(
368        &self,
369        chat_jid: &Jid,
370        participant_jid: Option<&Jid>,
371        message_id: &str,
372        from_me: bool,
373    ) -> Result<()> {
374        debug!("Unstarring message {message_id} in {chat_jid}");
375        self.send_star_mutation(chat_jid, participant_jid, message_id, from_me, false)
376            .await
377    }
378
379    /// Distinct from `readMessages` IQ receipts — this syncs state across linked devices.
380    pub async fn mark_chat_as_read(
381        &self,
382        jid: &Jid,
383        read: bool,
384        message_range: Option<SyncActionMessageRange>,
385    ) -> Result<()> {
386        debug!(
387            "Marking chat {jid} as {}",
388            if read { "read" } else { "unread" }
389        );
390        let index = serde_json::to_vec(&["markChatAsRead", &jid.to_string()])?;
391        let value = wa::SyncActionValue {
392            mark_chat_as_read_action: Some(wa::sync_action_value::MarkChatAsReadAction {
393                read: Some(read),
394                message_range,
395            }),
396            timestamp: Some(wacore::time::now_millis()),
397            ..Default::default()
398        };
399        self.send_mutation(WAPatchName::RegularLow, &index, &value)
400            .await
401    }
402
403    pub async fn delete_chat(
404        &self,
405        jid: &Jid,
406        delete_media: bool,
407        message_range: Option<SyncActionMessageRange>,
408    ) -> Result<()> {
409        debug!("Deleting chat {jid}");
410        let delete_media_str = if delete_media { "1" } else { "0" };
411        let index = serde_json::to_vec(&["deleteChat", &jid.to_string(), delete_media_str])?;
412        let value = wa::SyncActionValue {
413            delete_chat_action: Some(wa::sync_action_value::DeleteChatAction { message_range }),
414            timestamp: Some(wacore::time::now_millis()),
415            ..Default::default()
416        };
417        self.send_mutation(WAPatchName::RegularHigh, &index, &value)
418            .await
419    }
420
421    /// Deletes locally only (not for everyone).
422    /// `participant_jid`: required for group messages from others, `None` otherwise.
423    pub async fn delete_message_for_me(
424        &self,
425        chat_jid: &Jid,
426        participant_jid: Option<&Jid>,
427        message_id: &str,
428        from_me: bool,
429        delete_media: bool,
430        message_timestamp: Option<i64>,
431    ) -> Result<()> {
432        debug!("Deleting message {message_id} for me in {chat_jid}");
433        let index = build_message_key_index(
434            "deleteMessageForMe",
435            chat_jid,
436            participant_jid,
437            message_id,
438            from_me,
439        )?;
440        let value = wa::SyncActionValue {
441            delete_message_for_me_action: Some(wa::sync_action_value::DeleteMessageForMeAction {
442                delete_media: Some(delete_media),
443                message_timestamp,
444            }),
445            timestamp: Some(wacore::time::now_millis()),
446            ..Default::default()
447        };
448        self.send_mutation(WAPatchName::RegularHigh, &index, &value)
449            .await
450    }
451
452    async fn send_archive_mutation(
453        &self,
454        jid: &Jid,
455        archived: bool,
456        message_range: Option<SyncActionMessageRange>,
457    ) -> Result<()> {
458        let index = serde_json::to_vec(&["archive", &jid.to_string()])?;
459        let value = wa::SyncActionValue {
460            archive_chat_action: Some(wa::sync_action_value::ArchiveChatAction {
461                archived: Some(archived),
462                message_range,
463            }),
464            timestamp: Some(wacore::time::now_millis()),
465            ..Default::default()
466        };
467        self.send_mutation(WAPatchName::RegularLow, &index, &value)
468            .await
469    }
470
471    async fn send_pin_mutation(&self, jid: &Jid, pinned: bool) -> Result<()> {
472        let index = serde_json::to_vec(&["pin_v1", &jid.to_string()])?;
473        let value = wa::SyncActionValue {
474            pin_action: Some(wa::sync_action_value::PinAction {
475                pinned: Some(pinned),
476            }),
477            timestamp: Some(wacore::time::now_millis()),
478            ..Default::default()
479        };
480        self.send_mutation(WAPatchName::RegularLow, &index, &value)
481            .await
482    }
483
484    async fn send_mute_mutation(
485        &self,
486        jid: &Jid,
487        muted: bool,
488        mute_end_timestamp_ms: i64,
489    ) -> Result<()> {
490        let index = serde_json::to_vec(&["mute", &jid.to_string()])?;
491        // -1 = indefinite, 0 = unmuted, positive = expiry ms
492        let mute_end = if muted {
493            Some(mute_end_timestamp_ms)
494        } else {
495            Some(0)
496        };
497        let value = wa::SyncActionValue {
498            mute_action: Some(wa::sync_action_value::MuteAction {
499                muted: Some(muted),
500                mute_end_timestamp: mute_end,
501                auto_muted: None,
502            }),
503            timestamp: Some(wacore::time::now_millis()),
504            ..Default::default()
505        };
506        self.send_mutation(WAPatchName::RegularHigh, &index, &value)
507            .await
508    }
509
510    async fn send_star_mutation(
511        &self,
512        chat_jid: &Jid,
513        participant_jid: Option<&Jid>,
514        message_id: &str,
515        from_me: bool,
516        starred: bool,
517    ) -> Result<()> {
518        let index =
519            build_message_key_index("star", chat_jid, participant_jid, message_id, from_me)?;
520        let value = wa::SyncActionValue {
521            star_action: Some(wa::sync_action_value::StarAction {
522                starred: Some(starred),
523            }),
524            timestamp: Some(wacore::time::now_millis()),
525            ..Default::default()
526        };
527        self.send_mutation(WAPatchName::RegularHigh, &index, &value)
528            .await
529    }
530
531    async fn send_mutation(
532        &self,
533        collection: WAPatchName,
534        index: &[u8],
535        value: &wa::SyncActionValue,
536    ) -> Result<()> {
537        use rand::Rng;
538        use wacore::appstate::encode::encode_record;
539
540        let proc = self.client.get_app_state_processor().await;
541        let key_id = proc
542            .backend
543            .get_latest_sync_key_id()
544            .await
545            .map_err(|e| anyhow::anyhow!(e))?
546            .ok_or_else(|| anyhow::anyhow!("No app state sync key available"))?;
547        let keys = proc.get_app_state_key(&key_id).await?;
548
549        let mut iv = [0u8; 16];
550        rand::make_rng::<rand::rngs::StdRng>().fill_bytes(&mut iv);
551
552        let (mutation, value_mac) = encode_record(
553            wa::syncd_mutation::SyncdOperation::Set,
554            index,
555            value,
556            &keys,
557            &key_id,
558            &iv,
559        );
560
561        self.client
562            .send_app_state_patch(collection.as_str(), vec![(mutation, value_mac)])
563            .await
564    }
565}
566
567impl Client {
568    pub fn chat_actions(&self) -> ChatActions<'_> {
569        ChatActions::new(self)
570    }
571}