use crate::types::message::{MessageCategory, MessageInfo};
use crate::types::presence::ReceiptType;
use wacore_binary::NodeRef;
use wacore_binary::{Jid, JidExt as _, STATUS_BROADCAST_USER};
#[derive(Debug, Clone)]
pub struct ReceiptUser {
pub jid: Jid,
pub timestamp: Option<u64>,
pub r#type: Option<String>,
pub participant_pn: Option<Jid>,
pub participant_username: Option<String>,
}
pub fn parse_participants(
node: &NodeRef<'_>,
) -> (Option<String>, Option<String>, Vec<ReceiptUser>) {
let mut attrs = node.attrs();
let message_id = attrs.optional_string("message_id").map(|s| s.into_owned());
let key = attrs.optional_string("key").map(|s| s.into_owned());
let users = node
.children()
.map(|children| {
children
.iter()
.filter(|c| c.tag == "user")
.filter_map(|c| {
let mut a = c.attrs();
let jid = a.optional_jid("jid")?;
let timestamp = a.optional_u64("t");
let r#type = a.optional_string("type").map(|s| s.into_owned());
let participant_pn = a.optional_jid("participant_pn");
let participant_username = a
.optional_string("participant_username")
.map(|s| s.into_owned());
Some(ReceiptUser {
jid,
timestamp,
r#type,
participant_pn,
participant_username,
})
})
.collect()
})
.unwrap_or_default();
(message_id, key, users)
}
pub fn collect_simple_message_ids(
node: &NodeRef<'_>,
stanza_id: &str,
is_view: bool,
) -> Vec<String> {
let id_attr = if is_view { "server_id" } else { "id" };
let mut ids: Vec<String> = node
.get_optional_child("list")
.and_then(|list| {
list.children().map(|items| {
items
.iter()
.filter(|c| c.tag == "item")
.filter_map(|c| c.attrs().optional_string(id_attr).map(|s| s.into_owned()))
.collect()
})
})
.unwrap_or_default();
if !is_view {
ids.push(stanza_id.to_string());
}
ids
}
pub fn should_send_delivery_receipt(info: &MessageInfo) -> bool {
if info.id.is_empty()
|| info.source.chat.user == STATUS_BROADCAST_USER
|| info.source.chat.is_newsletter()
{
return false;
}
info.category == MessageCategory::Peer
|| !info.source.is_from_me
|| info.source.is_self_fanout()
}
pub fn downgrade_for_feature_incapable(
node: &NodeRef<'_>,
parsed_type: ReceiptType,
) -> ReceiptType {
if parsed_type != ReceiptType::Delivered {
return parsed_type;
}
let Some(err) = node.get_optional_child("error") else {
return parsed_type;
};
let mut a = err.attrs();
let reason = a.optional_string("reason");
let err_type = a.optional_string("type");
if reason.as_deref() == Some("lid") && err_type.as_deref() == Some("feature-incapable") {
ReceiptType::Sent
} else {
parsed_type
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::message::{MessageCategory, MessageInfo, MessageSource};
#[test]
fn feature_incapable_error_downgrades_delivery_to_sent() {
use wacore_binary::builder::NodeBuilder;
let with_error = NodeBuilder::new("receipt")
.children([NodeBuilder::new("error")
.attr("reason", "lid")
.attr("type", "feature-incapable")
.build()])
.build();
assert_eq!(
downgrade_for_feature_incapable(&with_error.as_node_ref(), ReceiptType::Delivered),
ReceiptType::Sent,
"lid/feature-incapable error downgrades delivery to sent"
);
let plain = NodeBuilder::new("receipt").build();
assert_eq!(
downgrade_for_feature_incapable(&plain.as_node_ref(), ReceiptType::Delivered),
ReceiptType::Delivered
);
let other = NodeBuilder::new("receipt")
.children([NodeBuilder::new("error")
.attr("reason", "lid")
.attr("type", "other")
.build()])
.build();
assert_eq!(
downgrade_for_feature_incapable(&other.as_node_ref(), ReceiptType::Delivered),
ReceiptType::Delivered
);
assert_eq!(
downgrade_for_feature_incapable(&with_error.as_node_ref(), ReceiptType::Read),
ReceiptType::Read
);
}
#[test]
fn skip_empty_id() {
let info = MessageInfo {
id: "".to_string(),
source: MessageSource {
chat: "12345@s.whatsapp.net".parse().unwrap(),
sender: "12345@s.whatsapp.net".parse().unwrap(),
is_from_me: false,
..Default::default()
},
..Default::default()
};
assert!(!should_send_delivery_receipt(&info));
}
#[test]
fn skip_status_broadcast() {
let info = MessageInfo {
id: "MSG1".to_string(),
source: MessageSource {
chat: "status@broadcast".parse().unwrap(),
sender: "12345@s.whatsapp.net".parse().unwrap(),
is_from_me: false,
is_group: true,
..Default::default()
},
..Default::default()
};
assert!(!should_send_delivery_receipt(&info));
}
#[test]
fn skip_newsletter() {
let info = MessageInfo {
id: "NL1".to_string(),
source: MessageSource {
chat: "120363173003902460@newsletter".parse().unwrap(),
sender: "120363173003902460@newsletter".parse().unwrap(),
is_from_me: false,
..Default::default()
},
..Default::default()
};
assert!(!should_send_delivery_receipt(&info));
}
#[test]
fn skip_own_non_peer_messages() {
let info = MessageInfo {
id: "OWN1".to_string(),
source: MessageSource {
chat: "12345@s.whatsapp.net".parse().unwrap(),
sender: "12345@s.whatsapp.net".parse().unwrap(),
is_from_me: true,
..Default::default()
},
..Default::default()
};
assert!(!should_send_delivery_receipt(&info));
}
#[test]
fn allow_peer_self_synced_messages() {
let info = MessageInfo {
id: "PEER1".to_string(),
source: MessageSource {
chat: "12345@s.whatsapp.net".parse().unwrap(),
sender: "12345@s.whatsapp.net".parse().unwrap(),
is_from_me: true,
..Default::default()
},
category: MessageCategory::Peer,
..Default::default()
};
assert!(should_send_delivery_receipt(&info));
}
#[test]
fn allow_self_fanout_with_recipient() {
let info = MessageInfo {
id: "FANOUT1".to_string(),
source: MessageSource {
chat: "200000000000002@bot".parse().unwrap(),
sender: "100000000000001@lid".parse().unwrap(),
recipient: Some("200000000000002@bot".parse().unwrap()),
is_from_me: true,
..Default::default()
},
..Default::default()
};
assert!(should_send_delivery_receipt(&info));
}
#[test]
fn skip_own_status_and_group_even_with_recipient() {
let own_status = MessageInfo {
id: "OWN_STATUS".to_string(),
source: MessageSource {
chat: "status@broadcast".parse().unwrap(),
sender: "100000000000001@lid".parse().unwrap(),
recipient: Some("100000000000001@lid".parse().unwrap()),
is_from_me: true,
..Default::default()
},
..Default::default()
};
assert!(!should_send_delivery_receipt(&own_status));
let own_group = MessageInfo {
id: "OWN_GROUP".to_string(),
source: MessageSource {
chat: "120363021033254949@g.us".parse().unwrap(),
sender: "100000000000001@lid".parse().unwrap(),
recipient: Some("100000000000001@lid".parse().unwrap()),
is_from_me: true,
is_group: true,
..Default::default()
},
..Default::default()
};
assert!(!should_send_delivery_receipt(&own_group));
}
#[test]
fn allow_incoming_dm() {
let info = MessageInfo {
id: "DM1".to_string(),
source: MessageSource {
chat: "12345@s.whatsapp.net".parse().unwrap(),
sender: "12345@s.whatsapp.net".parse().unwrap(),
is_from_me: false,
..Default::default()
},
..Default::default()
};
assert!(should_send_delivery_receipt(&info));
}
use wacore_binary::builder::NodeBuilder;
#[test]
fn participants_aggregated_by_message() {
let node = NodeBuilder::new("receipt")
.attr("id", "STANZA-AGG")
.children([NodeBuilder::new("participants")
.attr("message_id", "REAL-MSG-ID")
.children([
NodeBuilder::new("user")
.attr("jid", "11111@lid")
.attr("t", "1700000001")
.attr("type", "delivery")
.build(),
NodeBuilder::new("user")
.attr("jid", "22222@lid")
.attr("t", "1700000002")
.attr("type", "read")
.build(),
])
.build()])
.build();
let part = node.get_optional_child("participants").unwrap();
let (message_id, key, users) = parse_participants(&part.as_node_ref());
assert_eq!(message_id.as_deref(), Some("REAL-MSG-ID"));
assert!(key.is_none());
assert_eq!(users.len(), 2);
assert_eq!(users[0].jid.user, "11111");
assert_eq!(users[0].timestamp, Some(1700000001));
assert_eq!(users[0].r#type.as_deref(), Some("delivery"));
assert_eq!(users[1].r#type.as_deref(), Some("read"));
}
#[test]
fn participants_user_missing_t_yields_none_timestamp() {
let node = NodeBuilder::new("receipt")
.attr("id", "STANZA-NOT")
.children([NodeBuilder::new("participants")
.attr("message_id", "MSG-NOT")
.children([NodeBuilder::new("user")
.attr("jid", "99000000000001@lid")
.attr("type", "delivery")
.build()])
.build()])
.build();
let part = node.get_optional_child("participants").unwrap();
let (_, _, users) = parse_participants(&part.as_node_ref());
assert_eq!(users.len(), 1);
assert!(
users[0].timestamp.is_none(),
"missing <user t> must yield None"
);
}
#[test]
fn participants_aggregated_by_type() {
let node = NodeBuilder::new("receipt")
.attr("id", "STANZA-AGG2")
.children([NodeBuilder::new("participants")
.attr("key", "AGG-KEY")
.children([NodeBuilder::new("user")
.attr("jid", "33333@lid")
.attr("t", "1700000003")
.build()])
.build()])
.build();
let part = node.get_optional_child("participants").unwrap();
let (message_id, key, users) = parse_participants(&part.as_node_ref());
assert!(message_id.is_none());
assert_eq!(key.as_deref(), Some("AGG-KEY"));
assert_eq!(users.len(), 1);
assert!(
users[0].r#type.is_none(),
"no per-user type on aggregated_by_type"
);
}
#[test]
fn simple_message_ids_with_list() {
let node = NodeBuilder::new("receipt")
.attr("id", "STANZA-Z")
.children([NodeBuilder::new("list")
.children([
NodeBuilder::new("item").attr("id", "MSG-A").build(),
NodeBuilder::new("item").attr("id", "MSG-B").build(),
])
.build()])
.build();
let ids = collect_simple_message_ids(&node.as_node_ref(), "STANZA-Z", false);
assert_eq!(ids, vec!["MSG-A", "MSG-B", "STANZA-Z"]);
}
#[test]
fn simple_message_ids_without_list() {
let node = NodeBuilder::new("receipt").attr("id", "SOLO").build();
let ids = collect_simple_message_ids(&node.as_node_ref(), "SOLO", false);
assert_eq!(ids, vec!["SOLO"]);
}
#[test]
fn simple_message_ids_view_uses_server_id_no_stanza_append() {
let node = NodeBuilder::new("receipt")
.attr("id", "VIEW-STANZA")
.children([NodeBuilder::new("list")
.children([
NodeBuilder::new("item").attr("server_id", "100").build(),
NodeBuilder::new("item").attr("server_id", "101").build(),
])
.build()])
.build();
let ids = collect_simple_message_ids(&node.as_node_ref(), "VIEW-STANZA", true);
assert_eq!(ids, vec!["100", "101"]);
}
}