Skip to main content

botkit_cli/
wire.rs

1//! The JSONL wire protocol shared by the bot-side transport and the
2//! `botkit-cli` driver binary.
3//!
4//! Inbound lines (driver -> bot) are [`Inbound`] events. Outbound lines
5//! (bot -> driver) are [`Outbound`] actions. One JSON object per line, on
6//! stdin/stdout or a unix-socket connection.
7
8use serde::{Deserialize, Serialize};
9
10/// A user attached to an inbound event.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct WireUser {
13    /// Platform user id.
14    pub id: String,
15    /// Display name.
16    pub name: String,
17}
18
19/// A file attached to an inbound message.
20///
21/// The CLI platform has no remote file store: `path` is a local file the
22/// bot may read directly.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct WireFile {
25    /// Media kind: `photo`, `video`, `audio`, `voice`, `animation`,
26    /// `document`, or `sticker`.
27    pub kind: String,
28    /// Local filesystem path.
29    pub path: String,
30    /// MIME type; inferred from `path` when absent.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub mime: Option<String>,
33    /// Stable identifier for resend-by-id flows; defaults to `path`.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub file_id: Option<String>,
36}
37
38/// The message an inbound message replies to.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct WireReplyRef {
41    /// Replied-to message id.
42    pub message_id: i64,
43    /// Replied-to author display name.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub from: Option<String>,
46    /// Replied-to text.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub text: Option<String>,
49}
50
51/// A sticker attached to an inbound message.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct WireSticker {
54    /// Sticker identifier for resend-by-id.
55    pub file_id: String,
56    /// Associated emoji.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub emoji: Option<String>,
59    /// Owning set name.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub set_name: Option<String>,
62    /// `static`, `animated`, or `video`.
63    #[serde(default = "default_static")]
64    pub format: String,
65    /// Local file backing the sticker, if any.
66    #[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/// A plain or media message.
75#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
76pub struct InboundMessage {
77    /// Chat/channel id.
78    pub chat: String,
79    /// Author.
80    pub user: WireUser,
81    /// Message id; assigned by the hub when omitted.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub message_id: Option<i64>,
84    /// Message text.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub text: Option<String>,
87    /// Media caption, when the message carries files.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub caption: Option<String>,
90    /// Forum topic the message belongs to.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub thread_id: Option<i64>,
93    /// The message this one replies to.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub reply_to: Option<WireReplyRef>,
96    /// Attached files.
97    #[serde(default, skip_serializing_if = "Vec::is_empty")]
98    pub files: Vec<WireFile>,
99    /// Attached sticker.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub sticker: Option<WireSticker>,
102    /// Marks the message as room chatter not aimed at the bot — the way a
103    /// group message without a reply/mention looks. Consumers may use it to
104    /// flag the event `ambient` rather than `direct`.
105    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
106    pub ambient: bool,
107}
108
109/// A `/name args` invocation.
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111pub struct InboundCommand {
112    /// Chat/channel id.
113    pub chat: String,
114    /// Invoking user.
115    pub user: WireUser,
116    /// Command name, without prefix.
117    pub name: String,
118    /// Raw argument string.
119    #[serde(default)]
120    pub args: String,
121    /// Message id of the invocation; assigned by the hub when omitted.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub message_id: Option<i64>,
124    /// Forum topic the command was sent in.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub thread_id: Option<i64>,
127}
128
129/// An inline-button press.
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
131pub struct InboundButton {
132    /// Chat/channel id.
133    pub chat: String,
134    /// Pressing user.
135    pub user: WireUser,
136    /// The button's callback data.
137    pub data: String,
138    /// Message the button was attached to.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub message_id: Option<i64>,
141    /// Text of the message the button was attached to.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub message_text: Option<String>,
144    /// Forum topic of the button's message.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub thread_id: Option<i64>,
147}
148
149/// Reactions added and removed on a message.
150#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
151pub struct InboundReaction {
152    /// Chat/channel id.
153    pub chat: String,
154    /// Reacting user.
155    pub user: WireUser,
156    /// Message being reacted to.
157    pub message_id: i64,
158    /// Reactions that appeared.
159    #[serde(default, skip_serializing_if = "Vec::is_empty")]
160    pub added: Vec<String>,
161    /// Reactions that disappeared.
162    #[serde(default, skip_serializing_if = "Vec::is_empty")]
163    pub removed: Vec<String>,
164}
165
166/// A message whose text was edited.
167#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
168pub struct InboundEdited {
169    /// Chat/channel id.
170    pub chat: String,
171    /// Editing user.
172    pub user: WireUser,
173    /// Edited message id.
174    pub message_id: i64,
175    /// New text.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub text: Option<String>,
178    /// Forum topic of the message.
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub thread_id: Option<i64>,
181}
182
183/// An event a driver injects into the bot.
184#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
185#[serde(tag = "type", rename_all = "snake_case")]
186pub enum Inbound {
187    /// Register this connection as an outbound stream. Only meaningful on
188    /// the socket transport; on stdio the single stream is implicit.
189    Subscribe,
190    /// A plain or media message.
191    Message(Box<InboundMessage>),
192    /// A command invocation.
193    Command(InboundCommand),
194    /// An inline-button press.
195    Button(InboundButton),
196    /// A reaction update.
197    Reaction(InboundReaction),
198    /// A message edit.
199    Edited(InboundEdited),
200}
201
202impl Inbound {
203    /// The chat this event targets.
204    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    /// The acting user.
216    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    /// The message id attached to this event, if any.
228    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    /// The forum topic this event belongs to.
240    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    /// Assign a message id where the driver left it out.
251    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/// One inline-keyboard button.
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
266pub struct WireButton {
267    /// Button label.
268    pub text: String,
269    /// Callback data (mutually exclusive with `url`).
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub data: Option<String>,
272    /// Link target (mutually exclusive with `data`).
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub url: Option<String>,
275}
276
277/// A text message the bot sent.
278#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
279pub struct OutboundMessage {
280    /// Chat/channel id.
281    pub chat: String,
282    /// Assigned message id.
283    pub message_id: i64,
284    /// Message text.
285    pub text: String,
286    /// Inline keyboard rows.
287    #[serde(default, skip_serializing_if = "Vec::is_empty")]
288    pub buttons: Vec<Vec<WireButton>>,
289    /// Message this one replies to.
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub reply_to: Option<i64>,
292    /// Forum topic the message was sent to.
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub thread_id: Option<i64>,
295    /// Platform extras that have no CLI-native shape (embeds, select menus).
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub extras: Option<serde_json::Value>,
298}
299
300/// A file the bot sent. `path` stays a local reference — the platform does
301/// not copy bytes — while in-memory payloads travel base64'd in `data`.
302#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
303pub struct OutboundFile {
304    /// Chat/channel id.
305    pub chat: String,
306    /// Assigned message id.
307    pub message_id: i64,
308    /// Media kind.
309    pub kind: String,
310    /// Local path, when the payload lives on disk.
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub path: Option<String>,
313    /// Base64 payload, when the bytes were in memory.
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub data: Option<String>,
316    /// Suggested filename.
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub filename: Option<String>,
319    /// Caption.
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub caption: Option<String>,
322    /// Forum topic.
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub thread_id: Option<i64>,
325}
326
327/// Reaction(s) the bot set on a message.
328#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
329pub struct OutboundReaction {
330    /// Chat/channel id.
331    pub chat: String,
332    /// Target message.
333    pub message_id: i64,
334    /// Emojis now on the message.
335    pub emojis: Vec<String>,
336    /// Animated/big variant.
337    #[serde(default)]
338    pub is_big: bool,
339}
340
341/// A message the bot edited.
342#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
343pub struct OutboundEdit {
344    /// Chat/channel id.
345    pub chat: String,
346    /// Edited message.
347    pub message_id: i64,
348    /// New text.
349    pub text: String,
350}
351
352/// A message the bot deleted.
353#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
354pub struct OutboundDelete {
355    /// Chat/channel id.
356    pub chat: String,
357    /// Deleted message.
358    pub message_id: i64,
359}
360
361/// A message the bot pinned or unpinned.
362#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
363pub struct OutboundPin {
364    /// Chat/channel id.
365    pub chat: String,
366    /// Target message.
367    pub message_id: i64,
368    /// `true` for unpin.
369    #[serde(default)]
370    pub unpin: bool,
371    /// Whether the pin notified the chat.
372    #[serde(default)]
373    pub notify: bool,
374}
375
376/// A chat action indicator (typing and friends).
377#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
378pub struct OutboundAction {
379    /// Chat/channel id.
380    pub chat: String,
381    /// Action name, e.g. `typing`.
382    pub action: String,
383    /// `true` when the indicator was cleared early.
384    #[serde(default)]
385    pub clear: bool,
386    /// Forum topic.
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub thread_id: Option<i64>,
389}
390
391/// An injected event was accepted for dispatch.
392#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
393pub struct OutboundAck {
394    /// The message id the event carries (assigned if the driver omitted it).
395    #[serde(default, skip_serializing_if = "Option::is_none")]
396    pub message_id: Option<i64>,
397}
398
399/// An inbound line failed to parse or an event could not be delivered.
400#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
401pub struct OutboundError {
402    /// What went wrong.
403    pub message: String,
404}
405
406/// An action the bot performed, streamed to subscribers.
407#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
408#[serde(tag = "type", rename_all = "snake_case")]
409pub enum Outbound {
410    /// A text message.
411    Message(OutboundMessage),
412    /// A file/media message.
413    File(OutboundFile),
414    /// Reaction(s) set on a message.
415    Reaction(OutboundReaction),
416    /// A message edit.
417    Edit(OutboundEdit),
418    /// A message deletion.
419    Delete(OutboundDelete),
420    /// A pin or unpin.
421    Pin(OutboundPin),
422    /// A chat action indicator.
423    Action(OutboundAction),
424    /// An injected event was accepted.
425    Ack(OutboundAck),
426    /// A driver-facing error.
427    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}