Skip to main content

botkit_cli/
bot.rs

1//! The `CliBot` adapter: routes injected events through `BotBuilder` and
2//! turns handler `Response`s into outbound wire actions.
3
4use async_channel::Receiver;
5use botkit_core::FileSource;
6use botkit_core::types::component::Component;
7use botkit_core::{Bot, BotBuilder, BotError, Context, Event, IntoHandler, Response, Shutdown};
8
9use crate::context::CliContextData;
10use crate::hub::CliHub;
11use crate::transport::{self, Transport};
12use crate::wire::{Inbound, Outbound, OutboundFile, OutboundMessage, WireButton};
13
14/// A bot whose "platform" is the CLI wire protocol.
15///
16/// Build it like any other adapter, take the [`CliHub`] handle if you need
17/// to drive it out-of-band (acpbot-style platform senders, tests), then
18/// `run()`.
19pub struct CliBot {
20    builder: BotBuilder,
21    transport: Transport,
22    hub: CliHub,
23    inbound: Receiver<Inbound>,
24}
25
26impl CliBot {
27    /// A bot on the given transport.
28    pub fn new(transport: Transport) -> Self {
29        let (hub, inbound) = CliHub::new();
30        Self {
31            builder: BotBuilder::new(),
32            transport,
33            hub,
34            inbound,
35        }
36    }
37
38    /// The shared hub handle: inject events, subscribe to outbound lines,
39    /// or build platform senders on it.
40    pub fn hub(&self) -> CliHub {
41        self.hub.clone()
42    }
43
44    /// Register a command handler.
45    pub fn command<H, Args>(mut self, name: impl Into<String>, handler: H) -> Self
46    where
47        H: IntoHandler<Args>,
48    {
49        self.builder = self.builder.command(name, handler);
50        self
51    }
52
53    /// Register a command handler with a menu description.
54    pub fn command_with_description<H, Args>(
55        mut self,
56        name: impl Into<String>,
57        description: impl Into<String>,
58        handler: H,
59    ) -> Self
60    where
61        H: IntoHandler<Args>,
62    {
63        self.builder = self
64            .builder
65            .command_with_description(name, description, handler);
66        self
67    }
68
69    /// Register a button handler (`*` suffix matches by prefix).
70    pub fn button<H, Args>(mut self, pattern: impl Into<String>, handler: H) -> Self
71    where
72        H: IntoHandler<Args>,
73    {
74        self.builder = self.builder.button(pattern, handler);
75        self
76    }
77
78    /// Register the catch-all message handler.
79    pub fn message<H, Args>(mut self, handler: H) -> Self
80    where
81        H: IntoHandler<Args>,
82    {
83        self.builder = self.builder.message(handler);
84        self
85    }
86
87    /// Register the handler for events no route claims.
88    pub fn fallback<H, Args>(mut self, handler: H) -> Self
89    where
90        H: IntoHandler<Args>,
91    {
92        self.builder = self.builder.fallback(handler);
93        self
94    }
95
96    /// Dispatch one inbound event: route it, run the handler, translate the
97    /// response into outbound actions.
98    async fn dispatch(&self, inbound: Inbound) {
99        let event = match &inbound {
100            Inbound::Command(command) => Event::Command(command.name.as_str()),
101            Inbound::Button(button) => Event::Button(button.data.as_str()),
102            _ => Event::Message,
103        };
104        let Some(handler) = self.builder.route(event).cloned() else {
105            return;
106        };
107        let chat = inbound.chat().to_string();
108        let thread_id = inbound.thread_id();
109        let data = CliContextData::new(inbound, self.hub.clone());
110        let response = handler.call(Context::new(data)).await;
111        self.send_response(&chat, thread_id, response).await;
112    }
113
114    /// Serialize a handler `Response` into outbound wire actions addressed
115    /// to the event's chat and topic.
116    async fn send_response(&self, chat: &str, thread_id: Option<i64>, mut response: Response) {
117        if let Some(text) = response.content() {
118            self.hub.emit(Outbound::Message(OutboundMessage {
119                chat: chat.to_string(),
120                message_id: self.hub.next_message_id(),
121                text: text.to_string(),
122                buttons: components_to_buttons(response.components()),
123                reply_to: None,
124                thread_id,
125                extras: embeds_extra(&response),
126            }));
127            return;
128        }
129        if let Some(file) = response.take_file() {
130            let (path, data) = match file.file {
131                FileSource::Path(path) => (Some(path.display().to_string()), None),
132                other => {
133                    let bytes = other.read().await.unwrap_or_default();
134                    use base64::Engine;
135                    (
136                        None,
137                        Some(base64::engine::general_purpose::STANDARD.encode(bytes)),
138                    )
139                }
140            };
141            let kind = file
142                .filename
143                .as_deref()
144                .or(path.as_deref())
145                .map(media_kind)
146                .unwrap_or("document")
147                .to_string();
148            self.hub.emit(Outbound::File(OutboundFile {
149                chat: chat.to_string(),
150                message_id: self.hub.next_message_id(),
151                kind,
152                path,
153                data,
154                filename: file.filename,
155                caption: file.caption,
156                thread_id,
157            }));
158        }
159        // Empty and Acknowledge responses send nothing.
160    }
161}
162
163impl Bot for CliBot {
164    async fn run_until(self, shutdown: Shutdown) -> Result<(), BotError> {
165        transport::start(&self.transport, &self.hub);
166
167        enum Step {
168            Event(Box<Inbound>),
169            Stop,
170        }
171
172        loop {
173            let step = futures_lite::future::or(
174                async {
175                    match self.inbound.recv().await {
176                        Ok(event) => Step::Event(Box::new(event)),
177                        Err(_) => Step::Stop,
178                    }
179                },
180                async {
181                    shutdown.wait().await;
182                    Step::Stop
183                },
184            )
185            .await;
186
187            match step {
188                Step::Event(event) => self.dispatch(*event).await,
189                Step::Stop => return Ok(()),
190            }
191        }
192    }
193}
194
195/// Flatten botkit components into CLI keyboard rows; non-button components
196/// are skipped (they surface through `extras` instead).
197fn components_to_buttons(components: &[Component]) -> Vec<Vec<WireButton>> {
198    components
199        .iter()
200        .filter_map(|component| match component {
201            Component::ActionRow(row) => Some(
202                row.components
203                    .iter()
204                    .filter_map(|component| match component {
205                        Component::Button(button) => Some(WireButton {
206                            text: button.label.clone(),
207                            data: button.custom_id.clone(),
208                            url: button.url.clone(),
209                        }),
210                        _ => None,
211                    })
212                    .collect::<Vec<_>>(),
213            ),
214            _ => None,
215        })
216        .filter(|row: &Vec<WireButton>| !row.is_empty())
217        .collect()
218}
219
220/// Embeds and select menus have no CLI-native shape; carry them as raw JSON.
221fn embeds_extra(response: &Response) -> Option<serde_json::Value> {
222    let embeds = response.embeds();
223    if embeds.is_empty() {
224        return None;
225    }
226    serde_json::to_value(embeds).ok()
227}
228
229/// MIME-derived media kind for a filename or path.
230fn media_kind(name: &str) -> &'static str {
231    let mime = mime_guess::from_path(name).first_or_octet_stream();
232    match (mime.type_().as_str(), mime.subtype().as_str()) {
233        ("image", "gif") => "animation",
234        ("image", _) => "photo",
235        ("video", _) => "video",
236        ("audio", "ogg") => "voice",
237        ("audio", _) => "audio",
238        _ => "document",
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use crate::wire::{InboundButton, InboundCommand, InboundMessage, InboundReaction, WireUser};
246
247    /// Install and drive a global executor once for the whole test binary:
248    /// `Bot::run_until` and `ChatActionGuard` spawn through executor-core.
249    fn ensure_executor() {
250        static ONCE: std::sync::Once = std::sync::Once::new();
251        ONCE.call_once(|| {
252            let executor: &'static async_executor::Executor<'static> =
253                Box::leak(Box::new(async_executor::Executor::new()));
254            executor_core::init_global_executor(executor);
255            std::thread::spawn(move || {
256                futures_lite::future::block_on(executor.run(futures_lite::future::pending::<()>()));
257            });
258        });
259    }
260
261    fn user() -> WireUser {
262        WireUser {
263            id: "u1".to_string(),
264            name: "alice".to_string(),
265        }
266    }
267
268    fn message(text: &str) -> Inbound {
269        Inbound::Message(Box::new(InboundMessage {
270            chat: "c1".to_string(),
271            user: user(),
272            message_id: None,
273            text: Some(text.to_string()),
274            caption: None,
275            thread_id: None,
276            reply_to: None,
277            files: vec![],
278            sticker: None,
279            ambient: false,
280        }))
281    }
282
283    fn outbound_lines(rx: &Receiver<String>) -> Vec<Outbound> {
284        let mut lines = Vec::new();
285        while let Ok(line) = rx.try_recv() {
286            lines.push(serde_json::from_str(&line).unwrap());
287        }
288        lines
289    }
290
291    /// An injected message reaches the message handler and its `Response`
292    /// becomes an outbound `message` line addressed to the event's chat.
293    #[test]
294    fn message_round_trip() {
295        ensure_executor();
296        futures_lite::future::block_on(async {
297            let bot = CliBot::new(Transport::Manual).message(|ctx: Context| async move {
298                format!("echo: {}", ctx.message_content().unwrap_or("?"))
299            });
300            let hub = bot.hub();
301            let sink = hub.subscribe();
302            let (signal, shutdown) = botkit_core::Shutdown::channel();
303            let task = executor_core::spawn(bot.run_until(shutdown));
304
305            hub.inject(message("hi"));
306            let ack_deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
307            let lines = loop {
308                let lines = outbound_lines(&sink);
309                if lines.iter().any(|o| matches!(o, Outbound::Message(_))) {
310                    break lines;
311                }
312                assert!(
313                    std::time::Instant::now() < ack_deadline,
314                    "no outbound message"
315                );
316                std::thread::sleep(std::time::Duration::from_millis(10));
317            };
318            let outbound = lines
319                .into_iter()
320                .find_map(|o| match o {
321                    Outbound::Message(m) => Some(m),
322                    _ => None,
323                })
324                .unwrap();
325            assert_eq!(outbound.chat, "c1");
326            assert_eq!(outbound.text, "echo: hi");
327
328            signal.shutdown();
329            task.await.unwrap();
330        });
331    }
332
333    /// Commands route to their handler; button presses route to the button
334    /// handler; reactions land on the catch-all.
335    #[test]
336    fn command_button_reaction_routing() {
337        ensure_executor();
338        futures_lite::future::block_on(async {
339            let bot = CliBot::new(Transport::Manual)
340                .command("ping", || async { "pong" })
341                .button("yes", || async { "pressed" })
342                .message(|| async { "got it" });
343            let hub = bot.hub();
344            let sink = hub.subscribe();
345            let (signal, shutdown) = botkit_core::Shutdown::channel();
346            let task = executor_core::spawn(bot.run_until(shutdown));
347
348            hub.inject(Inbound::Command(InboundCommand {
349                chat: "c1".to_string(),
350                user: user(),
351                name: "ping".to_string(),
352                args: String::new(),
353                message_id: None,
354                thread_id: None,
355            }));
356            hub.inject(Inbound::Button(InboundButton {
357                chat: "c1".to_string(),
358                user: user(),
359                data: "yes".to_string(),
360                message_id: Some(3),
361                message_text: None,
362                thread_id: None,
363            }));
364            hub.inject(Inbound::Reaction(InboundReaction {
365                chat: "c1".to_string(),
366                user: user(),
367                message_id: 4,
368                added: vec!["👍".to_string()],
369                removed: vec![],
370            }));
371
372            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
373            let texts = loop {
374                let texts: Vec<String> = outbound_lines(&sink)
375                    .into_iter()
376                    .filter_map(|o| match o {
377                        Outbound::Message(m) => Some(m.text),
378                        _ => None,
379                    })
380                    .collect();
381                if texts.len() >= 3 {
382                    break texts;
383                }
384                assert!(std::time::Instant::now() < deadline, "only got {texts:?}");
385                std::thread::sleep(std::time::Duration::from_millis(10));
386            };
387            assert_eq!(texts, ["pong", "pressed", "got it"]);
388
389            signal.shutdown();
390            task.await.unwrap();
391        });
392    }
393
394    /// `ctx.typing()` produces an outbound `action` line on this platform.
395    #[test]
396    fn typing_action_surfaces() {
397        ensure_executor();
398        futures_lite::future::block_on(async {
399            let bot = CliBot::new(Transport::Manual).message(|ctx: Context| async move {
400                let _typing = ctx.typing();
401                "done"
402            });
403            let hub = bot.hub();
404            let sink = hub.subscribe();
405            let (signal, shutdown) = botkit_core::Shutdown::channel();
406            let task = executor_core::spawn(bot.run_until(shutdown));
407
408            hub.inject(message("x"));
409            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
410            let lines = loop {
411                let lines = outbound_lines(&sink);
412                if lines.iter().any(|o| matches!(o, Outbound::Message(_))) {
413                    break lines;
414                }
415                assert!(std::time::Instant::now() < deadline);
416                std::thread::sleep(std::time::Duration::from_millis(10));
417            };
418            assert!(lines.iter().any(|o| matches!(
419                o,
420                Outbound::Action(a) if a.action == "typing" && a.chat == "c1"
421            )));
422
423            signal.shutdown();
424            task.await.unwrap();
425        });
426    }
427}