wacore 0.6.0

Core WhatsApp protocol implementation without runtime dependencies
Documentation
//! Blocklist IQ types and specifications.
//!
//! This module provides type-safe structures for blocklist operations following
//! the `ProtocolNode` pattern defined in `wacore/src/protocol.rs`.

use crate::WireEnum;
use crate::iq::node::optional_child;
use crate::iq::spec::IqSpec;
use crate::protocol::ProtocolNode;
use crate::request::InfoQuery;
use anyhow::Result;
use log::warn;
use wacore_binary::builder::NodeBuilder;
use wacore_binary::{Jid, Server};
use wacore_binary::{Node, NodeContent, NodeRef};
/// IQ namespace for blocklist operations.
pub const BLOCKLIST_IQ_NAMESPACE: &str = "blocklist";
/// Action to perform on a blocklist entry.
#[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)]
pub enum BlocklistAction {
    #[wire = "block"]
    Block,
    #[wire = "unblock"]
    Unblock,
}
/// Wire requires `jid` in LID and an additional `pn_jid` (PN) when blocking;
/// servers reject PN-only blocks.
#[derive(Debug, Clone, crate::ProtocolNode)]
#[protocol(tag = "item")]
pub struct BlocklistItemRequest {
    #[attr(name = "jid", jid)]
    pub jid: Jid,
    #[attr(name = "action", string_enum)]
    pub action: BlocklistAction,
    #[attr(name = "pn_jid", jid, optional)]
    pub pn_jid: Option<Jid>,
}

impl BlocklistItemRequest {
    pub fn new(jid: &Jid, action: BlocklistAction) -> Self {
        Self {
            jid: jid.clone(),
            action,
            pn_jid: None,
        }
    }

    pub fn block(jid: &Jid) -> Self {
        Self::new(jid, BlocklistAction::Block)
    }

    pub fn unblock(jid: &Jid) -> Self {
        Self::new(jid, BlocklistAction::Unblock)
    }

    /// Construct a block request with the LID and PN required on the wire.
    pub fn block_with_pn(lid: &Jid, pn_jid: &Jid) -> Self {
        Self {
            pn_jid: Some(pn_jid.clone()),
            ..Self::new(lid, BlocklistAction::Block)
        }
    }
}
/// A single blocklist entry from the response.
///
/// Wire format: `<item jid="...@s.whatsapp.net" t="1234567890"/>`
#[derive(Debug, Clone, crate::ProtocolNode)]
#[protocol(tag = "item")]
pub struct BlocklistEntry {
    #[attr(name = "jid", jid)]
    pub jid: Jid,
    #[attr(name = "t", u64)]
    pub timestamp: Option<u64>,
}

/// Response containing the blocklist entries.
///
/// Wire format: `<list><item .../><item .../></list>` or `<item .../><item .../>`
#[derive(Debug, Clone, Default)]
pub struct BlocklistResponse {
    pub entries: Vec<BlocklistEntry>,
}

impl ProtocolNode for BlocklistResponse {
    fn tag(&self) -> &'static str {
        "list"
    }

    fn into_node(self) -> Node {
        let children: Vec<Node> = self.entries.into_iter().map(|e| e.into_node()).collect();
        NodeBuilder::new("list").children(children).build()
    }

    fn try_from_node_ref(node: &NodeRef<'_>) -> Result<Self> {
        // Response can be either:
        // 1. <list><item .../></list>
        // 2. Direct <item .../> children in the response node
        let entries = if let Some(list) = optional_child(node, "list") {
            list.get_children_by_tag("item")
        } else {
            node.get_children_by_tag("item")
        }
        .filter_map(|item| match BlocklistEntry::try_from_node_ref(item) {
            Ok(entry) => Some(entry),
            Err(e) => {
                warn!(target: "blocklist", "Failed to parse blocklist entry: {e}");
                None
            }
        })
        .collect();

        Ok(Self { entries })
    }
}
/// Fetches the blocklist.
#[derive(Debug, Default, Clone, Copy)]
pub struct GetBlocklistSpec;

impl IqSpec for GetBlocklistSpec {
    type Response = Vec<BlocklistEntry>;

    fn build_iq(&self) -> InfoQuery<'static> {
        InfoQuery::get(BLOCKLIST_IQ_NAMESPACE, Jid::new("", Server::Pn), None)
    }

    fn parse_response(&self, response: &NodeRef<'_>) -> Result<Self::Response> {
        // BlocklistResponse checks for a <list> child or direct <item> children
        let entries = if let Some(list) = response.get_optional_child("list") {
            list.get_children_by_tag("item")
        } else {
            response.get_children_by_tag("item")
        }
        .filter_map(|item| match BlocklistEntry::try_from_node_ref(item) {
            Ok(entry) => Some(entry),
            Err(e) => {
                warn!(target: "blocklist", "Failed to parse blocklist entry: {e}");
                None
            }
        })
        .collect();
        Ok(entries)
    }
}

/// Updates the blocklist (block/unblock).
#[derive(Debug, Clone)]
pub struct UpdateBlocklistSpec {
    request: BlocklistItemRequest,
}

impl UpdateBlocklistSpec {
    pub fn new(jid: &Jid, action: BlocklistAction) -> Self {
        Self {
            request: BlocklistItemRequest::new(jid, action),
        }
    }

    pub fn block(jid: &Jid) -> Self {
        Self {
            request: BlocklistItemRequest::block(jid),
        }
    }

    pub fn unblock(jid: &Jid) -> Self {
        Self {
            request: BlocklistItemRequest::unblock(jid),
        }
    }

    /// Construct a block spec with the LID and PN required on the wire.
    pub fn block_with_pn(lid: &Jid, pn_jid: &Jid) -> Self {
        Self {
            request: BlocklistItemRequest::block_with_pn(lid, pn_jid),
        }
    }
}

impl IqSpec for UpdateBlocklistSpec {
    type Response = ();

    fn build_iq(&self) -> InfoQuery<'static> {
        InfoQuery::set(
            BLOCKLIST_IQ_NAMESPACE,
            Jid::new("", Server::Pn),
            Some(NodeContent::Nodes(vec![self.request.clone().into_node()])),
        )
    }

    fn parse_response(&self, _response: &NodeRef<'_>) -> Result<Self::Response> {
        Ok(())
    }
}
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_blocklist_action_string_enum() {
        assert_eq!(BlocklistAction::Block.as_str(), "block");
        assert_eq!(BlocklistAction::Unblock.as_str(), "unblock");
        assert_eq!(
            BlocklistAction::try_from("block").unwrap(),
            BlocklistAction::Block
        );
        assert_eq!(
            BlocklistAction::try_from("unblock").unwrap(),
            BlocklistAction::Unblock
        );
    }

    #[test]
    fn test_blocklist_item_request_into_node() {
        let jid: Jid = "1234567890@s.whatsapp.net".parse().unwrap();
        let request = BlocklistItemRequest::block(&jid);
        let node = request.into_node();

        assert_eq!(node.tag, "item");
        assert!(node.attrs.get("action").is_some_and(|v| v == "block"));
        assert!(
            node.attrs
                .get("jid")
                .is_some_and(|v| v == "1234567890@s.whatsapp.net")
        );
    }

    #[test]
    fn test_blocklist_entry_into_node() {
        let jid: Jid = "1234567890@s.whatsapp.net".parse().unwrap();
        let entry = BlocklistEntry {
            jid: jid.clone(),
            timestamp: Some(1234567890),
        };
        let node = entry.into_node();

        assert_eq!(node.tag, "item");
        assert!(
            node.attrs
                .get("jid")
                .is_some_and(|v| v == "1234567890@s.whatsapp.net")
        );
        assert!(node.attrs.get("t").is_some_and(|v| v == "1234567890"));
    }

    #[test]
    fn test_blocklist_entry_try_from_node() {
        let node = NodeBuilder::new("item")
            .attr("jid", "1234567890@s.whatsapp.net")
            .attr("t", "1234567890")
            .build();

        let entry = BlocklistEntry::try_from_node(&node).unwrap();
        assert_eq!(entry.jid.user, "1234567890");
        assert_eq!(entry.timestamp, Some(1234567890));
    }

    #[test]
    fn test_blocklist_response_with_list_wrapper() {
        let list_node = NodeBuilder::new("list")
            .children([
                NodeBuilder::new("item")
                    .attr("jid", "111@s.whatsapp.net")
                    .build(),
                NodeBuilder::new("item")
                    .attr("jid", "222@s.whatsapp.net")
                    .build(),
            ])
            .build();
        let response_node = NodeBuilder::new("response").children([list_node]).build();

        let response = BlocklistResponse::try_from_node(&response_node).unwrap();
        assert_eq!(response.entries.len(), 2);
        assert_eq!(response.entries[0].jid.user, "111");
        assert_eq!(response.entries[1].jid.user, "222");
    }

    #[test]
    fn test_blocklist_response_direct_items() {
        let response_node = NodeBuilder::new("response")
            .children([
                NodeBuilder::new("item")
                    .attr("jid", "111@s.whatsapp.net")
                    .build(),
                NodeBuilder::new("item")
                    .attr("jid", "222@s.whatsapp.net")
                    .build(),
            ])
            .build();

        let response = BlocklistResponse::try_from_node(&response_node).unwrap();
        assert_eq!(response.entries.len(), 2);
    }

    #[test]
    fn test_update_blocklist_spec_convenience_methods() {
        let jid: Jid = "1234567890@s.whatsapp.net".parse().unwrap();

        let block_spec = UpdateBlocklistSpec::block(&jid);
        assert_eq!(block_spec.request.action, BlocklistAction::Block);

        let unblock_spec = UpdateBlocklistSpec::unblock(&jid);
        assert_eq!(unblock_spec.request.action, BlocklistAction::Unblock);
    }

    #[test]
    fn test_block_with_pn_emits_pn_jid_attr() {
        let lid: Jid = "12345678901234@lid".parse().unwrap();
        let pn: Jid = "5511999999999@s.whatsapp.net".parse().unwrap();
        let request = BlocklistItemRequest::block_with_pn(&lid, &pn);
        let node = request.into_node();

        assert_eq!(node.tag, "item");
        assert!(node.attrs.get("action").is_some_and(|v| v == "block"));
        assert!(
            node.attrs
                .get("jid")
                .is_some_and(|v| v == "12345678901234@lid")
        );
        assert!(
            node.attrs
                .get("pn_jid")
                .is_some_and(|v| v == "5511999999999@s.whatsapp.net")
        );
    }

    #[test]
    fn test_unblock_omits_pn_jid_attr() {
        let lid: Jid = "12345678901234@lid".parse().unwrap();
        let request = BlocklistItemRequest::unblock(&lid);
        let node = request.into_node();

        assert_eq!(node.tag, "item");
        assert!(node.attrs.get("action").is_some_and(|v| v == "unblock"));
        assert!(node.attrs.get("pn_jid").is_none());
    }
}