1use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5
6pub type Metadata = serde_json::Map<String, serde_json::Value>;
9
10macro_rules! string_newtype {
16 ($(#[$m:meta])* $name:ident, ord = $ord:tt) => {
17 $(#[$m])*
18 #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
19 #[serde(transparent)]
20 pub struct $name(String);
21 impl $name {
22 pub fn as_str(&self) -> &str { &self.0 }
24 pub fn into_string(self) -> String { self.0 }
26 }
27 impl std::fmt::Display for $name {
28 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29 f.write_str(&self.0)
30 }
31 }
32 impl From<String> for $name {
33 fn from(s: String) -> Self { Self(s) }
34 }
35 impl From<&str> for $name {
36 fn from(s: &str) -> Self { Self(s.to_owned()) }
37 }
38 impl std::borrow::Borrow<str> for $name {
39 fn borrow(&self) -> &str { &self.0 }
40 }
41 };
42}
43
44string_newtype!(
45 Serial,
50 ord = false
51);
52
53string_newtype!(
54 RoomName,
56 ord = false
57);
58
59#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
61#[serde(transparent)]
62pub struct Timestamp(i64);
63
64impl Timestamp {
65 pub fn as_millis(&self) -> i64 {
67 self.0
68 }
69
70 #[cfg(feature = "chrono")]
72 pub fn to_chrono(&self) -> Option<chrono::DateTime<chrono::Utc>> {
73 chrono::DateTime::from_timestamp_millis(self.0)
74 }
75}
76
77impl From<i64> for Timestamp {
78 fn from(v: i64) -> Self {
79 Self(v)
80 }
81}
82
83#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(from = "String", into = "String")]
91pub enum MessageAction {
92 Create,
94 Update,
96 Delete,
98 Other(String),
100}
101
102impl From<String> for MessageAction {
103 fn from(s: String) -> Self {
104 match s.as_str() {
105 "message.create" => Self::Create,
106 "message.update" => Self::Update,
107 "message.delete" => Self::Delete,
108 _ => Self::Other(s),
109 }
110 }
111}
112
113impl From<MessageAction> for String {
114 fn from(a: MessageAction) -> String {
115 match a {
116 MessageAction::Create => "message.create".into(),
117 MessageAction::Update => "message.update".into(),
118 MessageAction::Delete => "message.delete".into(),
119 MessageAction::Other(s) => s,
120 }
121 }
122}
123
124#[derive(Clone, Copy, Debug, PartialEq, Eq)]
127pub enum Direction {
128 Forwards,
130 Backwards,
132}
133
134#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(from = "String", into = "String")]
142pub enum ReactionType {
143 Unique,
145 Distinct,
147 Multiple,
149 Other(String),
151}
152
153impl From<String> for ReactionType {
154 fn from(s: String) -> Self {
155 match s.as_str() {
156 "unique" => Self::Unique,
157 "distinct" => Self::Distinct,
158 "multiple" => Self::Multiple,
159 _ => Self::Other(s),
160 }
161 }
162}
163
164impl From<ReactionType> for String {
165 fn from(t: ReactionType) -> String {
166 match t {
167 ReactionType::Unique => "unique".into(),
168 ReactionType::Distinct => "distinct".into(),
169 ReactionType::Multiple => "multiple".into(),
170 ReactionType::Other(s) => s,
171 }
172 }
173}
174
175#[derive(Clone, Debug, Deserialize)]
177#[serde(rename_all = "camelCase")]
178pub struct Message {
179 pub serial: Serial,
181 pub version: MessageVersion,
183 pub text: String,
185 pub client_id: String,
187 pub action: MessageAction,
189 #[serde(default)]
191 pub metadata: Metadata,
192 #[serde(default)]
194 pub headers: BTreeMap<String, String>,
195 #[serde(default)]
198 pub user_claim: Option<String>,
199 pub timestamp: Timestamp,
201 #[serde(default)]
203 pub reactions: ReactionSummary,
204}
205
206#[derive(Clone, Debug, Deserialize)]
208#[serde(rename_all = "camelCase")]
209pub struct MessageVersion {
210 pub serial: Serial,
212 pub timestamp: Timestamp,
214 #[serde(default)]
216 pub client_id: Option<String>,
217 #[serde(default)]
219 pub description: Option<String>,
220 #[serde(default)]
222 pub metadata: Option<BTreeMap<String, String>>,
223}
224
225#[derive(Clone, Copy, Debug, Default, Deserialize)]
227#[serde(rename_all = "camelCase")]
228pub struct Occupancy {
229 pub connections: u64,
231 pub presence_members: u64,
233}
234
235#[derive(Clone, Debug, Default, Deserialize)]
238pub struct ReactionSummary {
239 #[serde(default)]
241 pub unique: BTreeMap<String, ClientIdList>,
242 #[serde(default)]
244 pub distinct: BTreeMap<String, ClientIdList>,
245 #[serde(default)]
247 pub multiple: BTreeMap<String, ClientIdCounts>,
248}
249
250#[derive(Clone, Debug, Deserialize)]
252#[serde(rename_all = "camelCase")]
253pub struct ClientIdList {
254 pub total: u64,
256 #[serde(default)]
258 pub client_ids: Vec<String>,
259 #[serde(default)]
261 pub clipped: bool,
262}
263
264#[derive(Clone, Debug, Deserialize)]
266#[serde(rename_all = "camelCase")]
267pub struct ClientIdCounts {
268 pub total: u64,
270 #[serde(default)]
272 pub client_ids: BTreeMap<String, u64>,
273 #[serde(default)]
275 pub total_unidentified: u64,
276 #[serde(default)]
278 pub clipped: bool,
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 #[test]
286 fn serial_roundtrips_through_json() {
287 let s: Serial = serde_json::from_str("\"01abc@def:001\"").unwrap();
288 assert_eq!(s.as_str(), "01abc@def:001");
289 assert_eq!(serde_json::to_string(&s).unwrap(), "\"01abc@def:001\"");
290 }
291
292 #[test]
293 fn timestamp_is_epoch_millis() {
294 let t: Timestamp = serde_json::from_str("1700000000000").unwrap();
295 assert_eq!(t.as_millis(), 1_700_000_000_000);
296 }
297
298 #[test]
299 fn unknown_action_is_captured_not_rejected() {
300 let a: MessageAction = serde_json::from_str("\"message.future\"").unwrap();
301 assert_eq!(a, MessageAction::Other("message.future".into()));
302 let c: MessageAction = serde_json::from_str("\"message.create\"").unwrap();
303 assert_eq!(c, MessageAction::Create);
304 assert_eq!(
306 serde_json::to_string(&MessageAction::Delete).unwrap(),
307 "\"message.delete\""
308 );
309 }
310
311 #[test]
312 fn unknown_reaction_type_is_captured_not_rejected() {
313 let r: ReactionType = serde_json::from_str("\"future\"").unwrap();
314 assert_eq!(r, ReactionType::Other("future".into()));
315 let d: ReactionType = serde_json::from_str("\"distinct\"").unwrap();
316 assert_eq!(d, ReactionType::Distinct);
317 assert_eq!(
318 serde_json::to_string(&ReactionType::Multiple).unwrap(),
319 "\"multiple\""
320 );
321 }
322
323 #[test]
324 fn message_deserializes_from_wire_camelcase() {
325 let wire = r#"{
328 "serial": "01726585978590-001@abcdefghij:001",
329 "version": {
330 "serial": "01726585978590-001@abcdefghij:001",
331 "timestamp": 1700000000000,
332 "clientId": "alice",
333 "description": "edited"
334 },
335 "text": "hello",
336 "clientId": "alice",
337 "action": "message.create",
338 "metadata": {"priority": "high"},
339 "headers": {"topic": "announcements"},
340 "timestamp": 1700000000000,
341 "userClaim": "room-claim"
342 }"#;
343 let msg: Message = serde_json::from_str(wire).unwrap();
344 assert_eq!(msg.serial.as_str(), "01726585978590-001@abcdefghij:001");
345 assert_eq!(msg.text, "hello");
346 assert_eq!(msg.client_id, "alice");
347 assert_eq!(msg.action, MessageAction::Create);
348 assert_eq!(msg.timestamp.as_millis(), 1_700_000_000_000);
349 assert_eq!(msg.version.client_id.as_deref(), Some("alice"));
350 assert_eq!(msg.version.description.as_deref(), Some("edited"));
351 assert_eq!(msg.metadata["priority"], serde_json::json!("high"));
352 assert_eq!(msg.headers["topic"], "announcements");
353 assert_eq!(msg.user_claim.as_deref(), Some("room-claim"));
354 assert!(msg.reactions.unique.is_empty());
356 assert!(msg.reactions.distinct.is_empty());
357 assert!(msg.reactions.multiple.is_empty());
358 }
359
360 #[test]
361 fn reaction_summary_deserializes_camelcase_fields() {
362 let wire = r#"{
363 "unique": {"👍": {"total": 2, "clientIds": ["alice", "bob"], "clipped": false}},
364 "multiple": {"🎉": {"total": 5, "clientIds": {"alice": 3, "bob": 2}, "totalUnidentified": 1}}
365 }"#;
366 let summary: ReactionSummary = serde_json::from_str(wire).unwrap();
367 let thumbs = &summary.unique["\u{1f44d}"];
368 assert_eq!(thumbs.total, 2);
369 assert_eq!(thumbs.client_ids, vec!["alice", "bob"]);
370 assert!(!thumbs.clipped);
371 let party = &summary.multiple["\u{1f389}"];
372 assert_eq!(party.total, 5);
373 assert_eq!(party.client_ids["alice"], 3);
374 assert_eq!(party.total_unidentified, 1);
375 assert!(summary.distinct.is_empty());
376 }
377
378 #[test]
379 fn occupancy_deserializes() {
380 let occ: Occupancy =
381 serde_json::from_str(r#"{"connections": 3, "presenceMembers": 2}"#).unwrap();
382 assert_eq!(occ.connections, 3);
383 assert_eq!(occ.presence_members, 2);
384 }
385
386 #[cfg(feature = "chrono")]
387 #[test]
388 fn timestamp_converts_to_chrono() {
389 let t = Timestamp::from(1_700_000_000_000);
390 assert_eq!(t.to_chrono().unwrap().timestamp_millis(), 1_700_000_000_000);
391 }
392}