1use 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#[derive(Debug, Error)]
24#[non_exhaustive]
25pub enum AppStateError {
26 #[error("invalid app-state request: {0}")]
30 InvalidRequest(String),
31 #[error("{0}")]
33 Internal(#[from] anyhow::Error),
34}
35
36const MUTE_INDEFINITE: i64 = -1;
38
39pub type SyncActionMessageRange = wa::sync_action_value::SyncActionMessageRange;
40
41pub 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
75pub(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 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 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
304fn 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
333fn message_key_owned(
337 chat_jid: &Jid,
338 participant_jid: Option<&Jid>,
339 from_me: bool,
340) -> Result<(String, Option<String>)> {
341 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#[inline]
350fn bool_str(b: bool) -> &'static str {
351 if b { "1" } else { "0" }
352}
353
354fn 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
387pub(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
400pub 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 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 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 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 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 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 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 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 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 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 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 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 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 for c in [
955 Collection::Regular,
956 Collection::RegularLow,
957 Collection::RegularHigh,
958 Collection::CriticalBlock,
959 Collection::CriticalUnblockLow,
960 ] {
961 assert_eq!(collection_patch_name(c).as_str(), c.as_str());
963 }
964 }
965
966 #[test]
967 fn contact_action_index_and_collection() {
968 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 assert!(valid("5511999@s.whatsapp.net"));
986 assert!(!valid("5511999:4@s.whatsapp.net"));
988 assert!(!valid("100000012345678@lid"));
990 assert!(!valid("120363012345@g.us"));
992 assert!(!valid("123@newsletter"));
993 assert!(!valid("status@broadcast"));
994 }
995}