1use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct WireUser {
13 pub id: String,
15 pub name: String,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct WireFile {
25 pub kind: String,
28 pub path: String,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub mime: Option<String>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub file_id: Option<String>,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct WireReplyRef {
41 pub message_id: i64,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub from: Option<String>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub text: Option<String>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct WireSticker {
54 pub file_id: String,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub emoji: Option<String>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub set_name: Option<String>,
62 #[serde(default = "default_static")]
64 pub format: String,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub path: Option<String>,
68}
69
70fn default_static() -> String {
71 "static".to_string()
72}
73
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
76pub struct InboundMessage {
77 pub chat: String,
79 pub user: WireUser,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub message_id: Option<i64>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub text: Option<String>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub caption: Option<String>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub thread_id: Option<i64>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub reply_to: Option<WireReplyRef>,
96 #[serde(default, skip_serializing_if = "Vec::is_empty")]
98 pub files: Vec<WireFile>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub sticker: Option<WireSticker>,
102 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
106 pub ambient: bool,
107}
108
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111pub struct InboundCommand {
112 pub chat: String,
114 pub user: WireUser,
116 pub name: String,
118 #[serde(default)]
120 pub args: String,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub message_id: Option<i64>,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub thread_id: Option<i64>,
127}
128
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
131pub struct InboundButton {
132 pub chat: String,
134 pub user: WireUser,
136 pub data: String,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub message_id: Option<i64>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub message_text: Option<String>,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub thread_id: Option<i64>,
147}
148
149#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
151pub struct InboundReaction {
152 pub chat: String,
154 pub user: WireUser,
156 pub message_id: i64,
158 #[serde(default, skip_serializing_if = "Vec::is_empty")]
160 pub added: Vec<String>,
161 #[serde(default, skip_serializing_if = "Vec::is_empty")]
163 pub removed: Vec<String>,
164}
165
166#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
168pub struct InboundEdited {
169 pub chat: String,
171 pub user: WireUser,
173 pub message_id: i64,
175 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub text: Option<String>,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub thread_id: Option<i64>,
181}
182
183#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
185#[serde(tag = "type", rename_all = "snake_case")]
186pub enum Inbound {
187 Subscribe,
190 Message(Box<InboundMessage>),
192 Command(InboundCommand),
194 Button(InboundButton),
196 Reaction(InboundReaction),
198 Edited(InboundEdited),
200}
201
202impl Inbound {
203 pub fn chat(&self) -> &str {
205 match self {
206 Self::Message(e) => &e.chat,
207 Self::Command(e) => &e.chat,
208 Self::Button(e) => &e.chat,
209 Self::Reaction(e) => &e.chat,
210 Self::Edited(e) => &e.chat,
211 Self::Subscribe => "",
212 }
213 }
214
215 pub fn user(&self) -> Option<&WireUser> {
217 match self {
218 Self::Message(e) => Some(&e.user),
219 Self::Command(e) => Some(&e.user),
220 Self::Button(e) => Some(&e.user),
221 Self::Reaction(e) => Some(&e.user),
222 Self::Edited(e) => Some(&e.user),
223 Self::Subscribe => None,
224 }
225 }
226
227 pub fn message_id(&self) -> Option<i64> {
229 match self {
230 Self::Message(e) => e.message_id,
231 Self::Command(e) => e.message_id,
232 Self::Button(e) => e.message_id,
233 Self::Reaction(e) => Some(e.message_id),
234 Self::Edited(e) => Some(e.message_id),
235 Self::Subscribe => None,
236 }
237 }
238
239 pub fn thread_id(&self) -> Option<i64> {
241 match self {
242 Self::Message(e) => e.thread_id,
243 Self::Command(e) => e.thread_id,
244 Self::Button(e) => e.thread_id,
245 Self::Edited(e) => e.thread_id,
246 Self::Reaction(_) | Self::Subscribe => None,
247 }
248 }
249
250 pub(crate) fn ensure_message_id(&mut self, next: impl Fn() -> i64) {
252 let slot = match self {
253 Self::Message(e) => &mut e.message_id,
254 Self::Command(e) => &mut e.message_id,
255 Self::Button(e) => &mut e.message_id,
256 _ => return,
257 };
258 if slot.is_none() {
259 *slot = Some(next());
260 }
261 }
262}
263
264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
266pub struct WireButton {
267 pub text: String,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub data: Option<String>,
272 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub url: Option<String>,
275}
276
277#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
279pub struct OutboundMessage {
280 pub chat: String,
282 pub message_id: i64,
284 pub text: String,
286 #[serde(default, skip_serializing_if = "Vec::is_empty")]
288 pub buttons: Vec<Vec<WireButton>>,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub reply_to: Option<i64>,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub thread_id: Option<i64>,
295 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub extras: Option<serde_json::Value>,
298}
299
300#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
303pub struct OutboundFile {
304 pub chat: String,
306 pub message_id: i64,
308 pub kind: String,
310 #[serde(default, skip_serializing_if = "Option::is_none")]
312 pub path: Option<String>,
313 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub data: Option<String>,
316 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub filename: Option<String>,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub caption: Option<String>,
322 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub thread_id: Option<i64>,
325}
326
327#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
329pub struct OutboundReaction {
330 pub chat: String,
332 pub message_id: i64,
334 pub emojis: Vec<String>,
336 #[serde(default)]
338 pub is_big: bool,
339}
340
341#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
343pub struct OutboundEdit {
344 pub chat: String,
346 pub message_id: i64,
348 pub text: String,
350}
351
352#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
354pub struct OutboundDelete {
355 pub chat: String,
357 pub message_id: i64,
359}
360
361#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
363pub struct OutboundPin {
364 pub chat: String,
366 pub message_id: i64,
368 #[serde(default)]
370 pub unpin: bool,
371 #[serde(default)]
373 pub notify: bool,
374}
375
376#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
378pub struct OutboundAction {
379 pub chat: String,
381 pub action: String,
383 #[serde(default)]
385 pub clear: bool,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
388 pub thread_id: Option<i64>,
389}
390
391#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
393pub struct OutboundAck {
394 #[serde(default, skip_serializing_if = "Option::is_none")]
396 pub message_id: Option<i64>,
397}
398
399#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
401pub struct OutboundError {
402 pub message: String,
404}
405
406#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
408#[serde(tag = "type", rename_all = "snake_case")]
409pub enum Outbound {
410 Message(OutboundMessage),
412 File(OutboundFile),
414 Reaction(OutboundReaction),
416 Edit(OutboundEdit),
418 Delete(OutboundDelete),
420 Pin(OutboundPin),
422 Action(OutboundAction),
424 Ack(OutboundAck),
426 Error(OutboundError),
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433
434 fn user() -> WireUser {
435 WireUser {
436 id: "u1".to_string(),
437 name: "alice".to_string(),
438 }
439 }
440
441 #[test]
442 fn inbound_message_round_trips() {
443 let event = Inbound::Message(Box::new(InboundMessage {
444 chat: "c1".to_string(),
445 user: user(),
446 message_id: Some(7),
447 text: Some("hi".to_string()),
448 caption: None,
449 thread_id: Some(3),
450 reply_to: None,
451 files: vec![WireFile {
452 kind: "photo".to_string(),
453 path: "/tmp/x.png".to_string(),
454 mime: None,
455 file_id: None,
456 }],
457 sticker: None,
458 ambient: false,
459 }));
460 let line = serde_json::to_string(&event).unwrap();
461 let parsed: Inbound = serde_json::from_str(&line).unwrap();
462 assert_eq!(parsed, event);
463 assert_eq!(parsed.chat(), "c1");
464 assert_eq!(parsed.message_id(), Some(7));
465 assert_eq!(parsed.thread_id(), Some(3));
466 }
467
468 #[test]
469 fn inbound_tagged_variants_parse() {
470 let command: Inbound = serde_json::from_str(
471 r#"{"type":"command","chat":"c1","user":{"id":"u","name":"n"},"name":"ping","args":"a b"}"#,
472 )
473 .unwrap();
474 assert!(matches!(command, Inbound::Command(_)));
475
476 let button: Inbound = serde_json::from_str(
477 r#"{"type":"button","chat":"c1","user":{"id":"u","name":"n"},"data":"yes"}"#,
478 )
479 .unwrap();
480 assert!(matches!(button, Inbound::Button(_)));
481
482 let reaction: Inbound = serde_json::from_str(
483 r#"{"type":"reaction","chat":"c1","user":{"id":"u","name":"n"},"message_id":9,"added":["👍"]}"#,
484 )
485 .unwrap();
486 match reaction {
487 Inbound::Reaction(r) => assert_eq!(r.added, ["👍"]),
488 other => panic!("expected reaction, got {other:?}"),
489 }
490 }
491
492 #[test]
493 fn outbound_message_omits_empty_fields() {
494 let outbound = Outbound::Message(OutboundMessage {
495 chat: "c1".to_string(),
496 message_id: 1,
497 text: "hi".to_string(),
498 buttons: vec![],
499 reply_to: None,
500 thread_id: None,
501 extras: None,
502 });
503 let json = serde_json::to_value(&outbound).unwrap();
504 assert_eq!(json["type"], "message");
505 assert!(json.get("buttons").is_none());
506 assert!(json.get("thread_id").is_none());
507 }
508
509 #[test]
510 fn ensure_message_id_fills_missing() {
511 let mut event = Inbound::Message(Box::new(InboundMessage {
512 chat: "c".to_string(),
513 user: user(),
514 message_id: None,
515 text: None,
516 caption: None,
517 thread_id: None,
518 reply_to: None,
519 files: vec![],
520 sticker: None,
521 ambient: false,
522 }));
523 event.ensure_message_id(|| 42);
524 assert_eq!(event.message_id(), Some(42));
525
526 let mut fixed = Inbound::Edited(InboundEdited {
527 chat: "c".to_string(),
528 user: user(),
529 message_id: 5,
530 text: None,
531 thread_id: None,
532 });
533 fixed.ensure_message_id(|| 99);
534 assert_eq!(fixed.message_id(), Some(5));
535 }
536}