wacore 0.7.0

Core WhatsApp protocol implementation without runtime dependencies
Documentation
//! Spam report IQ specification.
//!
//! ## Wire Format
//! ```xml
//! <!-- Request -->
//! <iq xmlns="spam" type="set" to="s.whatsapp.net" id="...">
//!   <spam_list spam_flow="MessageMenu">
//!     <message id="MSG_ID" t="1234567890" from="1234567890@s.whatsapp.net">
//!       <raw v="3" mediatype="image">...</raw>
//!     </message>
//!   </spam_list>
//! </iq>
//!
//! <!-- Response -->
//! <iq from="s.whatsapp.net" id="..." type="result">
//!   <report_id>REPORT_123</report_id>
//! </iq>
//! ```

use crate::iq::spec::IqSpec;
use crate::iq::tctoken::build_tc_token_node;
use crate::request::InfoQuery;
use crate::types::spam_report::{SpamReportRequest, SpamReportResult, build_spam_list_node};
use wacore_binary::{Jid, Server};
use wacore_binary::{Node, NodeContent, NodeContentRef, NodeRef};

// Re-export types for convenience
pub use crate::types::spam_report::{
    SpamFlow, SpamReportRequest as Request, SpamReportResult as Response,
};

/// Sends a spam report for one or more messages to WhatsApp.
#[derive(Debug, Clone)]
pub struct SpamReportSpec {
    pub request: SpamReportRequest,
    /// Optional trusted-contact token for the reported contact, matching WA
    /// Web's `OutSpamTCTokenMixin` (gated on `enable_spam_report_iq_with_privacy_token`).
    pub tc_token: Option<Vec<u8>>,
}

impl SpamReportSpec {
    pub fn new(request: SpamReportRequest) -> Self {
        Self {
            request,
            tc_token: None,
        }
    }

    /// Include the reported contact's tctoken in the spam report IQ.
    pub fn with_tc_token(mut self, token: Vec<u8>) -> Self {
        self.tc_token = Some(token);
        self
    }
}

impl IqSpec for SpamReportSpec {
    type Response = SpamReportResult;

    fn build_iq(&self) -> InfoQuery<'static> {
        let mut children: Vec<Node> = vec![build_spam_list_node(&self.request)];
        if let Some(token) = &self.tc_token {
            children.push(build_tc_token_node(token));
        }

        InfoQuery::set(
            "spam",
            Jid::new("", Server::Pn),
            Some(NodeContent::Nodes(children)),
        )
    }

    fn parse_response(&self, response: &NodeRef<'_>) -> Result<Self::Response, anyhow::Error> {
        // Extract report_id from response if present
        let report_id = response
            .get_optional_child_by_tag(&["report_id"])
            .and_then(|n| match n.content.as_ref() {
                Some(NodeContentRef::String(s)) => Some(s.to_string()),
                _ => None,
            });

        Ok(SpamReportResult { report_id })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::spam_report::SpamFlow;
    use wacore_binary::builder::NodeBuilder;

    #[test]
    fn test_spam_report_spec_build_iq() {
        let request = SpamReportRequest {
            message_id: "TEST123".to_string(),
            message_timestamp: 1234567890,
            spam_flow: SpamFlow::MessageMenu,
            ..Default::default()
        };

        let spec = SpamReportSpec::new(request);
        let iq = spec.build_iq();

        assert_eq!(iq.namespace, "spam");
        assert_eq!(iq.query_type, crate::request::InfoQueryType::Set);

        if let Some(NodeContent::Nodes(nodes)) = &iq.content {
            assert_eq!(nodes.len(), 1);
            assert_eq!(nodes[0].tag, "spam_list");
            assert!(
                nodes[0]
                    .attrs
                    .get("spam_flow")
                    .is_some_and(|s| s == "MessageMenu")
            );
        } else {
            panic!("Expected NodeContent::Nodes");
        }
    }

    #[test]
    fn test_spam_report_spec_build_iq_with_tctoken() {
        let request = SpamReportRequest {
            message_id: "TEST123".to_string(),
            message_timestamp: 1234567890,
            spam_flow: SpamFlow::MessageMenu,
            ..Default::default()
        };

        let iq = SpamReportSpec::new(request)
            .with_tc_token(vec![0xCA, 0xFE])
            .build_iq();

        let Some(NodeContent::Nodes(nodes)) = &iq.content else {
            panic!("Expected NodeContent::Nodes");
        };
        assert!(nodes.iter().any(|n| n.tag == "spam_list"));
        let tctoken = nodes
            .iter()
            .find(|n| n.tag == "tctoken")
            .expect("spam report should carry a tctoken when set");
        match &tctoken.content {
            Some(NodeContent::Bytes(b)) => assert_eq!(b, &[0xCA, 0xFE]),
            _ => panic!("tctoken should carry bytes"),
        }
    }

    #[test]
    fn test_spam_report_spec_parse_response_with_report_id() {
        let request = SpamReportRequest {
            message_id: "TEST123".to_string(),
            message_timestamp: 1234567890,
            spam_flow: SpamFlow::MessageMenu,
            ..Default::default()
        };

        let spec = SpamReportSpec::new(request);

        let response = NodeBuilder::new("iq")
            .attr("type", "result")
            .children([NodeBuilder::new("report_id")
                .string_content("REPORT_ABC123")
                .build()])
            .build();

        let result = spec.parse_response(&response.as_node_ref()).unwrap();
        assert_eq!(result.report_id, Some("REPORT_ABC123".to_string()));
    }

    #[test]
    fn test_spam_report_spec_parse_response_without_report_id() {
        let request = SpamReportRequest {
            message_id: "TEST123".to_string(),
            message_timestamp: 1234567890,
            spam_flow: SpamFlow::MessageMenu,
            ..Default::default()
        };

        let spec = SpamReportSpec::new(request);

        let response = NodeBuilder::new("iq").attr("type", "result").build();

        let result = spec.parse_response(&response.as_node_ref()).unwrap();
        assert_eq!(result.report_id, None);
    }
}