use std::str::FromStr;
use wacore_binary::{Jid, JidExt};
use waproto::whatsapp as wa;
macro_rules! with_context_info_fields {
($callback:ident!($($prefix:tt)*)) => {
$callback!($($prefix)*
extended_text_message,
image_message,
video_message,
audio_message,
document_message,
sticker_message,
location_message,
live_location_message,
contact_message,
contacts_array_message,
buttons_message,
buttons_response_message,
list_message,
list_response_message,
template_message,
template_button_reply_message,
interactive_message,
interactive_response_message,
poll_creation_message,
poll_creation_message_v2,
poll_creation_message_v3,
product_message,
order_message,
group_invite_message,
event_message,
sticker_pack_message,
newsletter_admin_invite_message,
)
};
}
macro_rules! for_each_context_info_message {
($msg:expr, $ctx:ident, $body:block) => {
with_context_info_fields!(for_each_context_info_impl!($msg, $ctx, $body,))
};
}
macro_rules! for_each_context_info_impl {
($msg:expr, $ctx:ident, $body:block, $($field:ident),+ $(,)?) => {
$(
if let Some(ref mut m) = $msg.$field {
if let Some(ref mut $ctx) = m.context_info $body
}
)+
};
}
macro_rules! set_context_info_on_message {
($msg:expr, $ctx:expr) => {
with_context_info_fields!(set_context_info_impl!($msg, $ctx,))
};
}
macro_rules! set_context_info_impl {
($msg:expr, $ctx:expr, $($field:ident),+ $(,)?) => {{
let ctx = $ctx;
$(
if let Some(ref mut m) = $msg.$field {
m.context_info = Some(ctx);
return true;
}
)+
false
}};
}
pub trait MessageExt {
fn get_base_message(&self) -> &wa::Message;
fn into_base_message(self) -> wa::Message;
fn is_ephemeral(&self) -> bool;
fn is_view_once(&self) -> bool;
fn get_caption(&self) -> Option<&str>;
fn text_content(&self) -> Option<&str>;
fn prepare_for_quote(&self) -> Box<wa::Message>;
fn set_context_info(&mut self, context: wa::ContextInfo) -> bool;
fn get_ephemeral_expiration(&self) -> Option<u32>;
fn set_ephemeral_expiration(&mut self, expiration: u32) -> bool;
}
impl MessageExt for wa::Message {
fn get_base_message(&self) -> &wa::Message {
let mut current = self;
if let Some(msg) = self
.device_sent_message
.as_ref()
.and_then(|m| m.message.as_ref())
{
current = msg;
}
if let Some(msg) = current
.ephemeral_message
.as_ref()
.and_then(|m| m.message.as_ref())
{
current = msg;
}
if let Some(msg) = current
.view_once_message
.as_ref()
.and_then(|m| m.message.as_ref())
{
current = msg;
}
if let Some(msg) = current
.view_once_message_v2
.as_ref()
.and_then(|m| m.message.as_ref())
{
current = msg;
}
if let Some(msg) = current
.document_with_caption_message
.as_ref()
.and_then(|m| m.message.as_ref())
{
current = msg;
}
if let Some(msg) = current
.edited_message
.as_ref()
.and_then(|m| m.message.as_ref())
{
current = msg;
}
current
}
fn into_base_message(mut self) -> wa::Message {
macro_rules! peel_wrapper {
($field:ident) => {
if let Some(mut wrapper) = self.$field.take() {
if let Some(msg) = wrapper.message.take() {
self = *msg;
} else {
self.$field = Some(wrapper);
}
}
};
}
peel_wrapper!(device_sent_message);
peel_wrapper!(ephemeral_message);
peel_wrapper!(view_once_message);
peel_wrapper!(view_once_message_v2);
peel_wrapper!(document_with_caption_message);
peel_wrapper!(edited_message);
self
}
fn is_ephemeral(&self) -> bool {
self.ephemeral_message.is_some()
}
fn is_view_once(&self) -> bool {
let mut current = self;
loop {
if current.view_once_message.is_some()
|| current.view_once_message_v2.is_some()
|| current.view_once_message_v2_extension.is_some()
{
return true;
}
if let Some(inner) = current
.device_sent_message
.as_ref()
.and_then(|m| m.message.as_ref())
{
current = inner;
continue;
}
if let Some(inner) = current
.ephemeral_message
.as_ref()
.and_then(|m| m.message.as_ref())
{
current = inner;
continue;
}
break;
}
let base = self.get_base_message();
matches!(
base.image_message.as_deref().and_then(|m| m.view_once),
Some(true)
) || matches!(
base.video_message.as_deref().and_then(|m| m.view_once),
Some(true)
) || matches!(
base.audio_message.as_deref().and_then(|m| m.view_once),
Some(true)
) || matches!(
base.extended_text_message
.as_deref()
.and_then(|m| m.view_once),
Some(true)
)
}
fn get_caption(&self) -> Option<&str> {
let base = self.get_base_message();
if let Some(msg) = &base.image_message {
return msg.caption.as_deref();
}
if let Some(msg) = &base.video_message {
return msg.caption.as_deref();
}
if let Some(msg) = &base.document_message {
return msg.caption.as_deref();
}
None
}
fn text_content(&self) -> Option<&str> {
let base = self.get_base_message();
if let Some(text) = &base.conversation
&& !text.is_empty()
{
return Some(text);
}
if let Some(ext_text) = &base.extended_text_message
&& let Some(text) = &ext_text.text
{
return Some(text);
}
None
}
fn prepare_for_quote(&self) -> Box<wa::Message> {
let mut msg = self.clone();
strip_nested_context_info(&mut msg);
Box::new(msg)
}
fn set_context_info(&mut self, context: wa::ContextInfo) -> bool {
set_context_info_on_message!(self, Box::new(context))
}
fn get_ephemeral_expiration(&self) -> Option<u32> {
macro_rules! check {
($($field:ident),+ $(,)?) => {
$(
if let Some(ref m) = self.$field {
if let Some(ref ctx) = m.context_info {
if let Some(exp) = ctx.expiration {
if exp > 0 {
return Some(exp);
}
}
}
}
)+
};
}
with_context_info_fields!(check!());
None
}
fn set_ephemeral_expiration(&mut self, expiration: u32) -> bool {
if expiration == 0 {
return false;
}
macro_rules! try_set {
($($field:ident),+ $(,)?) => {
$(
if let Some(ref mut m) = self.$field {
let ctx = m.context_info.get_or_insert_with(|| Box::new(wa::ContextInfo::default()));
ctx.expiration = Some(expiration);
return true;
}
)+
};
}
with_context_info_fields!(try_set!());
false
}
}
pub(crate) fn strip_nested_context_info(msg: &mut wa::Message) {
fn clear_nested_context(ctx: &mut wa::ContextInfo) {
ctx.mentioned_jid.clear();
ctx.group_mentions.clear();
let is_bot = ctx
.participant
.as_ref()
.and_then(|p| Jid::from_str(p).ok())
.is_some_and(|jid| jid.is_bot());
if !is_bot {
ctx.quoted_message = None;
ctx.stanza_id = None;
ctx.remote_jid = None;
ctx.participant = None;
}
}
for_each_context_info_message!(msg, ctx, {
clear_nested_context(ctx);
});
macro_rules! recurse_into_wrapper {
($($wrapper:ident),+ $(,)?) => {
$(
if let Some(ref mut wrapper) = msg.$wrapper {
if let Some(ref mut inner) = wrapper.message {
strip_nested_context_info(inner);
}
}
)+
};
}
recurse_into_wrapper!(
ephemeral_message,
view_once_message,
view_once_message_v2,
document_with_caption_message,
edited_message,
);
if let Some(ref mut wrapper) = msg.device_sent_message
&& let Some(ref mut inner) = wrapper.message
{
strip_nested_context_info(inner);
}
}
pub fn merge_dsm_context(
inner: Option<wa::MessageContextInfo>,
outer: Option<&wa::MessageContextInfo>,
) -> Option<wa::MessageContextInfo> {
match (inner, outer) {
(None, None) => None,
(Some(mut inner), None) => {
inner.limit_sharing_v2 = None;
Some(inner)
}
(None, Some(outer)) => Some(wa::MessageContextInfo {
message_secret: outer.message_secret.clone(),
message_association: outer.message_association.clone(),
limit_sharing_v2: outer.limit_sharing_v2,
thread_id: outer.thread_id.clone(),
bot_metadata: outer.bot_metadata.clone(),
..Default::default()
}),
(Some(mut inner), Some(outer)) => {
if inner.message_secret.is_none() {
inner.message_secret = outer.message_secret.clone();
}
if inner.message_association.is_none() {
inner.message_association = outer.message_association.clone();
}
inner.limit_sharing_v2 = outer.limit_sharing_v2;
if inner.thread_id.is_empty() {
inner.thread_id = outer.thread_id.clone();
}
if inner.bot_metadata.is_none() {
inner.bot_metadata = outer.bot_metadata.clone();
}
Some(inner)
}
}
}
pub fn build_quote_context(
message_id: impl Into<String>,
sender_jid: impl Into<String>,
quoted_message: &wa::Message,
) -> wa::ContextInfo {
wa::ContextInfo {
stanza_id: Some(message_id.into()),
participant: Some(sender_jid.into()),
quoted_message: Some(quoted_message.prepare_for_quote()),
..Default::default()
}
}
pub fn build_quote_context_with_info(
message_id: impl Into<String>,
sender_jid: &Jid,
chat_jid: &Jid,
quoted_message: &wa::Message,
) -> wa::ContextInfo {
let remote_jid = chat_jid.to_string();
let participant = if chat_jid.is_newsletter() {
remote_jid.clone()
} else {
sender_jid.to_string()
};
wa::ContextInfo {
stanza_id: Some(message_id.into()),
participant: Some(participant),
remote_jid: Some(remote_jid),
quoted_message: Some(quoted_message.prepare_for_quote()),
..Default::default()
}
}
pub fn wrap_as_album_child(
mut inner_message: wa::Message,
parent_key: wa::MessageKey,
) -> wa::Message {
let existing_context = inner_message.message_context_info.take();
let association = wa::MessageAssociation {
association_type: Some(wa::message_association::AssociationType::MediaAlbum as i32),
parent_message_key: Some(parent_key),
message_index: None,
};
let mut outer_context = existing_context.unwrap_or_default();
outer_context.message_association = Some(association);
wa::Message {
associated_child_message: Some(Box::new(wa::message::FutureProofMessage {
message: Some(Box::new(inner_message)),
})),
message_context_info: Some(outer_context),
..Default::default()
}
}
pub trait ConversationExt {
fn subject(&self) -> Option<&str>;
fn participant_jids(&self) -> Vec<Jid>;
fn admin_jids(&self) -> Vec<Jid>;
fn is_locked(&self) -> bool;
fn is_announce_only(&self) -> bool;
}
impl ConversationExt for wa::Conversation {
fn subject(&self) -> Option<&str> {
self.name.as_deref()
}
fn participant_jids(&self) -> Vec<Jid> {
self.participant
.iter()
.filter_map(|p| Jid::from_str(&p.user_jid).ok())
.collect()
}
fn admin_jids(&self) -> Vec<Jid> {
use wa::group_participant::Rank;
self.participant
.iter()
.filter(|p| matches!(p.rank(), Rank::Admin | Rank::Superadmin))
.filter_map(|p| Jid::from_str(&p.user_jid).ok())
.collect()
}
fn is_locked(&self) -> bool {
self.locked.unwrap_or(false)
}
fn is_announce_only(&self) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_message_with_mentions() -> wa::Message {
wa::Message {
extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage {
text: Some("Hello @user1 @user2".to_string()),
context_info: Some(Box::new(wa::ContextInfo {
mentioned_jid: vec![
"111111@s.whatsapp.net".to_string(),
"222222@s.whatsapp.net".to_string(),
],
group_mentions: vec![wa::GroupMention {
group_jid: Some("120363012345@g.us".to_string()),
group_subject: Some("Test Group".to_string()),
}],
..Default::default()
})),
..Default::default()
})),
..Default::default()
}
}
#[test]
fn test_prepare_for_quote_strips_mentions_preserves_content() {
use wa::message::extended_text_message::{FontType, PreviewType};
let original = wa::Message {
extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage {
text: Some("Hello @user1 @user2".to_string()),
matched_text: Some("https://example.com".to_string()),
description: Some("Example description".to_string()),
title: Some("Example Title".to_string()),
text_argb: Some(0xFFFFFF),
background_argb: Some(0x000000),
font: Some(FontType::SystemBold.into()),
preview_type: Some(PreviewType::Video.into()),
context_info: Some(Box::new(wa::ContextInfo {
mentioned_jid: vec![
"111111@s.whatsapp.net".to_string(),
"222222@s.whatsapp.net".to_string(),
],
group_mentions: vec![wa::GroupMention {
group_jid: Some("120363012345@g.us".to_string()),
group_subject: Some("Test Group".to_string()),
}],
is_forwarded: Some(true),
forwarding_score: Some(5),
..Default::default()
})),
..Default::default()
})),
..Default::default()
};
let ext = original.extended_text_message.as_ref().unwrap();
let ctx = ext.context_info.as_ref().unwrap();
assert_eq!(ctx.mentioned_jid.len(), 2);
assert_eq!(ctx.group_mentions.len(), 1);
let prepared = original.prepare_for_quote();
let ext = prepared.extended_text_message.as_ref().unwrap();
let ctx = ext.context_info.as_ref().unwrap();
assert!(
ctx.mentioned_jid.is_empty(),
"mentioned_jid should be empty after prepare_for_quote"
);
assert!(
ctx.group_mentions.is_empty(),
"group_mentions should be empty after prepare_for_quote"
);
assert!(
ctx.quoted_message.is_none(),
"quoted_message should be None after prepare_for_quote"
);
assert!(
ctx.stanza_id.is_none(),
"stanza_id should be None after prepare_for_quote"
);
assert!(
ctx.participant.is_none(),
"participant should be None after prepare_for_quote"
);
assert!(
ctx.remote_jid.is_none(),
"remote_jid should be None after prepare_for_quote"
);
assert_eq!(ext.text.as_deref(), Some("Hello @user1 @user2"));
assert_eq!(ext.matched_text.as_deref(), Some("https://example.com"));
assert_eq!(ext.description.as_deref(), Some("Example description"));
assert_eq!(ext.title.as_deref(), Some("Example Title"));
assert_eq!(ext.text_argb, Some(0xFFFFFF));
assert_eq!(ext.background_argb, Some(0x000000));
assert_eq!(ext.font(), FontType::SystemBold);
assert_eq!(ext.preview_type(), PreviewType::Video);
assert_eq!(ctx.is_forwarded, Some(true));
assert_eq!(ctx.forwarding_score, Some(5));
}
#[test]
fn test_prepare_for_quote_preserves_media_fields() {
let original = wa::Message {
image_message: Some(Box::new(wa::message::ImageMessage {
url: Some("https://mmg.whatsapp.net/...".to_string()),
mimetype: Some("image/jpeg".to_string()),
caption: Some("Check out this image!".to_string()),
file_sha256: Some(vec![1, 2, 3, 4]),
file_length: Some(12345),
height: Some(1080),
width: Some(1920),
media_key: Some(vec![5, 6, 7, 8]),
direct_path: Some("/v/t62.1234-5/...".to_string()),
context_info: Some(Box::new(wa::ContextInfo {
mentioned_jid: vec!["someone@s.whatsapp.net".to_string()],
..Default::default()
})),
..Default::default()
})),
..Default::default()
};
let prepared = original.prepare_for_quote();
let img = prepared.image_message.as_ref().unwrap();
let ctx = img.context_info.as_ref().unwrap();
assert!(ctx.mentioned_jid.is_empty());
assert_eq!(img.url.as_deref(), Some("https://mmg.whatsapp.net/..."));
assert_eq!(img.mimetype.as_deref(), Some("image/jpeg"));
assert_eq!(img.caption.as_deref(), Some("Check out this image!"));
assert_eq!(img.file_sha256, Some(vec![1, 2, 3, 4]));
assert_eq!(img.file_length, Some(12345));
assert_eq!(img.height, Some(1080));
assert_eq!(img.width, Some(1920));
assert_eq!(img.media_key, Some(vec![5, 6, 7, 8]));
assert_eq!(img.direct_path.as_deref(), Some("/v/t62.1234-5/..."));
}
#[test]
fn test_prepare_for_quote_breaks_quote_chain() {
let original = wa::Message {
extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage {
text: Some("This is a reply".to_string()),
context_info: Some(Box::new(wa::ContextInfo {
stanza_id: Some("original-msg-id".to_string()),
participant: Some("original-sender@s.whatsapp.net".to_string()),
remote_jid: Some("chat@s.whatsapp.net".to_string()),
quoted_message: Some(Box::new(wa::Message {
conversation: Some("The original message".to_string()),
..Default::default()
})),
mentioned_jid: vec!["user@s.whatsapp.net".to_string()],
is_forwarded: Some(true),
forwarding_score: Some(3),
..Default::default()
})),
..Default::default()
})),
..Default::default()
};
let prepared = original.prepare_for_quote();
let ext = prepared.extended_text_message.as_ref().unwrap();
let ctx = ext.context_info.as_ref().unwrap();
assert!(
ctx.quoted_message.is_none(),
"quoted_message should be None (quote chain broken)"
);
assert!(
ctx.stanza_id.is_none(),
"stanza_id should be None (quote chain broken)"
);
assert!(
ctx.participant.is_none(),
"participant should be None (quote chain broken)"
);
assert!(
ctx.remote_jid.is_none(),
"remote_jid should be None (quote chain broken)"
);
assert!(
ctx.mentioned_jid.is_empty(),
"mentioned_jid should be empty"
);
assert_eq!(
ctx.is_forwarded,
Some(true),
"is_forwarded should be preserved"
);
assert_eq!(
ctx.forwarding_score,
Some(3),
"forwarding_score should be preserved"
);
assert_eq!(ext.text.as_deref(), Some("This is a reply"));
}
#[test]
fn test_set_context_info_extended_text() {
let mut msg = wa::Message {
extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage {
text: Some("Reply text".to_string()),
..Default::default()
})),
..Default::default()
};
let context = wa::ContextInfo {
stanza_id: Some("test-id".to_string()),
participant: Some("sender@s.whatsapp.net".to_string()),
..Default::default()
};
assert!(msg.set_context_info(context));
let ext = msg.extended_text_message.as_ref().unwrap();
let ctx = ext.context_info.as_ref().unwrap();
assert_eq!(ctx.stanza_id.as_deref(), Some("test-id"));
assert_eq!(ctx.participant.as_deref(), Some("sender@s.whatsapp.net"));
}
#[test]
fn test_set_context_info_image() {
let mut msg = wa::Message {
image_message: Some(Box::new(wa::message::ImageMessage {
caption: Some("Image caption".to_string()),
..Default::default()
})),
..Default::default()
};
let context = wa::ContextInfo {
stanza_id: Some("img-id".to_string()),
..Default::default()
};
assert!(msg.set_context_info(context));
let img = msg.image_message.as_ref().unwrap();
assert!(img.context_info.is_some());
assert_eq!(
img.context_info.as_ref().unwrap().stanza_id.as_deref(),
Some("img-id")
);
}
#[test]
fn test_set_context_info_location() {
let mut msg = wa::Message {
location_message: Some(Box::new(wa::message::LocationMessage {
degrees_latitude: Some(40.7128),
degrees_longitude: Some(-74.0060),
name: Some("New York".to_string()),
..Default::default()
})),
..Default::default()
};
let context = wa::ContextInfo {
stanza_id: Some("loc-id".to_string()),
..Default::default()
};
assert!(msg.set_context_info(context));
let loc = msg.location_message.as_ref().unwrap();
assert!(loc.context_info.is_some());
}
#[test]
fn test_set_context_info_unsupported() {
let mut msg = wa::Message {
conversation: Some("Simple text".to_string()),
..Default::default()
};
let context = wa::ContextInfo {
stanza_id: Some("test-id".to_string()),
..Default::default()
};
assert!(!msg.set_context_info(context));
}
#[test]
fn test_build_quote_context() {
let original = create_message_with_mentions();
let context = build_quote_context("3EB0123456789", "1234567890@s.whatsapp.net", &original);
assert_eq!(context.stanza_id.as_deref(), Some("3EB0123456789"));
assert_eq!(
context.participant.as_deref(),
Some("1234567890@s.whatsapp.net")
);
let quoted = context.quoted_message.as_ref().unwrap();
let ext = quoted.extended_text_message.as_ref().unwrap();
let quoted_ctx = ext.context_info.as_ref().unwrap();
assert!(
quoted_ctx.mentioned_jid.is_empty(),
"Quoted message mentions should be stripped"
);
}
#[test]
fn test_prepare_for_quote_ephemeral() {
let ephemeral_msg = wa::Message {
ephemeral_message: Some(Box::new(wa::message::FutureProofMessage {
message: Some(Box::new(create_message_with_mentions())),
})),
..Default::default()
};
let prepared = ephemeral_msg.prepare_for_quote();
let inner = prepared
.ephemeral_message
.as_ref()
.unwrap()
.message
.as_ref()
.unwrap();
let ext = inner.extended_text_message.as_ref().unwrap();
let ctx = ext.context_info.as_ref().unwrap();
assert!(
ctx.mentioned_jid.is_empty(),
"Mentions inside ephemeral wrapper should be stripped"
);
}
#[test]
fn test_prepare_for_quote_view_once() {
let view_once_msg = wa::Message {
view_once_message: Some(Box::new(wa::message::FutureProofMessage {
message: Some(Box::new(wa::Message {
image_message: Some(Box::new(wa::message::ImageMessage {
context_info: Some(Box::new(wa::ContextInfo {
mentioned_jid: vec!["someone@s.whatsapp.net".to_string()],
..Default::default()
})),
..Default::default()
})),
..Default::default()
})),
})),
..Default::default()
};
let prepared = view_once_msg.prepare_for_quote();
let inner = prepared
.view_once_message
.as_ref()
.unwrap()
.message
.as_ref()
.unwrap();
let img = inner.image_message.as_ref().unwrap();
let ctx = img.context_info.as_ref().unwrap();
assert!(
ctx.mentioned_jid.is_empty(),
"Mentions inside view_once wrapper should be stripped"
);
}
#[test]
fn test_prepare_for_quote_device_sent_message() {
let device_sent_msg = wa::Message {
device_sent_message: Some(Box::new(wa::message::DeviceSentMessage {
destination_jid: Some("1234567890@s.whatsapp.net".to_string()),
message: Some(Box::new(wa::Message {
extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage {
text: Some("Message from other device".to_string()),
context_info: Some(Box::new(wa::ContextInfo {
mentioned_jid: vec![
"user1@s.whatsapp.net".to_string(),
"user2@s.whatsapp.net".to_string(),
],
group_mentions: vec![wa::GroupMention {
group_jid: Some("group@g.us".to_string()),
group_subject: Some("Group Name".to_string()),
}],
..Default::default()
})),
..Default::default()
})),
..Default::default()
})),
phash: Some("somephash".to_string()),
})),
..Default::default()
};
let prepared = device_sent_msg.prepare_for_quote();
let wrapper = prepared.device_sent_message.as_ref().unwrap();
let inner = wrapper.message.as_ref().unwrap();
let ext = inner.extended_text_message.as_ref().unwrap();
let ctx = ext.context_info.as_ref().unwrap();
assert!(
ctx.mentioned_jid.is_empty(),
"mentioned_jid inside device_sent_message should be stripped"
);
assert!(
ctx.group_mentions.is_empty(),
"group_mentions inside device_sent_message should be stripped"
);
assert_eq!(ext.text.as_deref(), Some("Message from other device"));
assert_eq!(
wrapper.destination_jid.as_deref(),
Some("1234567890@s.whatsapp.net")
);
assert_eq!(wrapper.phash.as_deref(), Some("somephash"));
}
#[test]
fn test_prepare_for_quote_edited_message() {
let edited_msg = wa::Message {
edited_message: Some(Box::new(wa::message::FutureProofMessage {
message: Some(Box::new(wa::Message {
extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage {
text: Some("Edited message text".to_string()),
context_info: Some(Box::new(wa::ContextInfo {
mentioned_jid: vec!["mentioned@s.whatsapp.net".to_string()],
group_mentions: vec![wa::GroupMention {
group_jid: Some("editedgroup@g.us".to_string()),
group_subject: Some("Edited Group".to_string()),
}],
..Default::default()
})),
..Default::default()
})),
..Default::default()
})),
})),
..Default::default()
};
let prepared = edited_msg.prepare_for_quote();
let inner = prepared
.edited_message
.as_ref()
.unwrap()
.message
.as_ref()
.unwrap();
let ext = inner.extended_text_message.as_ref().unwrap();
let ctx = ext.context_info.as_ref().unwrap();
assert!(
ctx.mentioned_jid.is_empty(),
"mentioned_jid inside edited_message should be stripped"
);
assert!(
ctx.group_mentions.is_empty(),
"group_mentions inside edited_message should be stripped"
);
assert_eq!(ext.text.as_deref(), Some("Edited message text"));
}
#[test]
fn test_prepare_for_quote_nested_wrappers() {
let nested_wrapper_msg = wa::Message {
device_sent_message: Some(Box::new(wa::message::DeviceSentMessage {
destination_jid: Some("dest@s.whatsapp.net".to_string()),
message: Some(Box::new(wa::Message {
ephemeral_message: Some(Box::new(wa::message::FutureProofMessage {
message: Some(Box::new(wa::Message {
image_message: Some(Box::new(wa::message::ImageMessage {
caption: Some("Nested image".to_string()),
context_info: Some(Box::new(wa::ContextInfo {
mentioned_jid: vec!["deep@s.whatsapp.net".to_string()],
..Default::default()
})),
..Default::default()
})),
..Default::default()
})),
})),
..Default::default()
})),
..Default::default()
})),
..Default::default()
};
let prepared = nested_wrapper_msg.prepare_for_quote();
let device_sent = prepared.device_sent_message.as_ref().unwrap();
let device_inner = device_sent.message.as_ref().unwrap();
let ephemeral = device_inner.ephemeral_message.as_ref().unwrap();
let ephemeral_inner = ephemeral.message.as_ref().unwrap();
let img = ephemeral_inner.image_message.as_ref().unwrap();
let ctx = img.context_info.as_ref().unwrap();
assert!(
ctx.mentioned_jid.is_empty(),
"Mentions in deeply nested wrappers should be stripped"
);
assert_eq!(img.caption.as_deref(), Some("Nested image"));
}
#[test]
fn test_set_context_info_various_types() {
let test_cases: Vec<wa::Message> = vec![
wa::Message {
video_message: Some(Box::default()),
..Default::default()
},
wa::Message {
audio_message: Some(Box::default()),
..Default::default()
},
wa::Message {
document_message: Some(Box::default()),
..Default::default()
},
wa::Message {
sticker_message: Some(Box::default()),
..Default::default()
},
wa::Message {
contact_message: Some(Box::default()),
..Default::default()
},
wa::Message {
poll_creation_message: Some(Box::default()),
..Default::default()
},
];
for mut msg in test_cases {
let context = wa::ContextInfo {
stanza_id: Some("test".to_string()),
..Default::default()
};
assert!(
msg.set_context_info(context),
"set_context_info should succeed for this message type"
);
}
}
#[test]
fn test_prepare_for_quote_preserves_bot_quote_chain() {
let msg = wa::Message {
extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage {
text: Some("Bot reply".to_string()),
context_info: Some(Box::new(wa::ContextInfo {
participant: Some("131355512345@s.whatsapp.net".to_string()),
stanza_id: Some("bot-msg-id".to_string()),
remote_jid: Some("chat@g.us".to_string()),
quoted_message: Some(Box::new(wa::Message {
conversation: Some("Original user message".to_string()),
..Default::default()
})),
mentioned_jid: vec!["user@s.whatsapp.net".to_string()],
..Default::default()
})),
..Default::default()
})),
..Default::default()
};
let prepared = msg.prepare_for_quote();
let ctx = prepared
.extended_text_message
.as_ref()
.unwrap()
.context_info
.as_ref()
.unwrap();
assert!(
ctx.quoted_message.is_some(),
"Bot quote chain should be preserved"
);
assert!(ctx.stanza_id.is_some(), "Bot stanza_id should be preserved");
assert!(
ctx.participant.is_some(),
"Bot participant should be preserved"
);
assert!(
ctx.remote_jid.is_some(),
"Bot remote_jid should be preserved"
);
assert!(
ctx.mentioned_jid.is_empty(),
"Mentions should still be cleared even for bots"
);
}
#[test]
fn test_prepare_for_quote_preserves_bot_server_quote_chain() {
let msg = wa::Message {
extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage {
text: Some("Bot reply".to_string()),
context_info: Some(Box::new(wa::ContextInfo {
participant: Some("mybot@bot".to_string()),
stanza_id: Some("bot-msg-id".to_string()),
quoted_message: Some(Box::new(wa::Message {
conversation: Some("Original".to_string()),
..Default::default()
})),
..Default::default()
})),
..Default::default()
})),
..Default::default()
};
let prepared = msg.prepare_for_quote();
let ctx = prepared
.extended_text_message
.as_ref()
.unwrap()
.context_info
.as_ref()
.unwrap();
assert!(
ctx.quoted_message.is_some(),
"Bot (@bot server) quote chain should be preserved"
);
}
#[test]
fn test_build_quote_context_newsletter() {
let sender: Jid = "123456@s.whatsapp.net".parse().unwrap();
let chat: Jid = "1234567890@newsletter".parse().unwrap();
let msg = wa::Message::default();
let ctx = build_quote_context_with_info("msg-id", &sender, &chat, &msg);
assert_eq!(
ctx.participant.as_deref(),
Some("1234567890@newsletter"),
"Newsletter participant should be the newsletter JID"
);
assert_eq!(ctx.stanza_id.as_deref(), Some("msg-id"));
}
#[test]
fn test_build_quote_context_normal_message() {
let sender: Jid = "123456@s.whatsapp.net".parse().unwrap();
let chat: Jid = "group@g.us".parse().unwrap();
let msg = wa::Message::default();
let ctx = build_quote_context_with_info("msg-id", &sender, &chat, &msg);
assert_eq!(
ctx.participant.as_deref(),
Some("123456@s.whatsapp.net"),
"Normal message participant should be the sender JID"
);
}
#[test]
fn test_build_quote_context_status_broadcast() {
let sender: Jid = "123456@s.whatsapp.net".parse().unwrap();
let chat: Jid = "status@broadcast".parse().unwrap();
let msg = wa::Message::default();
let ctx = build_quote_context_with_info("msg-id", &sender, &chat, &msg);
assert_eq!(
ctx.participant.as_deref(),
Some("123456@s.whatsapp.net"),
"Status broadcast participant should fall back to sender"
);
}
#[test]
fn test_into_base_message_unwraps_device_sent_reaction() {
let msg = wa::Message {
device_sent_message: Some(Box::new(wa::message::DeviceSentMessage {
destination_jid: Some("5511999999999@s.whatsapp.net".to_string()),
message: Some(Box::new(wa::Message {
reaction_message: Some(wa::message::ReactionMessage {
text: Some("\u{2764}".to_string()),
..Default::default()
}),
..Default::default()
})),
phash: None,
})),
..Default::default()
};
let unwrapped = msg.into_base_message();
assert!(
unwrapped.device_sent_message.is_none(),
"device_sent_message wrapper should be removed"
);
assert!(
unwrapped.reaction_message.is_some(),
"reaction_message should be accessible after unwrapping"
);
assert_eq!(
unwrapped.reaction_message.as_ref().unwrap().text.as_deref(),
Some("\u{2764}")
);
}
#[test]
fn test_into_base_message_unwraps_nested_dsm_ephemeral() {
let msg = wa::Message {
device_sent_message: Some(Box::new(wa::message::DeviceSentMessage {
destination_jid: Some("5511999999999@s.whatsapp.net".to_string()),
message: Some(Box::new(wa::Message {
ephemeral_message: Some(Box::new(wa::message::FutureProofMessage {
message: Some(Box::new(wa::Message {
conversation: Some("secret".to_string()),
..Default::default()
})),
})),
..Default::default()
})),
phash: None,
})),
..Default::default()
};
let unwrapped = msg.into_base_message();
assert_eq!(
unwrapped.conversation.as_deref(),
Some("secret"),
"should unwrap through DSM then ephemeral to reach conversation"
);
}
#[test]
fn test_into_base_message_passthrough_plain() {
let msg = wa::Message {
conversation: Some("hello".to_string()),
..Default::default()
};
let unwrapped = msg.into_base_message();
assert_eq!(unwrapped.conversation.as_deref(), Some("hello"));
}
#[test]
fn test_into_base_message_empty_dsm() {
let msg = wa::Message {
device_sent_message: Some(Box::new(wa::message::DeviceSentMessage {
destination_jid: Some("5511999999999@s.whatsapp.net".to_string()),
message: None,
phash: None,
})),
..Default::default()
};
let unwrapped = msg.into_base_message();
assert!(
unwrapped.device_sent_message.is_some(),
"empty DSM wrapper should be preserved"
);
assert!(unwrapped.conversation.is_none());
}
#[test]
fn test_merge_dsm_context_both_none() {
assert!(merge_dsm_context(None, None).is_none());
}
#[test]
fn test_merge_dsm_context_inner_only() {
let inner = wa::MessageContextInfo {
message_secret: Some(vec![1, 2, 3]),
..Default::default()
};
let result = merge_dsm_context(Some(inner.clone()), None).unwrap();
assert_eq!(result.message_secret, Some(vec![1, 2, 3]));
}
#[test]
fn test_merge_dsm_context_outer_only() {
let outer = wa::MessageContextInfo {
message_secret: Some(vec![4, 5, 6]),
limit_sharing_v2: Some(wa::LimitSharing::default()),
..Default::default()
};
let result = merge_dsm_context(None, Some(&outer)).unwrap();
assert_eq!(
result.message_secret,
Some(vec![4, 5, 6]),
"message_secret should come from outer when inner is None"
);
assert!(
result.limit_sharing_v2.is_some(),
"limit_sharing_v2 should come from outer"
);
}
#[test]
fn test_merge_dsm_context_inner_preferred_for_secret() {
let inner = wa::MessageContextInfo {
message_secret: Some(vec![1, 2, 3]),
..Default::default()
};
let outer = wa::MessageContextInfo {
message_secret: Some(vec![4, 5, 6]),
..Default::default()
};
let result = merge_dsm_context(Some(inner), Some(&outer)).unwrap();
assert_eq!(
result.message_secret,
Some(vec![1, 2, 3]),
"inner message_secret should be preferred over outer"
);
}
#[test]
fn test_merge_dsm_context_secret_fallback_to_outer() {
let inner = wa::MessageContextInfo {
message_secret: None,
..Default::default()
};
let outer = wa::MessageContextInfo {
message_secret: Some(vec![4, 5, 6]),
..Default::default()
};
let result = merge_dsm_context(Some(inner), Some(&outer)).unwrap();
assert_eq!(
result.message_secret,
Some(vec![4, 5, 6]),
"should fall back to outer message_secret when inner is None"
);
}
#[test]
fn test_merge_dsm_context_limit_sharing_v2_always_outer() {
let inner_ls = wa::LimitSharing {
..Default::default()
};
let outer_ls = wa::LimitSharing {
..Default::default()
};
let inner = wa::MessageContextInfo {
limit_sharing_v2: Some(inner_ls),
..Default::default()
};
let outer = wa::MessageContextInfo {
limit_sharing_v2: Some(outer_ls),
..Default::default()
};
let result = merge_dsm_context(Some(inner), Some(&outer)).unwrap();
assert_eq!(
result.limit_sharing_v2,
Some(outer_ls),
"limit_sharing_v2 should always come from outer"
);
let inner_with_ls = wa::MessageContextInfo {
limit_sharing_v2: Some(wa::LimitSharing::default()),
..Default::default()
};
let result = merge_dsm_context(Some(inner_with_ls), None).unwrap();
assert_eq!(
result.limit_sharing_v2, None,
"limit_sharing_v2 should be cleared when outer is None"
);
}
#[test]
fn test_merge_dsm_context_thread_id_fallback() {
let outer = wa::MessageContextInfo {
thread_id: vec![wa::ThreadId::default()],
..Default::default()
};
let inner_empty = wa::MessageContextInfo::default();
let result = merge_dsm_context(Some(inner_empty), Some(&outer)).unwrap();
assert_eq!(
result.thread_id.len(),
1,
"should fall back to outer thread_id when inner is empty"
);
let inner_filled = wa::MessageContextInfo {
thread_id: vec![wa::ThreadId::default(), wa::ThreadId::default()],
..Default::default()
};
let result = merge_dsm_context(Some(inner_filled), Some(&outer)).unwrap();
assert_eq!(
result.thread_id.len(),
2,
"should keep inner thread_id when non-empty"
);
}
#[test]
fn quote_context_sets_remote_jid_for_group() {
let sender: Jid = "551199887766@s.whatsapp.net".parse().unwrap();
let group: Jid = "120363098765432100@g.us".parse().unwrap();
let msg = wa::Message {
conversation: Some("hello".into()),
..Default::default()
};
let ctx = build_quote_context_with_info("msg-id-123", &sender, &group, &msg);
assert_eq!(ctx.stanza_id.as_deref(), Some("msg-id-123"));
assert_eq!(
ctx.participant.as_deref(),
Some("551199887766@s.whatsapp.net")
);
assert_eq!(ctx.remote_jid.as_deref(), Some("120363098765432100@g.us"));
assert!(ctx.quoted_message.is_some());
assert!(ctx.mentioned_jid.is_empty());
}
#[test]
fn quote_context_sets_remote_jid_for_dm() {
let sender: Jid = "551199887766@s.whatsapp.net".parse().unwrap();
let chat: Jid = "551199887766@s.whatsapp.net".parse().unwrap();
let msg = wa::Message {
conversation: Some("ping".into()),
..Default::default()
};
let ctx = build_quote_context_with_info("msg-id-456", &sender, &chat, &msg);
assert_eq!(
ctx.remote_jid.as_deref(),
Some("551199887766@s.whatsapp.net")
);
assert_eq!(
ctx.participant.as_deref(),
Some("551199887766@s.whatsapp.net")
);
}
#[test]
fn quote_context_newsletter_uses_channel_as_participant() {
let sender: Jid = "551199887766@s.whatsapp.net".parse().unwrap();
let newsletter: Jid = "120363099999999999@newsletter".parse().unwrap();
let msg = wa::Message::default();
let ctx = build_quote_context_with_info("msg-id-789", &sender, &newsletter, &msg);
assert_eq!(
ctx.participant.as_deref(),
Some("120363099999999999@newsletter")
);
assert_eq!(
ctx.remote_jid.as_deref(),
Some("120363099999999999@newsletter")
);
}
#[test]
fn quote_context_strips_mentions_from_quoted_message() {
let sender: Jid = "551199887766@s.whatsapp.net".parse().unwrap();
let group: Jid = "120363098765432100@g.us".parse().unwrap();
let msg = create_message_with_mentions();
let ctx = build_quote_context_with_info("msg-id", &sender, &group, &msg);
let quoted = ctx.quoted_message.unwrap();
let inner_ctx = quoted.extended_text_message.unwrap().context_info.unwrap();
assert!(inner_ctx.mentioned_jid.is_empty());
assert!(inner_ctx.group_mentions.is_empty());
assert!(ctx.mentioned_jid.is_empty());
}
fn sample_parent_key() -> wa::MessageKey {
wa::MessageKey {
remote_jid: Some("5511999999999@s.whatsapp.net".to_string()),
from_me: Some(true),
id: Some("PARENT_MSG_ID".to_string()),
participant: None,
}
}
#[test]
fn test_wrap_as_album_child_basic() {
let inner = wa::Message {
image_message: Some(Box::new(wa::message::ImageMessage {
url: Some("https://mmg.whatsapp.net/test".to_string()),
..Default::default()
})),
..Default::default()
};
let wrapped = wrap_as_album_child(inner, sample_parent_key());
let future_proof = wrapped.associated_child_message.as_ref().unwrap();
let inner_msg = future_proof.message.as_ref().unwrap();
assert!(inner_msg.image_message.is_some());
assert!(inner_msg.message_context_info.is_none());
let ctx = wrapped.message_context_info.as_ref().unwrap();
let assoc = ctx.message_association.as_ref().unwrap();
assert_eq!(
assoc.association_type,
Some(wa::message_association::AssociationType::MediaAlbum as i32)
);
assert_eq!(assoc.parent_message_key, Some(sample_parent_key()));
assert_eq!(assoc.message_index, None);
}
#[test]
fn test_wrap_as_album_child_lifts_existing_context() {
let secret = vec![1u8; 32];
let inner = wa::Message {
video_message: Some(Box::new(wa::message::VideoMessage {
url: Some("https://mmg.whatsapp.net/vid".to_string()),
..Default::default()
})),
message_context_info: Some(wa::MessageContextInfo {
message_secret: Some(secret.clone()),
..Default::default()
}),
..Default::default()
};
let wrapped = wrap_as_album_child(inner, sample_parent_key());
let ctx = wrapped.message_context_info.as_ref().unwrap();
assert_eq!(ctx.message_secret.as_deref(), Some(secret.as_slice()));
assert!(ctx.message_association.is_some());
}
#[test]
fn is_view_once_detects_legacy_wrapper() {
let msg = wa::Message {
view_once_message: Some(Box::new(wa::message::FutureProofMessage {
message: Some(Box::new(wa::Message::default())),
})),
..Default::default()
};
assert!(msg.is_view_once());
let msg_v2 = wa::Message {
view_once_message_v2: Some(Box::new(wa::message::FutureProofMessage {
message: Some(Box::new(wa::Message::default())),
})),
..Default::default()
};
assert!(msg_v2.is_view_once());
}
#[test]
fn is_view_once_detects_wrapper_nested_in_device_sent() {
let msg = wa::Message {
device_sent_message: Some(Box::new(wa::message::DeviceSentMessage {
message: Some(Box::new(wa::Message {
view_once_message_v2: Some(Box::new(wa::message::FutureProofMessage {
message: Some(Box::new(wa::Message::default())),
})),
..Default::default()
})),
..Default::default()
})),
..Default::default()
};
assert!(msg.is_view_once());
}
#[test]
fn is_view_once_detects_inline_image_flag() {
let msg = wa::Message {
image_message: Some(Box::new(wa::message::ImageMessage {
view_once: Some(true),
..Default::default()
})),
..Default::default()
};
assert!(msg.is_view_once());
}
#[test]
fn is_view_once_detects_inline_video_flag() {
let msg = wa::Message {
video_message: Some(Box::new(wa::message::VideoMessage {
view_once: Some(true),
..Default::default()
})),
..Default::default()
};
assert!(msg.is_view_once());
}
#[test]
fn is_view_once_detects_inline_audio_flag() {
let msg = wa::Message {
audio_message: Some(Box::new(wa::message::AudioMessage {
view_once: Some(true),
..Default::default()
})),
..Default::default()
};
assert!(msg.is_view_once());
}
#[test]
fn is_view_once_detects_inline_extended_text_flag() {
let msg = wa::Message {
extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage {
view_once: Some(true),
..Default::default()
})),
..Default::default()
};
assert!(msg.is_view_once());
}
#[test]
fn is_view_once_detects_inline_flag_through_device_sent() {
let msg = wa::Message {
device_sent_message: Some(Box::new(wa::message::DeviceSentMessage {
message: Some(Box::new(wa::Message {
image_message: Some(Box::new(wa::message::ImageMessage {
view_once: Some(true),
..Default::default()
})),
..Default::default()
})),
..Default::default()
})),
..Default::default()
};
assert!(msg.is_view_once());
}
#[test]
fn is_view_once_false_for_plain_image() {
let msg = wa::Message {
image_message: Some(Box::new(wa::message::ImageMessage::default())),
..Default::default()
};
assert!(!msg.is_view_once());
let msg_explicit_false = wa::Message {
image_message: Some(Box::new(wa::message::ImageMessage {
view_once: Some(false),
..Default::default()
})),
..Default::default()
};
assert!(!msg_explicit_false.is_view_once());
}
#[test]
fn is_view_once_false_for_empty_message() {
assert!(!wa::Message::default().is_view_once());
}
#[test]
fn is_view_once_detects_v2_extension_wrapper() {
let msg = wa::Message {
view_once_message_v2_extension: Some(Box::new(wa::message::FutureProofMessage {
message: Some(Box::new(wa::Message::default())),
})),
..Default::default()
};
assert!(msg.is_view_once());
}
#[test]
fn is_view_once_detects_ephemeral_device_sent_view_once() {
let msg = wa::Message {
ephemeral_message: Some(Box::new(wa::message::FutureProofMessage {
message: Some(Box::new(wa::Message {
device_sent_message: Some(Box::new(wa::message::DeviceSentMessage {
message: Some(Box::new(wa::Message {
view_once_message_v2: Some(Box::new(wa::message::FutureProofMessage {
message: Some(Box::new(wa::Message::default())),
})),
..Default::default()
})),
..Default::default()
})),
..Default::default()
})),
})),
..Default::default()
};
assert!(msg.is_view_once());
}
}