use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InboundKind {
ExternalUser,
InternalSystem,
InterSession,
}
#[non_exhaustive]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct InboundMessageMeta {
pub kind: InboundKind,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub sender_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub msg_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub inbound_ts: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub reply_to_msg_id: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub has_media: bool,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub origin_session_id: Option<Uuid>,
}
fn is_false(b: &bool) -> bool {
!b
}
impl InboundMessageMeta {
pub fn external_user(sender_id: impl Into<String>, msg_id: impl Into<String>) -> Self {
Self {
kind: InboundKind::ExternalUser,
sender_id: Some(sender_id.into()),
msg_id: Some(msg_id.into()),
inbound_ts: None,
reply_to_msg_id: None,
has_media: false,
origin_session_id: None,
}
}
pub fn internal_system() -> Self {
Self {
kind: InboundKind::InternalSystem,
sender_id: None,
msg_id: None,
inbound_ts: None,
reply_to_msg_id: None,
has_media: false,
origin_session_id: None,
}
}
pub fn inter_session(origin_session_id: Uuid) -> Self {
Self {
kind: InboundKind::InterSession,
sender_id: None,
msg_id: None,
inbound_ts: None,
reply_to_msg_id: None,
has_media: false,
origin_session_id: Some(origin_session_id),
}
}
pub fn with_ts(mut self, ts: DateTime<Utc>) -> Self {
self.inbound_ts = Some(ts);
self
}
pub fn with_reply_to(mut self, reply_to_msg_id: impl Into<String>) -> Self {
self.reply_to_msg_id = Some(reply_to_msg_id.into());
self
}
pub fn with_media(mut self) -> Self {
self.has_media = true;
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn external_user_builder_sets_kind_and_sender_msg() {
let m = InboundMessageMeta::external_user("+5491100", "wa.ABCD");
assert_eq!(m.kind, InboundKind::ExternalUser);
assert_eq!(m.sender_id.as_deref(), Some("+5491100"));
assert_eq!(m.msg_id.as_deref(), Some("wa.ABCD"));
assert!(m.inbound_ts.is_none());
assert!(m.reply_to_msg_id.is_none());
assert!(!m.has_media);
assert!(m.origin_session_id.is_none());
}
#[test]
fn internal_system_builder_clears_sender_msg_origin() {
let m = InboundMessageMeta::internal_system();
assert_eq!(m.kind, InboundKind::InternalSystem);
assert!(m.sender_id.is_none());
assert!(m.msg_id.is_none());
assert!(m.origin_session_id.is_none());
}
#[test]
fn inter_session_builder_carries_origin_session_id() {
let id = Uuid::from_u128(0x42);
let m = InboundMessageMeta::inter_session(id);
assert_eq!(m.kind, InboundKind::InterSession);
assert!(m.sender_id.is_none());
assert_eq!(m.origin_session_id, Some(id));
}
#[test]
fn with_ts_with_reply_to_with_media_layer_correctly() {
let ts = Utc.with_ymd_and_hms(2026, 5, 1, 12, 34, 56).unwrap();
let m = InboundMessageMeta::external_user("+5491100", "wa.ABCD")
.with_ts(ts)
.with_reply_to("wa.PREV0001")
.with_media();
assert_eq!(m.inbound_ts, Some(ts));
assert_eq!(m.reply_to_msg_id.as_deref(), Some("wa.PREV0001"));
assert!(m.has_media);
}
#[test]
fn serialise_skips_none_and_false_fields() {
let m = InboundMessageMeta::internal_system();
let v = serde_json::to_value(&m).unwrap();
let obj = v.as_object().unwrap();
assert!(obj.contains_key("kind"));
assert!(!obj.contains_key("sender_id"));
assert!(!obj.contains_key("msg_id"));
assert!(!obj.contains_key("inbound_ts"));
assert!(!obj.contains_key("reply_to_msg_id"));
assert!(!obj.contains_key("has_media"));
assert!(!obj.contains_key("origin_session_id"));
}
#[test]
fn round_trip_through_serde_full_payload() {
let ts = Utc.with_ymd_and_hms(2026, 5, 1, 12, 34, 56).unwrap();
let original = InboundMessageMeta::external_user("user@host", "msg-1")
.with_ts(ts)
.with_reply_to("msg-0")
.with_media();
let s = serde_json::to_string(&original).unwrap();
let back: InboundMessageMeta = serde_json::from_str(&s).unwrap();
assert_eq!(original, back);
}
#[test]
fn parse_rejects_unknown_kind_string() {
let raw = serde_json::json!({ "kind": "future_kind" });
let r: Result<InboundMessageMeta, _> = serde_json::from_value(raw);
assert!(r.is_err(), "unknown kind must reject, not silently accept");
}
#[test]
fn clone_eq_holds_for_full_payload() {
let a = InboundMessageMeta::external_user("+5491100", "wa.ABCD")
.with_ts(Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap())
.with_reply_to("wa.PREV")
.with_media();
let b = a.clone();
assert_eq!(a, b);
}
#[test]
fn provider_agnostic_sender_id_accepts_arbitrary_string() {
let wa = InboundMessageMeta::external_user("+5491100", "wa.1");
let tg = InboundMessageMeta::external_user("tg.user_42", "tg.msg.7");
let em = InboundMessageMeta::external_user("alice@example.com", "<id@host>");
assert_eq!(wa.sender_id.as_deref(), Some("+5491100"));
assert_eq!(tg.sender_id.as_deref(), Some("tg.user_42"));
assert_eq!(em.sender_id.as_deref(), Some("alice@example.com"));
}
}