Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
700 changes: 700 additions & 0 deletions crates/chat-store/src/business.rs

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions crates/chat-store/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,9 @@ pub use types::{
MessageCoverage, MessageCursor, MessageKind, MessageStatus, ReactionEntry, ReceiptEntry,
StoreChange, StoredMessage,
};

mod business;
pub use business::{
BusinessMedia, business_content, business_contexts, business_media, hydrated_template,
interactive_business,
};
91 changes: 35 additions & 56 deletions crates/chat-store/src/materialize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ pub fn message_kind(message: &wa::Message) -> &'static str {
"event"
} else if base.group_invite_message.is_set() {
"group_invite"
} else if base.template_message.is_set() {
} else if base.template_message.is_set() || base.highly_structured_message.is_set() {
"template"
} else if base.template_button_reply_message.is_set() {
"template_reply"
Expand Down Expand Up @@ -130,20 +130,35 @@ pub(crate) fn unavailable_kind(unavailable_type: UnavailableType) -> Option<&'st
/// Repeating the pass handles valid nested combinations (for example a group
/// mention around an associated child around a future-proof poll).
pub fn normalized_message(message: &wa::Message) -> &wa::Message {
let mut base = message.get_base_message();
loop {
let inner = [
&base.group_mentioned_message,
&base.associated_child_message,
&base.poll_creation_message_v4,
]
.into_iter()
.find_map(|wrapper| wrapper.as_option().and_then(|w| w.message.as_option()));
let mut base = message;
// A finite budget also handles malformed, deeply nested carriers without
// unbounded projection work. Each pass borrows the next message.
for _ in 0..32 {
let inner = base
.device_sent_message
.as_option()
.and_then(|w| w.message.as_option())
.or_else(|| {
[
&base.ephemeral_message,
&base.view_once_message,
&base.view_once_message_v2,
&base.view_once_message_v2_extension,
&base.document_with_caption_message,
&base.edited_message,
&base.group_mentioned_message,
&base.associated_child_message,
&base.poll_creation_message_v4,
]
.into_iter()
.find_map(|w| w.as_option().and_then(|w| w.message.as_option()))
});
match inner {
Some(inner) if !std::ptr::eq(inner, base) => base = inner.get_base_message(),
_ => return base,
Some(inner) => base = inner,
None => return base,
}
}
base
}

/// Shared poll-creation detector for classification and storage compaction.
Expand Down Expand Up @@ -282,56 +297,20 @@ pub(crate) fn extract_text(base: &wa::Message) -> Option<String> {
},
);
}
if let Some(business) = crate::business_content(base) {
let body = if business.body.is_empty() {
business.header
} else {
business.body
};
return (!body.is_empty()).then_some(body);
}
base.text_content()
.or_else(|| base.get_caption())
.or_else(|| business_text(base))
.map(str::to_owned)
.or_else(|| crate::shared_contacts_text(base))
}

/// Body text of the business content carriers. Extraction mirrors WA Web's
/// per-type parsers; footers/buttons stay display-side (readable from the proto).
fn business_text(base: &wa::Message) -> Option<&str> {
if let Some(tpl) = base.template_message.as_option() {
use wa::message::template_message::Format;
// WA Web reads hydratedTemplate ?? format's hydratedFourRowTemplate.
return tpl
.hydrated_template
.as_option()
.or_else(|| match tpl.format.as_ref() {
Some(Format::HydratedFourRowTemplate(t)) => Some(t.as_ref()),
_ => None,
})
.and_then(|t| t.hydrated_content_text.as_deref());
}
if let Some(reply) = base.template_button_reply_message.as_option() {
return reply.selected_display_text.as_deref();
}
if let Some(buttons) = base.buttons_message.as_option() {
return buttons.content_text.as_deref();
}
if let Some(resp) = base.buttons_response_message.as_option() {
use wa::message::buttons_response_message::Response;
return match resp.response.as_ref() {
Some(Response::SelectedDisplayText(text)) => Some(text.as_str()),
_ => None,
};
}
if let Some(list) = base.list_message.as_option() {
return list.description.as_deref();
}
if let Some(resp) = base.list_response_message.as_option() {
return resp.title.as_deref();
}
if let Some(interactive) = base.interactive_message.as_option() {
return interactive.body.as_option().and_then(|b| b.text.as_deref());
}
if let Some(resp) = base.interactive_response_message.as_option() {
return resp.body.as_option().and_then(|b| b.text.as_deref());
}
None
}

/// Whether the unwrapped message carries anything a chat log should show.
/// Guards against storing rows for pure-bookkeeping payloads that classify as
/// "unknown". Decided structurally: strip the bookkeeping carriers and check
Expand Down
55 changes: 55 additions & 0 deletions crates/chat-store/tests/business.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//! Synthetic wire-to-durable-row coverage for business cards.
mod common;
use common::*;

#[tokio::test]
async fn interactive_template_materializes_body_and_preserves_the_card() {
let (store, chat_store) = test_store().await;
let mut body = wa::message::interactive_message::Body::default();
body.text = Some("*Order* is ready".into());
let mut interactive = wa::message::InteractiveMessage::default();
interactive.body = MessageField::some(body);
let mut template = wa::message::TemplateMessage::default();
template.format =
Some(wa::message::template_message::Format::InteractiveMessageTemplate(interactive.into()));
let mut proto = wa::Message::default();
proto.template_message = MessageField::some(template);
let expected = oxidezap_chat_store::business_content(&proto);
feed(
&chat_store,
[message_event(
proto,
incoming_info(PEER, PEER, "TEMPLATE-1", 1_700_000_000),
)],
)
.await;
let rows = chat_store.messages(&jid(PEER), None, 10).await.unwrap();
assert_eq!(rows[0].kind, MessageKind::Template);
assert_eq!(rows[0].text.as_deref(), Some("*Order* is ready"));
assert_eq!(
rows[0]
.message
.as_deref()
.and_then(oxidezap_chat_store::business_content),
expected
);
assert_eq!(
chat_store.chats(false, 10).await.unwrap()[0]
.last_message_preview
.as_deref(),
Some("*Order* is ready")
);
let reopened = ChatStore::new(&store).await.unwrap();
let stored = reopened
.message(&jid(PEER), "TEMPLATE-1")
.await
.unwrap()
.unwrap();
assert_eq!(
stored
.message
.as_deref()
.and_then(oxidezap_chat_store::business_content),
expected
);
}
133 changes: 133 additions & 0 deletions crates/core/src/business.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
//! Display-only business content. No response IDs, flow JSON, or executable actions.
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BusinessContent {
pub kind: BusinessKind,
pub header: String,
pub body: String,
pub footer: String,
pub buttons: Vec<BusinessButton>,
/// A readable limitation, never raw protobuf identifiers or payloads.
pub notice: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BusinessKind {
Template,
Buttons,
List,
Interactive,
Response,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BusinessButton {
pub label: String,
pub action: BusinessAction,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BusinessAction {
Reply,
OpenLink,
Call,
NativeFlow,
Unsupported,
}

impl BusinessAction {
pub fn unavailable_reason(self) -> &'static str {
match self {
Self::Reply => "Button replies are not supported by this client.",
Self::OpenLink => "Opening business button links is not supported by this client.",
Self::Call => "Calling business button numbers is not supported by this client.",
Self::NativeFlow => "This interactive action must be completed on your phone.",
Self::Unsupported => "This button action is not supported by this client.",
}
}
}

impl BusinessContent {
/// Human-readable output for CLI clients which do not draw a business card.
pub fn summary(&self) -> String {
let mut lines: Vec<&str> = [&self.header, &self.body, &self.footer]
.into_iter()
.map(String::as_str)
.filter(|s| !s.is_empty())
.collect();
lines.extend(self.buttons.iter().map(|button| button.label.as_str()));
lines.extend(self.notice.as_deref());
lines.join("\n")
}
}

#[cfg(test)]
mod tests {
use super::*;
#[test]
fn business_cards_cross_daemon_serialization_and_old_messages_still_decode() {
let mut message = crate::ChatMessage::new_incoming(
"synthetic".into(),
"example@lid".into(),
"Ready".into(),
);
message.business = Some(BusinessContent {
kind: BusinessKind::Template,
header: "Order".into(),
body: "Ready".into(),
footer: "Example shop".into(),
buttons: vec![BusinessButton {
label: "Thanks".into(),
action: BusinessAction::Reply,
}],
notice: None,
});
let encoded = serde_json::to_value(&message).unwrap();
assert_eq!(
serde_json::from_value::<crate::ChatMessage>(encoded.clone()).unwrap(),
message
);
let mut old = encoded;
old.as_object_mut().unwrap().remove("business");
assert!(
serde_json::from_value::<crate::ChatMessage>(old)
.unwrap()
.business
.is_none()
);
message.status = crate::MessageStatus::Failed;
message.is_from_me = true;
assert!(
message.resend().is_none(),
"a business body must not become a button response or resend"
);
}
#[test]
fn accepted_revoke_discards_business_text_and_choices() {
let mut message =
crate::ChatMessage::new_outgoing("synthetic".into(), "original body".into());
message.business = Some(BusinessContent {
kind: BusinessKind::Template,
header: "original header".into(),
body: "original body".into(),
footer: "original footer".into(),
buttons: vec![BusinessButton {
label: "original choice".into(),
action: BusinessAction::Reply,
}],
notice: None,
});
message.mark_revoked();
assert!(message.revoked);
assert!(message.business.is_none());
assert_eq!(message.preview_text(), "[Message deleted]");
assert!(
!serde_json::to_string(&message)
.unwrap()
.contains("original")
);
}
}
23 changes: 22 additions & 1 deletion crates/core/src/chat/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ pub struct ChatMessage {
/// on. `None` on every other message.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub poll: Option<PollContent>,
/// Display-only template, interactive card or business response.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub business: Option<crate::BusinessContent>,
Comment thread
jlucaso1 marked this conversation as resolved.
}

/// What a failed message can be sent again as.
Expand Down Expand Up @@ -128,6 +131,7 @@ impl ChatMessage {
edited: false,
system: None,
poll: None,
business: None,
}
}

Expand Down Expand Up @@ -160,6 +164,7 @@ impl ChatMessage {
edited: false,
system: None,
poll: None,
business: None,
}
}

Expand Down Expand Up @@ -194,6 +199,16 @@ impl ChatMessage {
self.is_from_me && self.status.is_failed()
}

/// Apply an accepted deletion without retaining displayable original content.
pub fn mark_revoked(&mut self) {
self.revoked = true;
self.content = "[Message deleted]".into();
self.media = None;
self.quoted = None;
self.poll = None;
self.business = None;
}

/// What sending this again would put on the wire, if anything.
///
/// One question with one answer, asked by the bubble to decide whether to
Expand All @@ -211,7 +226,7 @@ impl ChatMessage {
// message would send the caption without the file. Failed media has
// no resendable form here — the echo of a video holds no bytes — so
// it offers no retry rather than a wrong one.
if self.media.is_none() && !self.content.is_empty() {
if self.business.is_none() && self.media.is_none() && !self.content.is_empty() {
return Some(Resend::Text(&self.content));
}
// A voice note has no text and is not therefore beyond recovery: the
Expand All @@ -229,6 +244,12 @@ impl ChatMessage {
/// - For text-only messages: the message content
/// - For media messages: "[MediaType] caption" or just "[MediaType]"
pub fn preview_text(&self) -> String {
if let Some(business) = &self.business {
if !self.content.is_empty() {
return self.content.clone();
}
return business.summary();
}
if let Some(poll) = &self.poll {
return format!("\u{1f4ca} {}", poll.question);
}
Expand Down
3 changes: 3 additions & 0 deletions crates/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,6 @@ pub use rich_text::{
pub use status::{StatusAuthor, StatusFeed};
pub use system_notice::{CallOutcome, CallRecord, SystemNotice, format_duration};
pub use video::{CallVideo, CallVideoFrame, VideoStream};

mod business;
pub use business::{BusinessAction, BusinessButton, BusinessContent, BusinessKind};
Loading
Loading