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 log::debug;
11use thiserror::Error;
12use wacore::appstate::patch_decode::WAPatchName;
13use wacore::appstate::schemas::{self, IndexPart, Schema};
14use wacore::types::events::{
15    ArchiveUpdate, ClearChatUpdate, ContactUpdate, DeleteChatUpdate, DeleteMessageForMeUpdate,
16    Event, MarkChatAsReadUpdate, MuteUpdate, PinUpdate, StarUpdate, UserStatusMuteUpdate,
17};
18use wacore_binary::{Jid, JidExt};
19use waproto::whatsapp as wa;
20
21/// Error returned by app-state (syncd) mutations — the shared failure domain
22/// of chat actions ([`ChatActions`]) and labels ([`crate::Labels`]).
23#[derive(Debug, Error)]
24#[non_exhaustive]
25pub enum AppStateError {
26    /// The mutation arguments are invalid (e.g. a past mute timestamp, a
27    /// non-phone-number contact id, a missing group participant, an empty
28    /// label id).
29    #[error("invalid app-state request: {0}")]
30    InvalidRequest(String),
31    /// Encoding, key lookup, or sending the app-state patch failed.
32    #[error("{0}")]
33    Internal(#[from] anyhow::Error),
34}
35
36/// WA Web uses `-1` for indefinite mute.
37const MUTE_INDEFINITE: i64 = -1;
38
39pub type SyncActionMessageRange = wa::sync_action_value::SyncActionMessageRange;
40
41/// Enables multi-device conflict resolution. `None` is safe for clients without
42/// a complete message database; callers with one can populate the range.
43pub fn message_range(
44    last_message_timestamp: i64,
45    last_system_message_timestamp: Option<i64>,
46    messages: Vec<(wa::MessageKey, i64)>,
47) -> SyncActionMessageRange {
48    SyncActionMessageRange {
49        last_message_timestamp: Some(last_message_timestamp),
50        last_system_message_timestamp,
51        messages: messages
52            .into_iter()
53            .map(|(key, ts)| wa::sync_action_value::SyncActionMessage {
54                key: buffa::MessageField::some(key),
55                timestamp: Some(ts),
56            })
57            .collect(),
58    }
59}
60
61pub fn message_key(
62    id: impl Into<String>,
63    remote_jid: &Jid,
64    from_me: bool,
65    participant: Option<&Jid>,
66) -> wa::MessageKey {
67    wa::MessageKey {
68        id: Some(id.into()),
69        remote_jid: Some(remote_jid.to_string()),
70        from_me: Some(from_me),
71        participant: participant.map(|j| j.to_string()),
72    }
73}
74
75/// Returns `true` if handled, `false` if unknown (so other handlers can try).
76pub(crate) fn dispatch_chat_mutation(
77    event_bus: &wacore::types::events::CoreEventBus,
78    m: &Mutation,
79    full_sync: bool,
80) -> bool {
81    if m.operation != wa::syncd_mutation::SyncdOperation::SET || m.index.is_empty() {
82        return false;
83    }
84
85    let kind = &m.index[0];
86
87    if !matches!(
88        kind.as_str(),
89        "mute"
90            | "pin"
91            | "pin_v1"
92            | "archive"
93            | "star"
94            | "contact"
95            | "mark_chat_as_read"
96            | "markChatAsRead"
97            | "deleteChat"
98            | "clearChat"
99            | "userStatusMute"
100            | "deleteMessageForMe"
101    ) {
102        return false;
103    }
104
105    let ts = m
106        .action_value
107        .as_ref()
108        .and_then(|v| v.timestamp)
109        .unwrap_or(0);
110    let time = wacore::time::from_millis_or_now(ts);
111    let jid: Jid = if m.index.len() > 1 {
112        match m.index[1].parse() {
113            Ok(j) => j,
114            Err(_) => {
115                log::warn!(
116                    "Skipping chat mutation '{}': malformed JID '{}'",
117                    kind,
118                    m.index[1]
119                );
120                return true;
121            }
122        }
123    } else {
124        log::warn!("Skipping chat mutation '{}': missing JID in index", kind);
125        return true;
126    };
127
128    match kind.as_str() {
129        "mute" => {
130            if let Some(val) = &m.action_value
131                && let Some(act) = val.mute_action.as_option()
132            {
133                event_bus.dispatch(Event::MuteUpdate(
134                    MuteUpdate::builder()
135                        .jid(jid)
136                        .timestamp(time)
137                        .action(Box::new(act.clone()))
138                        .from_full_sync(full_sync)
139                        .build(),
140                ));
141            }
142            true
143        }
144        "pin" | "pin_v1" => {
145            if let Some(val) = &m.action_value
146                && let Some(act) = val.pin_action.as_option()
147            {
148                event_bus.dispatch(Event::PinUpdate(
149                    PinUpdate::builder()
150                        .jid(jid)
151                        .timestamp(time)
152                        .action(Box::new(act.clone()))
153                        .from_full_sync(full_sync)
154                        .build(),
155                ));
156            }
157            true
158        }
159        "archive" => {
160            if let Some(val) = &m.action_value
161                && let Some(act) = val.archive_chat_action.as_option()
162            {
163                event_bus.dispatch(Event::ArchiveUpdate(
164                    ArchiveUpdate::builder()
165                        .jid(jid)
166                        .timestamp(time)
167                        .action(Box::new(act.clone()))
168                        .from_full_sync(full_sync)
169                        .build(),
170                ));
171            }
172            true
173        }
174        "star" => {
175            if let Some(val) = &m.action_value
176                && let Some(act) = val.star_action.as_option()
177                && let Some((message_id, from_me, participant_jid)) =
178                    parse_message_key_fields(kind, &m.index)
179            {
180                event_bus.dispatch(Event::StarUpdate(
181                    StarUpdate::builder()
182                        .chat_jid(jid)
183                        .maybe_participant_jid(participant_jid)
184                        .message_id(message_id)
185                        .from_me(from_me)
186                        .timestamp(time)
187                        .action(Box::new(act.clone()))
188                        .from_full_sync(full_sync)
189                        .build(),
190                ));
191            }
192            true
193        }
194        "contact" => {
195            if let Some(val) = &m.action_value
196                && let Some(act) = val.contact_action.as_option()
197            {
198                event_bus.dispatch(Event::ContactUpdate(
199                    ContactUpdate::builder()
200                        .jid(jid)
201                        .timestamp(time)
202                        .action(Box::new(act.clone()))
203                        .from_full_sync(full_sync)
204                        .build(),
205                ));
206            }
207            true
208        }
209        "mark_chat_as_read" | "markChatAsRead" => {
210            if let Some(val) = &m.action_value
211                && let Some(act) = val.mark_chat_as_read_action.as_option()
212            {
213                event_bus.dispatch(Event::MarkChatAsReadUpdate(
214                    MarkChatAsReadUpdate::builder()
215                        .jid(jid)
216                        .timestamp(time)
217                        .action(Box::new(act.clone()))
218                        .from_full_sync(full_sync)
219                        .build(),
220                ));
221            }
222            true
223        }
224        "deleteChat" => {
225            if let Some(val) = &m.action_value
226                && let Some(act) = val.delete_chat_action.as_option()
227            {
228                // delete_media is in index[2], not in the proto (which only has messageRange)
229                let delete_media = m.index.get(2).is_none_or(|v| v != "0");
230                event_bus.dispatch(Event::DeleteChatUpdate(
231                    DeleteChatUpdate::builder()
232                        .jid(jid)
233                        .delete_media(delete_media)
234                        .timestamp(time)
235                        .action(Box::new(act.clone()))
236                        .from_full_sync(full_sync)
237                        .build(),
238                ));
239            }
240            true
241        }
242        "clearChat" => {
243            if let Some(val) = &m.action_value
244                && let Some(act) = val.clear_chat_action.as_option()
245            {
246                // deleteStarred/deleteMedia live in the index (index[2]/index[3]),
247                // not in ClearChatAction (which only has messageRange). WA Web's send
248                // builder encodes both as "1"/"0".
249                let delete_starred = m.index.get(2).is_some_and(|v| v == "1");
250                let delete_media = m.index.get(3).is_some_and(|v| v == "1");
251                event_bus.dispatch(Event::ClearChatUpdate(
252                    ClearChatUpdate::builder()
253                        .jid(jid)
254                        .delete_starred(delete_starred)
255                        .delete_media(delete_media)
256                        .timestamp(time)
257                        .action(Box::new(act.clone()))
258                        .from_full_sync(full_sync)
259                        .build(),
260                ));
261            }
262            true
263        }
264        "userStatusMute" => {
265            if let Some(val) = &m.action_value
266                && let Some(act) = val.user_status_mute_action.as_option()
267            {
268                event_bus.dispatch(Event::UserStatusMuteUpdate(
269                    UserStatusMuteUpdate::builder()
270                        .jid(jid)
271                        .muted(act.muted.unwrap_or(false))
272                        .timestamp(time)
273                        .action(Box::new(act.clone()))
274                        .from_full_sync(full_sync)
275                        .build(),
276                ));
277            }
278            true
279        }
280        "deleteMessageForMe" => {
281            if let Some(val) = &m.action_value
282                && let Some(act) = val.delete_message_for_me_action.as_option()
283                && let Some((message_id, from_me, participant_jid)) =
284                    parse_message_key_fields(kind, &m.index)
285            {
286                event_bus.dispatch(Event::DeleteMessageForMeUpdate(
287                    DeleteMessageForMeUpdate::builder()
288                        .chat_jid(jid)
289                        .maybe_participant_jid(participant_jid)
290                        .message_id(message_id)
291                        .from_me(from_me)
292                        .timestamp(time)
293                        .action(Box::new(act.clone()))
294                        .from_full_sync(full_sync)
295                        .build(),
296                ));
297            }
298            true
299        }
300        _ => false,
301    }
302}
303
304/// Parse message-key fields (messageId, fromMe, participant) from index positions 2-4.
305/// Returns `None` (with a warning log) if the index is too short or participant is malformed.
306fn parse_message_key_fields(kind: &str, index: &[String]) -> Option<(String, bool, Option<Jid>)> {
307    if index.len() < 5 {
308        log::warn!(
309            "Skipping {kind} mutation: expected 5 index elements, got {}",
310            index.len()
311        );
312        return None;
313    }
314    let message_id = index[2].clone();
315    let from_me = index[3] == "1";
316    let participant_jid = if index[4] != "0" {
317        match index[4].parse() {
318            Ok(j) => Some(j),
319            Err(_) => {
320                log::warn!(
321                    "Skipping {kind} mutation: malformed participant JID '{}'",
322                    index[4]
323                );
324                return None;
325            }
326        }
327    } else {
328        None
329    };
330    Some((message_id, from_me, participant_jid))
331}
332
333/// Validate and own only the index args that must outlive the call: the chat JID
334/// and (optional) participant JID. `messageId` and `fromMe` are passed through by
335/// the caller without copying. Mirrors WAWebSyncdActionUtils.buildMessageKey.
336fn message_key_owned(
337    chat_jid: &Jid,
338    participant_jid: Option<&Jid>,
339    from_me: bool,
340) -> Result<(String, Option<String>)> {
341    // syncKeyToMsgKey rejects group non-fromMe without valid participant
342    if chat_jid.is_group() && !from_me && participant_jid.is_none() {
343        anyhow::bail!("participant_jid is required for group messages not sent by us");
344    }
345    Ok((chat_jid.to_string(), participant_jid.map(|j| j.to_string())))
346}
347
348/// The `"1"`/`"0"` wire string for a `fromMe` flag (no allocation).
349#[inline]
350fn bool_str(b: bool) -> &'static str {
351    if b { "1" } else { "0" }
352}
353
354/// Assemble the JSON-array mutation index for `schema` from its non-literal index
355/// args (in `schema.index_parts` order). The arg count must match.
356/// A `contact` app-state mutation is keyed by a bare phone-number JID. Reject
357/// LIDs (a separate WA Web path), group/status/broadcast/newsletter JIDs, and
358/// AD/device JIDs (e.g. `123:4@s.whatsapp.net`) that would form an invalid index.
359fn is_valid_contact_id(jid: &Jid) -> bool {
360    jid.is_pn() && jid.device == 0
361}
362
363pub(crate) fn build_action_index(schema: &Schema, args: &[&str]) -> Result<Vec<u8>> {
364    let non_literal = schema
365        .index_parts
366        .iter()
367        .filter(|p| !matches!(p, IndexPart::Literal { .. }))
368        .count();
369    if args.len() != non_literal {
370        anyhow::bail!(
371            "index args for action '{}': expected {non_literal}, got {}",
372            schema.name,
373            args.len()
374        );
375    }
376    let mut parts: Vec<&str> = Vec::with_capacity(schema.index_parts.len());
377    let mut it = args.iter();
378    for part in schema.index_parts {
379        match part {
380            IndexPart::Literal { value } => parts.push(value),
381            _ => parts.push(it.next().expect("arg count checked above")),
382        }
383    }
384    Ok(serde_json::to_vec(&parts)?)
385}
386
387/// Map a generated `Collection` to our `WAPatchName` (total — every generated
388/// collection has a `WAPatchName` counterpart).
389pub(crate) fn collection_patch_name(c: schemas::Collection) -> WAPatchName {
390    use schemas::Collection;
391    match c {
392        Collection::Regular => WAPatchName::Regular,
393        Collection::RegularLow => WAPatchName::RegularLow,
394        Collection::RegularHigh => WAPatchName::RegularHigh,
395        Collection::CriticalBlock => WAPatchName::CriticalBlock,
396        Collection::CriticalUnblockLow => WAPatchName::CriticalUnblockLow,
397    }
398}
399
400/// Access via `client.chat_actions()`.
401pub struct ChatActions<'a> {
402    client: &'a Client,
403}
404
405impl<'a> ChatActions<'a> {
406    pub(crate) fn new(client: &'a Client) -> Self {
407        Self { client }
408    }
409
410    pub async fn archive_chat(
411        &self,
412        jid: &Jid,
413        message_range: Option<SyncActionMessageRange>,
414    ) -> Result<(), AppStateError> {
415        debug!("Archiving chat {jid}");
416        self.send_archive_mutation(jid, true, message_range).await
417    }
418
419    pub async fn unarchive_chat(
420        &self,
421        jid: &Jid,
422        message_range: Option<SyncActionMessageRange>,
423    ) -> Result<(), AppStateError> {
424        debug!("Unarchiving chat {jid}");
425        self.send_archive_mutation(jid, false, message_range).await
426    }
427
428    pub async fn pin_chat(&self, jid: &Jid) -> Result<(), AppStateError> {
429        debug!("Pinning chat {jid}");
430        self.send_pin_mutation(jid, true).await
431    }
432
433    pub async fn unpin_chat(&self, jid: &Jid) -> Result<(), AppStateError> {
434        debug!("Unpinning chat {jid}");
435        self.send_pin_mutation(jid, false).await
436    }
437
438    pub async fn mute_chat(&self, jid: &Jid) -> Result<(), AppStateError> {
439        debug!("Muting chat {jid} indefinitely");
440        self.send_mute_mutation(jid, true, MUTE_INDEFINITE).await
441    }
442
443    /// Must be in the future. Use [`mute_chat`](Self::mute_chat) for indefinite.
444    pub async fn mute_chat_until(
445        &self,
446        jid: &Jid,
447        mute_end_timestamp_ms: i64,
448    ) -> Result<(), AppStateError> {
449        if mute_end_timestamp_ms <= 0 {
450            return Err(AppStateError::InvalidRequest(
451                "mute_end_timestamp_ms must be a positive future timestamp (use mute_chat() for indefinite)".into(),
452            ));
453        }
454        let now_ms = wacore::time::now_millis();
455        if mute_end_timestamp_ms <= now_ms {
456            return Err(AppStateError::InvalidRequest(format!(
457                "mute_end_timestamp_ms is in the past ({mute_end_timestamp_ms} <= {now_ms})"
458            )));
459        }
460        debug!("Muting chat {jid} until {mute_end_timestamp_ms}");
461        self.send_mute_mutation(jid, true, mute_end_timestamp_ms)
462            .await
463    }
464
465    pub async fn unmute_chat(&self, jid: &Jid) -> Result<(), AppStateError> {
466        debug!("Unmuting chat {jid}");
467        self.send_mute_mutation(jid, false, 0).await
468    }
469
470    /// `participant_jid`: required for group messages from others, `None` otherwise.
471    pub async fn star_message(
472        &self,
473        chat_jid: &Jid,
474        participant_jid: Option<&Jid>,
475        message_id: &str,
476        from_me: bool,
477    ) -> Result<(), AppStateError> {
478        debug!("Starring message {message_id} in {chat_jid}");
479        self.send_star_mutation(chat_jid, participant_jid, message_id, from_me, true)
480            .await
481    }
482
483    pub async fn unstar_message(
484        &self,
485        chat_jid: &Jid,
486        participant_jid: Option<&Jid>,
487        message_id: &str,
488        from_me: bool,
489    ) -> Result<(), AppStateError> {
490        debug!("Unstarring message {message_id} in {chat_jid}");
491        self.send_star_mutation(chat_jid, participant_jid, message_id, from_me, false)
492            .await
493    }
494
495    /// Distinct from `readMessages` IQ receipts — this syncs state across linked devices.
496    pub async fn mark_chat_as_read(
497        &self,
498        jid: &Jid,
499        read: bool,
500        message_range: Option<SyncActionMessageRange>,
501    ) -> Result<(), AppStateError> {
502        debug!(
503            "Marking chat {jid} as {}",
504            if read { "read" } else { "unread" }
505        );
506        let value = wa::SyncActionValue {
507            mark_chat_as_read_action: buffa::MessageField::some(
508                wa::sync_action_value::MarkChatAsReadAction {
509                    read: Some(read),
510                    message_range: message_range.into(),
511                },
512            ),
513            timestamp: Some(wacore::time::now_millis()),
514            ..Default::default()
515        };
516        let jid = jid.to_string();
517        self.client
518            .send_app_state_action(&schemas::MARK_CHAT_AS_READ, &[jid.as_str()], &value)
519            .await
520    }
521
522    pub async fn delete_chat(
523        &self,
524        jid: &Jid,
525        delete_media: bool,
526        message_range: Option<SyncActionMessageRange>,
527    ) -> Result<(), AppStateError> {
528        debug!("Deleting chat {jid}");
529        let delete_media_str = if delete_media { "1" } else { "0" };
530        let value = wa::SyncActionValue {
531            delete_chat_action: buffa::MessageField::some(
532                wa::sync_action_value::DeleteChatAction {
533                    message_range: message_range.into(),
534                },
535            ),
536            timestamp: Some(wacore::time::now_millis()),
537            ..Default::default()
538        };
539        let jid = jid.to_string();
540        self.client
541            .send_app_state_action(
542                &schemas::DELETE_CHAT,
543                &[jid.as_str(), delete_media_str],
544                &value,
545            )
546            .await
547    }
548
549    /// Clears a chat's messages while keeping the chat (WA Web's clearChat).
550    ///
551    /// `delete_starred` also removes starred messages; `delete_media` also removes
552    /// downloaded media. Both flags live only in the mutation index, not the proto.
553    pub async fn clear_chat(
554        &self,
555        jid: &Jid,
556        delete_starred: bool,
557        delete_media: bool,
558        message_range: Option<SyncActionMessageRange>,
559    ) -> Result<(), AppStateError> {
560        debug!("Clearing chat {jid}");
561        // WA Web's $ClearChatSync$p_3 encodes both flags as "1"/"0".
562        let delete_starred_str = if delete_starred { "1" } else { "0" };
563        let delete_media_str = if delete_media { "1" } else { "0" };
564        let value = wa::SyncActionValue {
565            clear_chat_action: buffa::MessageField::some(wa::sync_action_value::ClearChatAction {
566                message_range: message_range.into(),
567            }),
568            timestamp: Some(wacore::time::now_millis()),
569            ..Default::default()
570        };
571        let jid = jid.to_string();
572        self.client
573            .send_app_state_action(
574                &schemas::CLEAR_CHAT,
575                &[jid.as_str(), delete_starred_str, delete_media_str],
576                &value,
577            )
578            .await
579    }
580
581    /// Mute or unmute a contact/group/newsletter's status updates across devices
582    /// (WA Web's userStatusMute). `muted = true` hides their status.
583    pub async fn set_user_status_mute(&self, jid: &Jid, muted: bool) -> Result<(), AppStateError> {
584        debug!("Setting userStatusMute for {jid} -> {muted}");
585        let value = wa::SyncActionValue {
586            user_status_mute_action: buffa::MessageField::some(
587                wa::sync_action_value::UserStatusMuteAction { muted: Some(muted) },
588            ),
589            timestamp: Some(wacore::time::now_millis()),
590            ..Default::default()
591        };
592        let jid = jid.to_string();
593        self.client
594            .send_app_state_action(&schemas::USER_STATUS_MUTE, &[jid.as_str()], &value)
595            .await
596    }
597
598    /// Deletes locally only (not for everyone).
599    /// `participant_jid`: required for group messages from others, `None` otherwise.
600    pub async fn delete_message_for_me(
601        &self,
602        chat_jid: &Jid,
603        participant_jid: Option<&Jid>,
604        message_id: &str,
605        from_me: bool,
606        delete_media: bool,
607        message_timestamp: Option<i64>,
608    ) -> Result<(), AppStateError> {
609        debug!("Deleting message {message_id} for me in {chat_jid}");
610        let (chat, participant) = message_key_owned(chat_jid, participant_jid, from_me)?;
611        let value = wa::SyncActionValue {
612            delete_message_for_me_action: buffa::MessageField::some(
613                wa::sync_action_value::DeleteMessageForMeAction {
614                    delete_media: Some(delete_media),
615                    message_timestamp,
616                },
617            ),
618            timestamp: Some(wacore::time::now_millis()),
619            ..Default::default()
620        };
621        self.client
622            .send_app_state_action(
623                &schemas::DELETE_MESSAGE_FOR_ME,
624                &[
625                    chat.as_str(),
626                    message_id,
627                    bool_str(from_me),
628                    participant.as_deref().unwrap_or("0"),
629                ],
630                &value,
631            )
632            .await
633    }
634
635    /// Save or rename a contact, syncing the name to the user's other linked devices.
636    ///
637    /// Writes a `contact` app-state SET mutation (WAWebContactSync.getContactSyncMutation)
638    /// to the `critical_unblock_low` collection with index `["contact", jid]`.
639    /// `full_name`/`first_name` are sent verbatim; an absent `first_name` is omitted
640    /// (WA Web derives no short-name default). `save_on_primary_addressbook` controls
641    /// whether it is saved to the phone's address book.
642    ///
643    /// The contact id must be a phone-number JID: WA Web refuses to send a contact
644    /// mutation keyed by a LID (LID contacts use a separate path), so a LID is rejected.
645    pub async fn save_contact(
646        &self,
647        jid: &Jid,
648        full_name: Option<String>,
649        first_name: Option<String>,
650        save_on_primary_addressbook: bool,
651    ) -> Result<(), AppStateError> {
652        if !is_valid_contact_id(jid) {
653            return Err(AppStateError::InvalidRequest(
654                "save_contact: contact id must be a bare phone-number JID (not a LID, group, or device-specific JID)".into(),
655            ));
656        }
657        debug!("Saving contact {jid}");
658        let value = wa::SyncActionValue {
659            contact_action: buffa::MessageField::some(wa::sync_action_value::ContactAction {
660                full_name,
661                first_name,
662                save_on_primary_addressbook: Some(save_on_primary_addressbook),
663                ..Default::default()
664            }),
665            timestamp: Some(wacore::time::now_millis()),
666            ..Default::default()
667        };
668        let jid_str = jid.to_string();
669        self.client
670            .send_app_state_action(&schemas::CONTACT, &[jid_str.as_str()], &value)
671            .await
672    }
673
674    async fn send_archive_mutation(
675        &self,
676        jid: &Jid,
677        archived: bool,
678        message_range: Option<SyncActionMessageRange>,
679    ) -> Result<(), AppStateError> {
680        let value = wa::SyncActionValue {
681            archive_chat_action: buffa::MessageField::some(
682                wa::sync_action_value::ArchiveChatAction {
683                    archived: Some(archived),
684                    message_range: message_range.into(),
685                },
686            ),
687            timestamp: Some(wacore::time::now_millis()),
688            ..Default::default()
689        };
690        let jid = jid.to_string();
691        self.client
692            .send_app_state_action(&schemas::ARCHIVE, &[jid.as_str()], &value)
693            .await
694    }
695
696    async fn send_pin_mutation(&self, jid: &Jid, pinned: bool) -> Result<(), AppStateError> {
697        let value = wa::SyncActionValue {
698            pin_action: buffa::MessageField::some(wa::sync_action_value::PinAction {
699                pinned: Some(pinned),
700            }),
701            timestamp: Some(wacore::time::now_millis()),
702            ..Default::default()
703        };
704        let jid = jid.to_string();
705        self.client
706            .send_app_state_action(&schemas::PIN, &[jid.as_str()], &value)
707            .await
708    }
709
710    async fn send_mute_mutation(
711        &self,
712        jid: &Jid,
713        muted: bool,
714        mute_end_timestamp_ms: i64,
715    ) -> Result<(), AppStateError> {
716        // -1 = indefinite, 0 = unmuted, positive = expiry ms
717        let mute_end = if muted {
718            Some(mute_end_timestamp_ms)
719        } else {
720            Some(0)
721        };
722        let value = wa::SyncActionValue {
723            mute_action: buffa::MessageField::some(wa::sync_action_value::MuteAction {
724                muted: Some(muted),
725                mute_end_timestamp: mute_end,
726                ..Default::default()
727            }),
728            timestamp: Some(wacore::time::now_millis()),
729            ..Default::default()
730        };
731        let jid = jid.to_string();
732        self.client
733            .send_app_state_action(&schemas::MUTE, &[jid.as_str()], &value)
734            .await
735    }
736
737    async fn send_star_mutation(
738        &self,
739        chat_jid: &Jid,
740        participant_jid: Option<&Jid>,
741        message_id: &str,
742        from_me: bool,
743        starred: bool,
744    ) -> Result<(), AppStateError> {
745        let (chat, participant) = message_key_owned(chat_jid, participant_jid, from_me)?;
746        let value = wa::SyncActionValue {
747            star_action: buffa::MessageField::some(wa::sync_action_value::StarAction {
748                starred: Some(starred),
749            }),
750            timestamp: Some(wacore::time::now_millis()),
751            ..Default::default()
752        };
753        self.client
754            .send_app_state_action(
755                &schemas::STAR,
756                &[
757                    chat.as_str(),
758                    message_id,
759                    bool_str(from_me),
760                    participant.as_deref().unwrap_or("0"),
761                ],
762                &value,
763            )
764            .await
765    }
766}
767
768impl Client {
769    pub fn chat_actions(&self) -> ChatActions<'_> {
770        ChatActions::new(self)
771    }
772
773    /// Encode a single `Set` app-state mutation (stamped with the action schema
774    /// `version`) and send it as a patch on `collection`. Shared by the
775    /// chat-action and label features.
776    pub(crate) async fn send_app_state_mutation(
777        &self,
778        collection: WAPatchName,
779        index: &[u8],
780        value: &wa::SyncActionValue,
781        version: i32,
782    ) -> Result<(), AppStateError> {
783        use rand::Rng;
784        use wacore::appstate::encode::encode_record;
785
786        let proc = self.get_app_state_processor().await;
787        let key_id = proc
788            .backend
789            .get_latest_sync_key_id()
790            .await
791            .map_err(|e| anyhow::anyhow!(e))?
792            .ok_or_else(|| {
793                AppStateError::InvalidRequest("no app state sync key available".into())
794            })?;
795        let keys = proc
796            .get_app_state_key(&key_id)
797            .await
798            .map_err(|e| AppStateError::Internal(e.into()))?;
799
800        let mut iv = [0u8; 16];
801        rand::make_rng::<rand::rngs::StdRng>().fill_bytes(&mut iv);
802
803        let (mutation, _) = encode_record(
804            wa::syncd_mutation::SyncdOperation::SET,
805            index,
806            value,
807            &keys,
808            &key_id,
809            &iv,
810            version,
811        );
812
813        self.send_app_state_patch(collection.as_str(), vec![mutation])
814            .await?;
815        Ok(())
816    }
817
818    /// Send any app-state (syncd) `Set` action, driven by a generated
819    /// [`Schema`] from
820    /// [`wacore::appstate::schemas`]. The collection, action version, and index
821    /// shape come from the registry; the caller only fills the typed
822    /// [`SyncActionValue`](wa::SyncActionValue) (its action sub-field, plus a
823    /// `timestamp`) and supplies the non-literal index args in `index_parts`
824    /// order. This is the generic escape hatch for actions without a dedicated
825    /// helper (e.g. `clear_chat`, `favorites`, `quick_reply`); the typed APIs
826    /// like [`ChatActions`] and [`Labels`](crate::Labels) wrap it.
827    ///
828    /// ```no_run
829    /// # #![recursion_limit = "512"]
830    /// # async fn ex(client: &whatsapp_rust::Client) -> anyhow::Result<()> {
831    /// use whatsapp_rust::schemas;
832    /// use whatsapp_rust::waproto::whatsapp as wa;
833    /// let value = wa::SyncActionValue {
834    ///     clear_chat_action: Some(Default::default()).into(),
835    ///     timestamp: Some(1_700_000_000_000), // a real epoch-ms timestamp
836    ///     ..Default::default()
837    /// };
838    /// // Args are the non-literal index parts in `schema.index_parts` order;
839    /// // CLEAR_CHAT is [chatJid, deleteStarred, deleteMedia].
840    /// client
841    ///     .send_app_state_action(
842    ///         &schemas::CLEAR_CHAT,
843    ///         &["123@s.whatsapp.net", "0", "0"],
844    ///         &value,
845    ///     )
846    ///     .await?;
847    /// # Ok(()) }
848    /// ```
849    pub async fn send_app_state_action(
850        &self,
851        schema: &Schema,
852        index_args: &[&str],
853        value: &wa::SyncActionValue,
854    ) -> Result<(), AppStateError> {
855        let index = build_action_index(schema, index_args)?;
856        let collection = collection_patch_name(schema.collection);
857        self.send_app_state_mutation(collection, &index, value, schema.version as i32)
858            .await
859    }
860}
861
862#[cfg(test)]
863mod registry_tests {
864    use super::*;
865
866    #[test]
867    fn build_index_matches_legacy_shapes() {
868        let cases: &[(&Schema, &[&str], &[&str])] = &[
869            (
870                &schemas::ARCHIVE,
871                &["123@s.whatsapp.net"],
872                &["archive", "123@s.whatsapp.net"],
873            ),
874            (
875                &schemas::PIN,
876                &["123@s.whatsapp.net"],
877                &["pin_v1", "123@s.whatsapp.net"],
878            ),
879            (
880                &schemas::MUTE,
881                &["123@s.whatsapp.net"],
882                &["mute", "123@s.whatsapp.net"],
883            ),
884            (
885                &schemas::MARK_CHAT_AS_READ,
886                &["123@s.whatsapp.net"],
887                &["markChatAsRead", "123@s.whatsapp.net"],
888            ),
889            (
890                &schemas::DELETE_CHAT,
891                &["123@s.whatsapp.net", "1"],
892                &["deleteChat", "123@s.whatsapp.net", "1"],
893            ),
894            (
895                &schemas::CLEAR_CHAT,
896                &["123@s.whatsapp.net", "0", "1"],
897                &["clearChat", "123@s.whatsapp.net", "0", "1"],
898            ),
899            (
900                &schemas::USER_STATUS_MUTE,
901                &["123@s.whatsapp.net"],
902                &["userStatusMute", "123@s.whatsapp.net"],
903            ),
904            (&schemas::LABEL_EDIT, &["5"], &["label_edit", "5"]),
905            (
906                &schemas::LABEL_JID,
907                &["5", "123@s.whatsapp.net"],
908                &["label_jid", "5", "123@s.whatsapp.net"],
909            ),
910            (
911                &schemas::STAR,
912                &["123@g.us", "MSGID", "1", "0"],
913                &["star", "123@g.us", "MSGID", "1", "0"],
914            ),
915            (&schemas::SETTING_PUSH_NAME, &[], &["setting_pushName"]),
916        ];
917        for (schema, args, expected) in cases {
918            assert_eq!(
919                build_action_index(schema, args).unwrap(),
920                serde_json::to_vec(expected).unwrap(),
921                "index mismatch for {}",
922                schema.name
923            );
924        }
925    }
926
927    #[test]
928    fn build_index_rejects_arg_count_mismatch() {
929        assert!(build_action_index(&schemas::ARCHIVE, &[]).is_err());
930        assert!(build_action_index(&schemas::ARCHIVE, &["a", "b"]).is_err());
931        assert!(build_action_index(&schemas::SETTING_PUSH_NAME, &["x"]).is_err());
932    }
933
934    #[test]
935    fn registry_versions_match_whatsmeow() {
936        // Locks the per-action versions the migration relies on (vs whatsmeow);
937        // a regenerated registry that changes one will trip this for review.
938        assert_eq!(schemas::MUTE.version, 2);
939        assert_eq!(schemas::PIN.version, 5);
940        assert_eq!(schemas::ARCHIVE.version, 3);
941        assert_eq!(schemas::MARK_CHAT_AS_READ.version, 3);
942        assert_eq!(schemas::STAR.version, 2);
943        assert_eq!(schemas::CONTACT.version, 2);
944        assert_eq!(schemas::DELETE_MESSAGE_FOR_ME.version, 3);
945        assert_eq!(schemas::LABEL_EDIT.version, 3);
946        assert_eq!(schemas::LABEL_JID.version, 3);
947        assert_eq!(schemas::SETTING_PUSH_NAME.version, 1);
948    }
949
950    #[test]
951    fn collection_mapping() {
952        use schemas::Collection;
953        // Every generated collection has a WAPatchName counterpart (total map).
954        for c in [
955            Collection::Regular,
956            Collection::RegularLow,
957            Collection::RegularHigh,
958            Collection::CriticalBlock,
959            Collection::CriticalUnblockLow,
960        ] {
961            // Round-trips through the wire name.
962            assert_eq!(collection_patch_name(c).as_str(), c.as_str());
963        }
964    }
965
966    #[test]
967    fn contact_action_index_and_collection() {
968        // WAWebContactSync writes ["contact", jid] to critical_unblock_low.
969        let index = build_action_index(&schemas::CONTACT, &["5511999@s.whatsapp.net"]).unwrap();
970        let parts: Vec<String> = serde_json::from_slice(&index).unwrap();
971        assert_eq!(
972            parts,
973            vec!["contact".to_string(), "5511999@s.whatsapp.net".to_string()]
974        );
975        assert_eq!(
976            collection_patch_name(schemas::CONTACT.collection),
977            WAPatchName::CriticalUnblockLow
978        );
979    }
980
981    #[test]
982    fn contact_id_validation_accepts_only_bare_pn() {
983        let valid = |s: &str| is_valid_contact_id(&s.parse::<Jid>().expect("test JID"));
984        // bare PN -> accepted
985        assert!(valid("5511999@s.whatsapp.net"));
986        // AD/device-specific PN -> rejected (would form an invalid contact index)
987        assert!(!valid("5511999:4@s.whatsapp.net"));
988        // LID -> rejected (separate WA Web path)
989        assert!(!valid("100000012345678@lid"));
990        // group / newsletter / status -> rejected
991        assert!(!valid("120363012345@g.us"));
992        assert!(!valid("123@newsletter"));
993        assert!(!valid("status@broadcast"));
994    }
995}