Skip to main content

whatsapp_rust/features/
blocking.rs

1//! Blocking feature for managing blocked contacts.
2//!
3//! This module provides high-level APIs for blocking and unblocking contacts.
4//! Protocol-level types are defined in `wacore::iq::blocklist`.
5
6use crate::client::Client;
7use crate::request::IqError;
8use log::debug;
9use thiserror::Error;
10pub use wacore::iq::blocklist::BlocklistEntry;
11use wacore::iq::blocklist::{GetBlocklistSpec, UpdateBlocklistSpec};
12use wacore_binary::Jid;
13
14/// Error returned by blocklist operations.
15#[derive(Debug, Error)]
16#[non_exhaustive]
17pub enum BlockingError {
18    /// The IQ to the server failed (transport, timeout, server rejection).
19    #[error("{0}")]
20    Iq(#[from] IqError),
21    /// The target JID is not a user JID, or has no resolvable LID↔PN mapping
22    /// (modern WA requires both sides for a block).
23    #[error("invalid blocklist target: {0}")]
24    InvalidJid(String),
25    /// Catch-all for internal failures (e.g. LID/PN store lookup).
26    #[error("{0}")]
27    Internal(#[from] anyhow::Error),
28}
29
30/// Feature handle for blocklist operations.
31pub struct Blocking<'a> {
32    client: &'a Client,
33}
34
35impl<'a> Blocking<'a> {
36    pub(crate) fn new(client: &'a Client) -> Self {
37        Self { client }
38    }
39
40    /// Resolve `bare` (LID or PN) into the `(lid, pn)` pair the server expects
41    /// on blocklist stanzas.
42    async fn resolve_lid_pn(&self, bare: Jid) -> Result<(Jid, Jid), BlockingError> {
43        if !(bare.is_lid() || bare.is_pn()) {
44            return Err(BlockingError::InvalidJid(
45                "jid is neither PN nor LID".into(),
46            ));
47        }
48        let entry = self.client.get_lid_pn_entry(&bare).await?.ok_or_else(|| {
49            BlockingError::InvalidJid("no LID↔PN mapping for provided jid".into())
50        })?;
51        Ok(if bare.is_lid() {
52            (bare, Jid::pn(&*entry.phone_number))
53        } else {
54            (Jid::lid(&*entry.lid), bare)
55        })
56    }
57
58    /// Block a contact. Accepts either LID or PN; the wire stanza always
59    /// carries both (`jid=LID, pn_jid=PN`) — modern WA rejects PN-only blocks.
60    pub async fn block(&self, jid: &Jid) -> Result<(), BlockingError> {
61        debug!(target: "Blocking", "Blocking contact");
62        let (lid_jid, pn_jid) = self.resolve_lid_pn(jid.to_non_ad()).await?;
63        self.client
64            .execute(UpdateBlocklistSpec::block_with_pn(&lid_jid, &pn_jid))
65            .await?;
66        debug!(target: "Blocking", "Successfully blocked contact");
67        Ok(())
68    }
69
70    /// Unblock a contact. Stanza only needs the LID, but PN input is accepted
71    /// and resolved through the mapping.
72    pub async fn unblock(&self, jid: &Jid) -> Result<(), BlockingError> {
73        debug!(target: "Blocking", "Unblocking contact");
74        // The unblock stanza only needs the LID, so a LID input must not require a
75        // PN↔LID mapping (resolve_lid_pn hard-fails when none exists).
76        let bare = jid.to_non_ad();
77        let lid_jid = if bare.is_lid() {
78            bare
79        } else {
80            self.resolve_lid_pn(bare).await?.0
81        };
82        self.client
83            .execute(UpdateBlocklistSpec::unblock(&lid_jid))
84            .await?;
85        debug!(target: "Blocking", "Successfully unblocked contact");
86        Ok(())
87    }
88
89    /// Get the full blocklist.
90    pub async fn get_blocklist(&self) -> Result<Vec<BlocklistEntry>, BlockingError> {
91        debug!(target: "Blocking", "Fetching blocklist...");
92        let entries = self.client.execute(GetBlocklistSpec).await?;
93        debug!(target: "Blocking", "Fetched {} blocked contacts", entries.len());
94        Ok(entries)
95    }
96
97    /// Check if a contact is blocked.
98    ///
99    /// Compares only the user part of the JID, ignoring device ID, since blocking
100    /// applies to the entire user account, not individual devices.
101    pub async fn is_blocked(&self, jid: &Jid) -> Result<bool, BlockingError> {
102        let blocklist = self.get_blocklist().await?;
103        let bare = jid.to_non_ad();
104
105        // Blocks are stored keyed by LID (block() always resolves the input to a LID), so a
106        // PN-input query must resolve to its LID before comparing or it never matches a
107        // LID-keyed entry. Match against the raw user plus the resolved LID and PN. Propagate a
108        // backend failure (swallowing it would fall back to the raw user and re-introduce the
109        // false negative); a genuine absence (Ok(None), incl. a non-LID/PN input) falls back.
110        let mapping = self.client.get_lid_pn_entry(&bare).await?;
111        let mut users: Vec<&str> = vec![bare.user.as_str()];
112        if let Some(entry) = mapping.as_ref() {
113            users.push(&*entry.lid);
114            users.push(&*entry.phone_number);
115        }
116
117        Ok(blocklist_contains(&blocklist, &users))
118    }
119}
120
121/// Whether any blocklist entry's user part matches one of `candidate_users`.
122///
123/// Blocks are stored keyed by LID, so the caller resolves the queried JID to its
124/// LID/PN pair and passes all of them (raw, LID, PN) to catch a LID-keyed entry from
125/// a PN-input query (and the reverse).
126fn blocklist_contains(blocklist: &[BlocklistEntry], candidate_users: &[&str]) -> bool {
127    blocklist
128        .iter()
129        .any(|e| candidate_users.contains(&e.jid.user.as_str()))
130}
131
132impl Client {
133    /// Access blocking operations.
134    pub fn blocking(&self) -> Blocking<'_> {
135        Blocking::new(self)
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    fn lid_entry(user: &str) -> BlocklistEntry {
144        BlocklistEntry {
145            jid: Jid::lid(user.to_string()),
146            timestamp: None,
147        }
148    }
149
150    #[test]
151    fn pn_query_matches_lid_keyed_block_only_when_resolved() {
152        // A block stored under the LID (modern WA) must be found once the PN query is
153        // resolved to that LID. Without resolution the LID-keyed block is missed (the bug).
154        let blocklist = vec![lid_entry("100000012345678")];
155
156        assert!(
157            blocklist_contains(&blocklist, &["559980000001", "100000012345678"]),
158            "resolved PN->LID candidate matches the LID-keyed block"
159        );
160        assert!(
161            !blocklist_contains(&blocklist, &["559980000001"]),
162            "raw PN alone misses the LID-keyed block (the false negative)"
163        );
164        assert!(
165            blocklist_contains(&blocklist, &["100000012345678"]),
166            "a LID query matches directly"
167        );
168        assert!(
169            !blocklist_contains(&blocklist, &["559981111111", "100000099999999"]),
170            "an unrelated contact is not blocked"
171        );
172    }
173}