1use crate::appstate_sync::Mutation;
12use crate::client::Client;
13use crate::features::chat_actions::AppStateError;
14use log::debug;
15use wacore::appstate::schemas;
16use wacore::types::events::{Event, LabelAssociationUpdate, LabelEditUpdate};
17use wacore_binary::Jid;
18use waproto::whatsapp as wa;
19
20pub(crate) fn dispatch_label_mutation(
23 event_bus: &wacore::types::events::CoreEventBus,
24 m: &Mutation,
25 full_sync: bool,
26) -> bool {
27 if m.operation != wa::syncd_mutation::SyncdOperation::Set || m.index.is_empty() {
28 return false;
29 }
30
31 let kind = m.index[0].as_str();
32 if !matches!(kind, "label_edit" | "label_jid") {
33 return false;
34 }
35
36 let ts = m
37 .action_value
38 .as_ref()
39 .and_then(|v| v.timestamp)
40 .unwrap_or(0);
41 let time = wacore::time::from_millis_or_now(ts);
42
43 let Some(label_id) = m.index.get(1).cloned() else {
44 log::warn!("Skipping label mutation '{kind}': missing label id in index");
45 return true;
46 };
47
48 match kind {
49 "label_edit" => {
50 if let Some(val) = &m.action_value
51 && let Some(act) = val.label_edit_action.as_option()
52 {
53 event_bus.dispatch(Event::LabelEditUpdate(
54 LabelEditUpdate::builder()
55 .label_id(label_id)
56 .timestamp(time)
57 .action(Box::new(act.clone()))
58 .from_full_sync(full_sync)
59 .build(),
60 ));
61 }
62 true
63 }
64 "label_jid" => {
65 let chat_jid: Jid = match m.index.get(2) {
66 Some(s) => match s.parse() {
67 Ok(j) => j,
68 Err(_) => {
69 log::warn!("Skipping label_jid mutation: malformed chat JID '{s}'");
70 return true;
71 }
72 },
73 None => {
74 log::warn!("Skipping label_jid mutation: missing chat JID in index");
75 return true;
76 }
77 };
78 if let Some(val) = &m.action_value
79 && let Some(act) = val.label_association_action.as_option()
80 {
81 event_bus.dispatch(Event::LabelAssociationUpdate(
82 LabelAssociationUpdate::builder()
83 .label_id(label_id)
84 .chat_jid(chat_jid)
85 .timestamp(time)
86 .action(Box::new(act.clone()))
87 .from_full_sync(full_sync)
88 .build(),
89 ));
90 }
91 true
92 }
93 _ => false,
94 }
95}
96
97pub struct Labels<'a> {
99 client: &'a Client,
100}
101
102impl<'a> Labels<'a> {
103 pub(crate) fn new(client: &'a Client) -> Self {
104 Self { client }
105 }
106
107 pub async fn create_label(
111 &self,
112 label_id: &str,
113 name: &str,
114 color: i32,
115 ) -> Result<(), AppStateError> {
116 if label_id.is_empty() {
117 return Err(AppStateError::InvalidRequest(
118 "label_id cannot be empty".into(),
119 ));
120 }
121 if name.is_empty() {
122 return Err(AppStateError::InvalidRequest(
123 "label name cannot be empty".into(),
124 ));
125 }
126 debug!(
128 "Setting label {label_id} (name_len={}, color={color})",
129 name.len()
130 );
131 let value = wa::SyncActionValue {
132 label_edit_action: buffa::MessageField::some(wa::sync_action_value::LabelEditAction {
133 name: Some(name.to_string()),
134 color: Some(color),
135 deleted: Some(false),
136 ..Default::default()
137 }),
138 timestamp: Some(wacore::time::now_millis()),
139 ..Default::default()
140 };
141 self.client
142 .send_app_state_action(&schemas::LABEL_EDIT, &[label_id], &value)
143 .await
144 }
145
146 pub async fn delete_label(&self, label_id: &str) -> Result<(), AppStateError> {
149 if label_id.is_empty() {
150 return Err(AppStateError::InvalidRequest(
151 "label_id cannot be empty".into(),
152 ));
153 }
154 debug!("Deleting label {label_id}");
155 let value = wa::SyncActionValue {
156 label_edit_action: buffa::MessageField::some(wa::sync_action_value::LabelEditAction {
157 deleted: Some(true),
158 ..Default::default()
159 }),
160 timestamp: Some(wacore::time::now_millis()),
161 ..Default::default()
162 };
163 self.client
164 .send_app_state_action(&schemas::LABEL_EDIT, &[label_id], &value)
165 .await
166 }
167
168 pub async fn add_chat_label(
170 &self,
171 label_id: &str,
172 chat_jid: &Jid,
173 ) -> Result<(), AppStateError> {
174 self.send_association(label_id, chat_jid, true).await
175 }
176
177 pub async fn remove_chat_label(
179 &self,
180 label_id: &str,
181 chat_jid: &Jid,
182 ) -> Result<(), AppStateError> {
183 self.send_association(label_id, chat_jid, false).await
184 }
185
186 async fn send_association(
187 &self,
188 label_id: &str,
189 chat_jid: &Jid,
190 labeled: bool,
191 ) -> Result<(), AppStateError> {
192 if label_id.is_empty() {
193 return Err(AppStateError::InvalidRequest(
194 "label_id cannot be empty".into(),
195 ));
196 }
197 debug!(
198 "{} label {label_id} {} chat {chat_jid}",
199 if labeled { "Adding" } else { "Removing" },
200 if labeled { "to" } else { "from" },
201 );
202 let chat = chat_jid.to_string();
203 let value = wa::SyncActionValue {
204 label_association_action: buffa::MessageField::some(
205 wa::sync_action_value::LabelAssociationAction {
206 labeled: Some(labeled),
207 ..Default::default()
208 },
209 ),
210 timestamp: Some(wacore::time::now_millis()),
211 ..Default::default()
212 };
213 self.client
214 .send_app_state_action(&schemas::LABEL_JID, &[label_id, chat.as_str()], &value)
215 .await
216 }
217}
218
219impl Client {
220 pub fn labels(&self) -> Labels<'_> {
221 Labels::new(self)
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use std::sync::{Arc, Mutex};
229 use wacore::types::events::{CoreEventBus, EventHandler, EventInterest};
230
231 #[derive(Default)]
232 struct Recorder {
233 events: Mutex<Vec<Arc<Event>>>,
234 }
235 impl EventHandler for Recorder {
236 fn handle_event(&self, event: Arc<Event>) {
237 self.events.lock().unwrap().push(event);
238 }
239 fn interest(&self) -> EventInterest {
240 EventInterest::ALL
241 }
242 }
243
244 fn set_mutation(index: Vec<&str>, value: wa::SyncActionValue) -> Mutation {
245 Mutation {
246 index: index.into_iter().map(String::from).collect(),
247 operation: wa::syncd_mutation::SyncdOperation::Set,
248 action_value: Some(value),
249 }
250 }
251
252 fn run(m: &Mutation) -> (bool, Vec<Arc<Event>>) {
253 let bus = CoreEventBus::new();
254 let rec = Arc::new(Recorder::default());
255 bus.subscribe_handler(rec.clone()).detach();
256 let handled = dispatch_label_mutation(&bus, m, false);
257 let events = rec.events.lock().unwrap().clone();
258 (handled, events)
259 }
260
261 #[test]
262 fn label_edit_dispatches_update() {
263 let m = set_mutation(
264 vec!["label_edit", "5"],
265 wa::SyncActionValue {
266 label_edit_action: buffa::MessageField::some(
267 wa::sync_action_value::LabelEditAction {
268 name: Some("Work".into()),
269 color: Some(2),
270 deleted: Some(false),
271 ..Default::default()
272 },
273 ),
274 timestamp: Some(1000),
275 ..Default::default()
276 },
277 );
278 let (handled, events) = run(&m);
279 assert!(handled);
280 assert_eq!(events.len(), 1);
281 match &*events[0] {
282 Event::LabelEditUpdate(u) => {
283 assert_eq!(u.label_id, "5");
284 assert_eq!(u.action.name.as_deref(), Some("Work"));
285 assert_eq!(u.action.color, Some(2));
286 assert_eq!(u.action.deleted, Some(false));
287 }
288 other => panic!("expected LabelEditUpdate, got {other:?}"),
289 }
290 }
291
292 #[test]
293 fn label_jid_dispatches_association() {
294 let m = set_mutation(
295 vec!["label_jid", "5", "15551112222@s.whatsapp.net"],
296 wa::SyncActionValue {
297 label_association_action: buffa::MessageField::some(
298 wa::sync_action_value::LabelAssociationAction {
299 labeled: Some(true),
300 ..Default::default()
301 },
302 ),
303 timestamp: Some(1000),
304 ..Default::default()
305 },
306 );
307 let (handled, events) = run(&m);
308 assert!(handled);
309 assert_eq!(events.len(), 1);
310 match &*events[0] {
311 Event::LabelAssociationUpdate(u) => {
312 assert_eq!(u.label_id, "5");
313 assert_eq!(u.chat_jid.to_string(), "15551112222@s.whatsapp.net");
314 assert_eq!(u.action.labeled, Some(true));
315 }
316 other => panic!("expected LabelAssociationUpdate, got {other:?}"),
317 }
318 }
319
320 #[tokio::test]
321 async fn label_methods_reject_empty_id() {
322 let client = crate::test_utils::create_test_client().await;
325 let jid: Jid = "15551112222@s.whatsapp.net".parse().unwrap();
326
327 let err = client
328 .labels()
329 .create_label("", "Work", 0)
330 .await
331 .unwrap_err();
332 assert!(err.to_string().contains("label_id cannot be empty"));
333
334 let err = client.labels().create_label("5", "", 0).await.unwrap_err();
335 assert!(err.to_string().contains("label name cannot be empty"));
336
337 assert!(client.labels().delete_label("").await.is_err());
338 assert!(client.labels().add_chat_label("", &jid).await.is_err());
339 assert!(client.labels().remove_chat_label("", &jid).await.is_err());
340 }
341
342 #[test]
343 fn non_label_kind_is_not_claimed() {
344 let m = set_mutation(
346 vec!["mute", "15551112222@s.whatsapp.net"],
347 wa::SyncActionValue::default(),
348 );
349 let (handled, events) = run(&m);
350 assert!(!handled);
351 assert!(events.is_empty());
352 }
353
354 #[test]
355 fn label_jid_with_malformed_chat_is_claimed_but_not_dispatched() {
356 let m = set_mutation(
358 vec!["label_jid", "5", "not a jid"],
359 wa::SyncActionValue {
360 label_association_action: buffa::MessageField::some(
361 wa::sync_action_value::LabelAssociationAction {
362 labeled: Some(true),
363 ..Default::default()
364 },
365 ),
366 ..Default::default()
367 },
368 );
369 let (handled, events) = run(&m);
370 assert!(handled);
371 assert!(events.is_empty());
372 }
373}