use crate::WireEnum;
use crate::protocol::ProtocolNode;
use anyhow::Result;
use thiserror::Error;
use wacore_binary::Jid;
use wacore_binary::Node;
use wacore_binary::NodeRef;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ChatstateParseError {
#[error("expected <chatstate>, got <{0}>")]
WrongTag(String),
#[error("self-echo chatstate (has 'to' but no 'from')")]
SelfEcho,
#[error("missing required attribute 'from'")]
MissingFrom,
#[error("invalid JID: {0}")]
InvalidJid(#[from] anyhow::Error),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)]
pub enum ReceivedChatState {
#[wire = "typing"]
Typing,
#[wire = "recording_audio"]
RecordingAudio,
#[wire = "idle"]
#[wire_default]
Idle,
}
impl ReceivedChatState {
pub fn from_child_node(child: &NodeRef<'_>) -> Self {
match child.tag.as_ref() {
"composing" => {
if child
.get_attr("media")
.map(|v| v.as_str())
.is_some_and(|s| s == "audio")
{
Self::RecordingAudio
} else {
Self::Typing
}
}
"paused" => Self::Idle,
_ => Self::Idle, }
}
}
#[derive(Debug, Clone)]
pub enum ChatstateSource {
User { from: Jid },
Group { from: Jid, participant: Jid },
}
#[derive(Debug, Clone)]
pub struct ChatstateStanza {
pub source: ChatstateSource,
pub state: ReceivedChatState,
}
impl ChatstateStanza {
pub fn parse(node: &NodeRef<'_>) -> Result<Self, ChatstateParseError> {
if node.tag != "chatstate" {
return Err(ChatstateParseError::WrongTag(node.tag.to_string()));
}
let mut attrs = node.attrs();
let from = match attrs.optional_jid("from") {
Some(jid) => jid,
None => {
if attrs.optional_jid("to").is_some() {
return Err(ChatstateParseError::SelfEcho);
}
return Err(ChatstateParseError::MissingFrom);
}
};
let source = match attrs.optional_jid("participant") {
Some(participant) => ChatstateSource::Group { from, participant },
None => ChatstateSource::User { from },
};
let state = node
.children()
.and_then(|children| children.first())
.map(ReceivedChatState::from_child_node)
.unwrap_or(ReceivedChatState::Idle);
Ok(Self { source, state })
}
}
impl ProtocolNode for ChatstateStanza {
fn tag(&self) -> &'static str {
"chatstate"
}
fn into_node(self) -> Node {
unimplemented!("ChatstateStanza is incoming-only")
}
fn try_from_node_ref(node: &NodeRef<'_>) -> Result<Self> {
Self::parse(node).map_err(Into::into)
}
}
#[cfg(test)]
mod tests {
use super::*;
use wacore_binary::builder::NodeBuilder;
#[test]
fn test_received_chat_state_string_enum() {
assert_eq!(ReceivedChatState::Typing.as_str(), "typing");
assert_eq!(
ReceivedChatState::RecordingAudio.as_str(),
"recording_audio"
);
assert_eq!(ReceivedChatState::Idle.as_str(), "idle");
assert_eq!(ReceivedChatState::default(), ReceivedChatState::Idle);
}
#[test]
fn test_parse_user_typing() {
let node = NodeBuilder::new("chatstate")
.attr("from", "1234567890@s.whatsapp.net")
.children([NodeBuilder::new("composing").build()])
.build();
let stanza = ChatstateStanza::try_from_node(&node).unwrap();
assert!(matches!(stanza.source, ChatstateSource::User { .. }));
assert_eq!(stanza.state, ReceivedChatState::Typing);
if let ChatstateSource::User { from } = stanza.source {
assert_eq!(from.user, "1234567890");
}
}
#[test]
fn test_parse_user_recording() {
let node = NodeBuilder::new("chatstate")
.attr("from", "1234567890@s.whatsapp.net")
.children([NodeBuilder::new("composing").attr("media", "audio").build()])
.build();
let stanza = ChatstateStanza::try_from_node(&node).unwrap();
assert_eq!(stanza.state, ReceivedChatState::RecordingAudio);
}
#[test]
fn test_parse_user_paused() {
let node = NodeBuilder::new("chatstate")
.attr("from", "1234567890@s.whatsapp.net")
.children([NodeBuilder::new("paused").build()])
.build();
let stanza = ChatstateStanza::try_from_node(&node).unwrap();
assert_eq!(stanza.state, ReceivedChatState::Idle);
}
#[test]
fn test_parse_group_typing() {
let node = NodeBuilder::new("chatstate")
.attr("from", "123456789-1234567890@g.us")
.attr("participant", "1234567890@s.whatsapp.net")
.children([NodeBuilder::new("composing").build()])
.build();
let stanza = ChatstateStanza::try_from_node(&node).unwrap();
assert!(matches!(stanza.source, ChatstateSource::Group { .. }));
assert_eq!(stanza.state, ReceivedChatState::Typing);
if let ChatstateSource::Group { from, participant } = stanza.source {
assert_eq!(from.user, "123456789-1234567890");
assert_eq!(participant.user, "1234567890");
}
}
#[test]
fn test_parse_group_recording() {
let node = NodeBuilder::new("chatstate")
.attr("from", "123456789-1234567890@g.us")
.attr("participant", "5678@s.whatsapp.net")
.children([NodeBuilder::new("composing").attr("media", "audio").build()])
.build();
let stanza = ChatstateStanza::try_from_node(&node).unwrap();
assert!(matches!(stanza.source, ChatstateSource::Group { .. }));
assert_eq!(stanza.state, ReceivedChatState::RecordingAudio);
}
#[test]
fn test_parse_missing_from_error() {
let node = NodeBuilder::new("chatstate")
.children([NodeBuilder::new("composing").build()])
.build();
let result = ChatstateStanza::parse(&node.as_node_ref());
assert!(matches!(result, Err(ChatstateParseError::MissingFrom)));
}
#[test]
fn test_parse_self_echo_chatstate() {
let node = NodeBuilder::new("chatstate")
.attr("to", "1234567890@s.whatsapp.net")
.children([NodeBuilder::new("composing").build()])
.build();
let result = ChatstateStanza::parse(&node.as_node_ref());
assert!(matches!(result, Err(ChatstateParseError::SelfEcho)));
}
#[test]
fn test_parse_wrong_tag_error() {
let node = NodeBuilder::new("message")
.attr("from", "1234567890@s.whatsapp.net")
.build();
let result = ChatstateStanza::parse(&node.as_node_ref());
assert!(matches!(result, Err(ChatstateParseError::WrongTag(_))));
}
#[test]
fn test_parse_no_children_defaults_to_idle() {
let node = NodeBuilder::new("chatstate")
.attr("from", "1234567890@s.whatsapp.net")
.build();
let stanza = ChatstateStanza::try_from_node(&node).unwrap();
assert_eq!(stanza.state, ReceivedChatState::Idle);
}
#[test]
fn test_parse_unknown_child_defaults_to_idle() {
let node = NodeBuilder::new("chatstate")
.attr("from", "1234567890@s.whatsapp.net")
.children([NodeBuilder::new("unknown_state").build()])
.build();
let stanza = ChatstateStanza::try_from_node(&node).unwrap();
assert_eq!(stanza.state, ReceivedChatState::Idle);
}
#[test]
fn test_parse_lid_jid_chatstate() {
let node = NodeBuilder::new("chatstate")
.attr("from", "236395184570386@lid")
.children([NodeBuilder::new("composing").build()])
.build();
let stanza = ChatstateStanza::parse(&node.as_node_ref()).unwrap();
assert!(matches!(stanza.source, ChatstateSource::User { .. }));
assert_eq!(stanza.state, ReceivedChatState::Typing);
if let ChatstateSource::User { from } = stanza.source {
assert_eq!(from.user, "236395184570386");
assert_eq!(from.server, "lid");
}
}
#[test]
fn test_parse_jid_attribute_as_jid_type() {
use wacore_binary::Jid;
let jid: Jid = "236395184570386@lid".parse().unwrap();
let node = NodeBuilder::new("chatstate")
.attr("from", jid)
.children([NodeBuilder::new("composing").build()])
.build();
let stanza = ChatstateStanza::parse(&node.as_node_ref()).unwrap();
assert!(matches!(stanza.source, ChatstateSource::User { .. }));
assert_eq!(stanza.state, ReceivedChatState::Typing);
if let ChatstateSource::User { from } = stanza.source {
assert_eq!(from.user, "236395184570386");
assert_eq!(from.server, "lid");
}
}
#[test]
fn test_parse_group_chatstate_with_jid_types() {
use wacore_binary::Jid;
let group_jid: Jid = "123456789-1234567890@g.us".parse().unwrap();
let participant_jid: Jid = "236395184570386@lid".parse().unwrap();
let node = NodeBuilder::new("chatstate")
.attr("from", group_jid)
.attr("participant", participant_jid)
.children([NodeBuilder::new("composing").build()])
.build();
let stanza = ChatstateStanza::parse(&node.as_node_ref()).unwrap();
assert!(matches!(stanza.source, ChatstateSource::Group { .. }));
assert_eq!(stanza.state, ReceivedChatState::Typing);
if let ChatstateSource::Group { from, participant } = stanza.source {
assert_eq!(from.server, "g.us");
assert_eq!(participant.user, "236395184570386");
assert_eq!(participant.server, "lid");
}
}
}