1use chrono::{DateTime, Utc};
20use serde::{Deserialize, Serialize};
21use uuid::Uuid;
22
23#[non_exhaustive]
30#[cfg_attr(feature = "ts-export", derive(ts_rs::TS), ts(export))]
31#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum InboundKind {
34 ExternalUser,
38 InternalSystem,
42 InterSession,
45}
46
47#[non_exhaustive]
78#[cfg_attr(feature = "ts-export", derive(ts_rs::TS), ts(export))]
79#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
80pub struct InboundMessageMeta {
81 pub kind: InboundKind,
83
84 #[serde(skip_serializing_if = "Option::is_none", default)]
89 pub sender_id: Option<String>,
90
91 #[serde(skip_serializing_if = "Option::is_none", default)]
98 pub msg_id: Option<String>,
99
100 #[serde(skip_serializing_if = "Option::is_none", default)]
104 pub inbound_ts: Option<DateTime<Utc>>,
105
106 #[serde(skip_serializing_if = "Option::is_none", default)]
109 pub reply_to_msg_id: Option<String>,
110
111 #[serde(default, skip_serializing_if = "is_false")]
116 pub has_media: bool,
117
118 #[serde(skip_serializing_if = "Option::is_none", default)]
121 pub origin_session_id: Option<Uuid>,
122}
123
124fn is_false(b: &bool) -> bool {
125 !b
126}
127
128impl InboundMessageMeta {
129 pub fn external_user(sender_id: impl Into<String>, msg_id: impl Into<String>) -> Self {
132 Self {
133 kind: InboundKind::ExternalUser,
134 sender_id: Some(sender_id.into()),
135 msg_id: Some(msg_id.into()),
136 inbound_ts: None,
137 reply_to_msg_id: None,
138 has_media: false,
139 origin_session_id: None,
140 }
141 }
142
143 pub fn internal_system() -> Self {
146 Self {
147 kind: InboundKind::InternalSystem,
148 sender_id: None,
149 msg_id: None,
150 inbound_ts: None,
151 reply_to_msg_id: None,
152 has_media: false,
153 origin_session_id: None,
154 }
155 }
156
157 pub fn inter_session(origin_session_id: Uuid) -> Self {
161 Self {
162 kind: InboundKind::InterSession,
163 sender_id: None,
164 msg_id: None,
165 inbound_ts: None,
166 reply_to_msg_id: None,
167 has_media: false,
168 origin_session_id: Some(origin_session_id),
169 }
170 }
171
172 pub fn with_ts(mut self, ts: DateTime<Utc>) -> Self {
174 self.inbound_ts = Some(ts);
175 self
176 }
177
178 pub fn with_reply_to(mut self, reply_to_msg_id: impl Into<String>) -> Self {
180 self.reply_to_msg_id = Some(reply_to_msg_id.into());
181 self
182 }
183
184 pub fn with_media(mut self) -> Self {
186 self.has_media = true;
187 self
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194 use chrono::TimeZone;
195
196 #[test]
197 fn external_user_builder_sets_kind_and_sender_msg() {
198 let m = InboundMessageMeta::external_user("+5491100", "wa.ABCD");
199 assert_eq!(m.kind, InboundKind::ExternalUser);
200 assert_eq!(m.sender_id.as_deref(), Some("+5491100"));
201 assert_eq!(m.msg_id.as_deref(), Some("wa.ABCD"));
202 assert!(m.inbound_ts.is_none());
203 assert!(m.reply_to_msg_id.is_none());
204 assert!(!m.has_media);
205 assert!(m.origin_session_id.is_none());
206 }
207
208 #[test]
209 fn internal_system_builder_clears_sender_msg_origin() {
210 let m = InboundMessageMeta::internal_system();
211 assert_eq!(m.kind, InboundKind::InternalSystem);
212 assert!(m.sender_id.is_none());
213 assert!(m.msg_id.is_none());
214 assert!(m.origin_session_id.is_none());
215 }
216
217 #[test]
218 fn inter_session_builder_carries_origin_session_id() {
219 let id = Uuid::from_u128(0x42);
220 let m = InboundMessageMeta::inter_session(id);
221 assert_eq!(m.kind, InboundKind::InterSession);
222 assert!(m.sender_id.is_none());
223 assert_eq!(m.origin_session_id, Some(id));
224 }
225
226 #[test]
227 fn with_ts_with_reply_to_with_media_layer_correctly() {
228 let ts = Utc.with_ymd_and_hms(2026, 5, 1, 12, 34, 56).unwrap();
229 let m = InboundMessageMeta::external_user("+5491100", "wa.ABCD")
230 .with_ts(ts)
231 .with_reply_to("wa.PREV0001")
232 .with_media();
233 assert_eq!(m.inbound_ts, Some(ts));
234 assert_eq!(m.reply_to_msg_id.as_deref(), Some("wa.PREV0001"));
235 assert!(m.has_media);
236 }
237
238 #[test]
239 fn serialise_skips_none_and_false_fields() {
240 let m = InboundMessageMeta::internal_system();
241 let v = serde_json::to_value(&m).unwrap();
242 let obj = v.as_object().unwrap();
243 assert!(obj.contains_key("kind"));
244 assert!(!obj.contains_key("sender_id"));
245 assert!(!obj.contains_key("msg_id"));
246 assert!(!obj.contains_key("inbound_ts"));
247 assert!(!obj.contains_key("reply_to_msg_id"));
248 assert!(!obj.contains_key("has_media"));
250 assert!(!obj.contains_key("origin_session_id"));
251 }
252
253 #[test]
254 fn round_trip_through_serde_full_payload() {
255 let ts = Utc.with_ymd_and_hms(2026, 5, 1, 12, 34, 56).unwrap();
256 let original = InboundMessageMeta::external_user("user@host", "msg-1")
257 .with_ts(ts)
258 .with_reply_to("msg-0")
259 .with_media();
260 let s = serde_json::to_string(&original).unwrap();
261 let back: InboundMessageMeta = serde_json::from_str(&s).unwrap();
262 assert_eq!(original, back);
263 }
264
265 #[test]
266 fn parse_rejects_unknown_kind_string() {
267 let raw = serde_json::json!({ "kind": "future_kind" });
272 let r: Result<InboundMessageMeta, _> = serde_json::from_value(raw);
273 assert!(r.is_err(), "unknown kind must reject, not silently accept");
274 }
275
276 #[test]
277 fn clone_eq_holds_for_full_payload() {
278 let a = InboundMessageMeta::external_user("+5491100", "wa.ABCD")
279 .with_ts(Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap())
280 .with_reply_to("wa.PREV")
281 .with_media();
282 let b = a.clone();
283 assert_eq!(a, b);
284 }
285
286 #[test]
287 fn provider_agnostic_sender_id_accepts_arbitrary_string() {
288 let wa = InboundMessageMeta::external_user("+5491100", "wa.1");
291 let tg = InboundMessageMeta::external_user("tg.user_42", "tg.msg.7");
292 let em = InboundMessageMeta::external_user("alice@example.com", "<id@host>");
293 assert_eq!(wa.sender_id.as_deref(), Some("+5491100"));
294 assert_eq!(tg.sender_id.as_deref(), Some("tg.user_42"));
295 assert_eq!(em.sender_id.as_deref(), Some("alice@example.com"));
296 }
297}