Skip to main content

whatsapp_rust/features/
labels.rs

1//! Chat labels (etiquetas) via app state sync (syncd).
2//!
3//! Mirrors WhatsApp Web's `WAWebLabel*`. Both label actions live in the
4//! `regular` collection:
5//! - `label_edit`  (index `["label_edit", labelId]`)        -> `LabelEditAction`
6//! - `label_jid`   (index `["label_jid", labelId, chatJid]`) -> `LabelAssociationAction`
7//!
8//! Collection, action version, and index shape all come from the generated
9//! `schemas::{LABEL_EDIT, LABEL_JID}` registry.
10
11use 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
20/// Dispatch inbound label mutations synced from a linked device.
21/// Returns `true` if handled, `false` if the mutation is not a label kind.
22pub(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
97/// Access via `client.labels()`.
98pub 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    /// Create or update a label. App state is an upsert keyed by `label_id`, so
108    /// this both creates a new label and renames/recolors an existing one.
109    /// `color` is a WhatsApp color index.
110    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        // Don't log the label name (user content); the id/color are enough to trace.
127        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    /// Delete a label. Chats keep their association rows; WA Web prunes them
147    /// from the local DB on receipt of the delete.
148    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    /// Associate a label with a chat.
169    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    /// Remove a label association from a chat.
178    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        // Validation fires before any network/app-state work, so a key-less test
323        // client still exercises the guard.
324        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        // A chat-action mutation must fall through so its own handler runs.
345        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        // Claimed (returns true) so it isn't re-tried, but no event is emitted.
357        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}