use crate::client::Client;
use crate::types::events::{Event, Receipt};
use crate::types::presence::ReceiptType;
use log::debug;
use std::sync::Arc;
use wacore_binary::builder::NodeBuilder;
use wacore_binary::jid::{Jid, JidExt as _};
use wacore_binary::node::Node;
impl Client {
fn should_send_delivery_receipt(info: &crate::types::message::MessageInfo) -> bool {
use wacore_binary::jid::STATUS_BROADCAST_USER;
if info.id.is_empty()
|| info.source.chat.user == STATUS_BROADCAST_USER
|| info.source.chat.is_newsletter()
{
return false;
}
info.category == "peer" || !info.source.is_from_me
}
pub(crate) async fn handle_receipt(self: &Arc<Self>, node: Arc<Node>) {
let mut attrs = node.attrs();
let from = attrs.jid("from");
let id = match attrs.optional_string("id") {
Some(id) => id.to_string(),
None => {
log::warn!("Receipt stanza missing required 'id' attribute");
return;
}
};
let receipt_type_cow = attrs.optional_string("type");
let receipt_type_str = receipt_type_cow.as_deref().unwrap_or("delivery");
let participant = attrs.optional_jid("participant");
let receipt_type = ReceiptType::from(receipt_type_str.to_string());
debug!("Received receipt type '{receipt_type:?}' for message {id} from {from}");
let from_clone = from.clone();
let sender = if from.is_group() {
if let Some(participant) = participant {
participant
} else {
from_clone
}
} else {
from.clone()
};
let receipt = Receipt {
message_ids: vec![id.clone()],
source: crate::types::message::MessageSource {
chat: from.clone(),
sender: sender.clone(),
..Default::default()
},
timestamp: wacore::time::now_utc(),
r#type: receipt_type.clone(),
message_sender: sender.clone(),
};
if receipt_type == ReceiptType::Retry {
let client_clone = Arc::clone(self);
let node_clone = Arc::clone(&node);
self.runtime
.spawn(Box::pin(async move {
if let Err(e) = client_clone
.handle_retry_receipt(&receipt, &node_clone)
.await
{
log::warn!(
"Failed to handle retry receipt for {}: {:?}",
receipt.message_ids[0],
e
);
}
}))
.detach();
} else if receipt_type == ReceiptType::EncRekeyRetry {
if let Some(child) = node.get_optional_child("enc_rekey") {
let mut attrs = child.attrs();
log::debug!(
"Received enc_rekey_retry receipt for call-id={} from {} \
(call-creator={}, count={}). VoIP not implemented, forwarding as event.",
attrs
.optional_string("call-id")
.as_deref()
.unwrap_or_default(),
from,
attrs
.optional_string("call-creator")
.as_deref()
.unwrap_or_default(),
attrs
.optional_string("count")
.and_then(|s| s.parse::<u8>().ok())
.unwrap_or(1),
);
}
self.core.event_bus.dispatch(&Event::Receipt(receipt));
} else {
self.core.event_bus.dispatch(&Event::Receipt(receipt));
}
}
pub(crate) async fn send_delivery_receipt(&self, info: &crate::types::message::MessageInfo) {
if !Self::should_send_delivery_receipt(info) {
return;
}
let mut builder = NodeBuilder::new("receipt")
.attr("id", &info.id)
.attr("to", info.source.chat.clone());
if info.category == "peer" {
builder = builder.attr("type", "peer_msg");
}
if info.source.is_group {
builder = builder.attr("participant", info.source.sender.clone());
}
let receipt_node = builder.build();
debug!(target: "Client/Receipt", "Sending {} receipt for message {} to {}",
if info.category == "peer" { "peer_msg" } else { "delivery" },
info.id, info.source.sender);
if let Err(e) = self.send_node(receipt_node).await {
log::warn!(target: "Client/Receipt", "Failed to send delivery receipt for message {}: {:?}", info.id, e);
}
}
pub async fn mark_as_read(
&self,
chat: &Jid,
sender: Option<&Jid>,
message_ids: Vec<String>,
) -> Result<(), anyhow::Error> {
if message_ids.is_empty() {
return Ok(());
}
let timestamp = (wacore::time::now_secs() as u64).to_string();
let mut builder = NodeBuilder::new("receipt")
.attr("to", chat.clone())
.attr("type", "read")
.attr("id", &message_ids[0])
.attr("t", ×tamp);
if let Some(sender) = sender {
builder = builder.attr("participant", sender.clone());
}
if message_ids.len() > 1 {
let items: Vec<wacore_binary::node::Node> = message_ids[1..]
.iter()
.map(|id| NodeBuilder::new("item").attr("id", id).build())
.collect();
builder = builder.children(vec![NodeBuilder::new("list").children(items).build()]);
}
let node = builder.build();
debug!(target: "Client/Receipt", "Sending read receipt for {} message(s) to {}", message_ids.len(), chat);
self.send_node(node)
.await
.map_err(|e| anyhow::anyhow!("Failed to send read receipt: {}", e))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::persistence_manager::PersistenceManager;
use crate::test_utils::MockHttpClient;
use crate::types::message::{MessageInfo, MessageSource};
use std::sync::Mutex;
use wacore::types::events::EventHandler;
#[derive(Default)]
struct TestEventCollector {
events: Mutex<Vec<Event>>,
}
impl EventHandler for TestEventCollector {
fn handle_event(&self, event: &Event) {
self.events
.lock()
.expect("collector mutex should not be poisoned")
.push(event.clone());
}
}
impl TestEventCollector {
fn events(&self) -> Vec<Event> {
self.events
.lock()
.expect("collector mutex should not be poisoned")
.clone()
}
}
#[tokio::test]
async fn test_send_delivery_receipt_dm() {
let backend = crate::test_utils::create_test_backend().await;
let pm = Arc::new(
PersistenceManager::new(backend)
.await
.expect("persistence manager should initialize"),
);
let (client, _rx) = Client::new(
Arc::new(crate::runtime_impl::TokioRuntime),
pm,
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
)
.await;
let info = MessageInfo {
id: "TEST-ID-123".to_string(),
source: MessageSource {
chat: "12345@s.whatsapp.net"
.parse()
.expect("test JID should be valid"),
sender: "12345@s.whatsapp.net"
.parse()
.expect("test JID should be valid"),
is_from_me: false,
is_group: false,
..Default::default()
},
..Default::default()
};
client.send_delivery_receipt(&info).await;
}
#[tokio::test]
async fn test_send_delivery_receipt_group() {
let backend = crate::test_utils::create_test_backend().await;
let pm = Arc::new(
PersistenceManager::new(backend)
.await
.expect("persistence manager should initialize"),
);
let (client, _rx) = Client::new(
Arc::new(crate::runtime_impl::TokioRuntime),
pm,
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
)
.await;
let info = MessageInfo {
id: "GROUP-MSG-ID".to_string(),
source: MessageSource {
chat: "120363021033254949@g.us"
.parse()
.expect("test JID should be valid"),
sender: "15551234567@s.whatsapp.net"
.parse()
.expect("test JID should be valid"),
is_from_me: false,
is_group: true,
..Default::default()
},
..Default::default()
};
client.send_delivery_receipt(&info).await;
}
#[tokio::test]
async fn test_skip_delivery_receipt_for_own_messages() {
let backend = crate::test_utils::create_test_backend().await;
let pm = Arc::new(
PersistenceManager::new(backend)
.await
.expect("persistence manager should initialize"),
);
let (client, _rx) = Client::new(
Arc::new(crate::runtime_impl::TokioRuntime),
pm,
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
)
.await;
let info = MessageInfo {
id: "OWN-MSG-ID".to_string(),
source: MessageSource {
chat: "12345@s.whatsapp.net"
.parse()
.expect("test JID should be valid"),
sender: "12345@s.whatsapp.net"
.parse()
.expect("test JID should be valid"),
is_from_me: true, is_group: false,
..Default::default()
},
..Default::default()
};
client.send_delivery_receipt(&info).await;
}
#[tokio::test]
async fn test_skip_delivery_receipt_for_empty_id() {
let backend = crate::test_utils::create_test_backend().await;
let pm = Arc::new(
PersistenceManager::new(backend)
.await
.expect("persistence manager should initialize"),
);
let (client, _rx) = Client::new(
Arc::new(crate::runtime_impl::TokioRuntime),
pm,
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
)
.await;
let info = MessageInfo {
id: "".to_string(), source: MessageSource {
chat: "12345@s.whatsapp.net"
.parse()
.expect("test JID should be valid"),
sender: "12345@s.whatsapp.net"
.parse()
.expect("test JID should be valid"),
is_from_me: false,
is_group: false,
..Default::default()
},
..Default::default()
};
client.send_delivery_receipt(&info).await;
}
#[tokio::test]
async fn test_skip_delivery_receipt_for_status_broadcast() {
let backend = crate::test_utils::create_test_backend().await;
let pm = Arc::new(
PersistenceManager::new(backend)
.await
.expect("persistence manager should initialize"),
);
let (client, _rx) = Client::new(
Arc::new(crate::runtime_impl::TokioRuntime),
pm,
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
)
.await;
let info = MessageInfo {
id: "STATUS-MSG-ID".to_string(),
source: MessageSource {
chat: "status@broadcast"
.parse()
.expect("test JID should be valid"), sender: "12345@s.whatsapp.net"
.parse()
.expect("test JID should be valid"),
is_from_me: false,
is_group: true,
..Default::default()
},
..Default::default()
};
client.send_delivery_receipt(&info).await;
}
#[test]
fn test_should_skip_delivery_receipt_for_newsletter() {
let info = MessageInfo {
id: "NEWSLETTER-MSG-ID".to_string(),
source: MessageSource {
chat: "120363173003902460@newsletter"
.parse()
.expect("newsletter JID should be valid"),
sender: "120363173003902460@newsletter"
.parse()
.expect("newsletter JID should be valid"),
is_from_me: false,
is_group: false,
..Default::default()
},
..Default::default()
};
assert!(
!Client::should_send_delivery_receipt(&info),
"generic delivery receipts must be skipped for newsletters"
);
}
#[test]
fn test_should_send_peer_msg_receipt_for_self_synced_messages() {
let info = MessageInfo {
id: "PEER-MSG-ID".to_string(),
source: MessageSource {
chat: "155500012345@s.whatsapp.net"
.parse()
.expect("own PN JID should be valid"),
sender: "155500012345@s.whatsapp.net"
.parse()
.expect("own PN JID should be valid"),
is_from_me: true,
is_group: false,
..Default::default()
},
category: "peer".to_string(),
..Default::default()
};
assert!(
Client::should_send_delivery_receipt(&info),
"peer device messages must get delivery receipts even when is_from_me"
);
}
async fn setup_client_with_collector() -> (Arc<Client>, Arc<TestEventCollector>) {
let backend = crate::test_utils::create_test_backend().await;
let pm = Arc::new(
PersistenceManager::new(backend)
.await
.expect("persistence manager should initialize"),
);
let (client, _rx) = Client::new(
Arc::new(crate::runtime_impl::TokioRuntime),
pm,
Arc::new(crate::transport::mock::MockTransportFactory::new()),
Arc::new(MockHttpClient),
None,
)
.await;
let collector = Arc::new(TestEventCollector::default());
client.register_handler(collector.clone());
(client, collector)
}
#[tokio::test]
async fn test_enc_rekey_retry_receipt_dispatches_event() {
let (client, collector) = setup_client_with_collector().await;
let node = Arc::new(
NodeBuilder::new("receipt")
.attr("from", "5511999999999@s.whatsapp.net")
.attr("id", "3EB0AABBCCDD")
.attr("type", "enc_rekey_retry")
.children([
NodeBuilder::new("enc_rekey")
.attr("call-creator", "5511888888888@s.whatsapp.net")
.attr("call-id", "CALL-123")
.attr("count", "1")
.build(),
NodeBuilder::new("registration")
.bytes(12345u32.to_be_bytes().to_vec())
.build(),
])
.build(),
);
client.handle_receipt(node).await;
let events = collector.events();
let receipt_events: Vec<_> = events
.iter()
.filter_map(|e| match e {
Event::Receipt(r) => Some(r),
_ => None,
})
.collect();
assert_eq!(
receipt_events.len(),
1,
"enc_rekey_retry must dispatch exactly one Receipt event"
);
assert_eq!(
receipt_events[0].r#type,
ReceiptType::EncRekeyRetry,
"dispatched receipt must have EncRekeyRetry type"
);
assert_eq!(receipt_events[0].message_ids, vec!["3EB0AABBCCDD"]);
}
#[tokio::test]
async fn test_enc_rekey_retry_receipt_without_child_still_dispatches() {
let (client, collector) = setup_client_with_collector().await;
let node = Arc::new(
NodeBuilder::new("receipt")
.attr("from", "5511999999999@s.whatsapp.net")
.attr("id", "3EB0AABBCCDD")
.attr("type", "enc_rekey_retry")
.build(),
);
client.handle_receipt(node).await;
let events = collector.events();
let receipt_events: Vec<_> = events
.iter()
.filter_map(|e| match e {
Event::Receipt(r) => Some(r),
_ => None,
})
.collect();
assert_eq!(
receipt_events.len(),
1,
"malformed enc_rekey_retry must still dispatch Receipt event"
);
assert_eq!(receipt_events[0].r#type, ReceiptType::EncRekeyRetry);
}
#[test]
fn test_should_skip_non_peer_self_messages() {
let info = MessageInfo {
id: "SELF-MSG-ID".to_string(),
source: MessageSource {
chat: "155500012345@s.whatsapp.net"
.parse()
.expect("own PN JID should be valid"),
sender: "155500012345@s.whatsapp.net"
.parse()
.expect("own PN JID should be valid"),
is_from_me: true,
is_group: false,
..Default::default()
},
..Default::default()
};
assert!(
!Client::should_send_delivery_receipt(&info),
"non-peer self messages must not get delivery receipts"
);
}
#[test]
fn test_receipt_node_uses_jid_attrs() {
use wacore_binary::node::NodeValue;
let chat_jid: Jid = "120363021033254949@g.us"
.parse()
.expect("test JID should be valid");
let sender_jid: Jid = "15551234567@s.whatsapp.net"
.parse()
.expect("test JID should be valid");
let node = NodeBuilder::new("receipt")
.attr("id", "MSG-123")
.attr("to", chat_jid.clone())
.attr("participant", sender_jid.clone())
.build();
let to_attr = node.attrs.get("to").expect("receipt must have 'to' attr");
assert!(
matches!(to_attr, NodeValue::Jid(_)),
"'to' attr should be JID-typed, got: {:?}",
to_attr
);
assert_eq!(to_attr.to_jid().unwrap(), chat_jid);
let participant_attr = node
.attrs
.get("participant")
.expect("group receipt must have 'participant' attr");
assert!(
matches!(participant_attr, NodeValue::Jid(_)),
"'participant' attr should be JID-typed, got: {:?}",
participant_attr
);
assert_eq!(participant_attr.to_jid().unwrap(), sender_jid);
}
}