1use 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
14pub struct CliBot {
20 builder: BotBuilder,
21 transport: Transport,
22 hub: CliHub,
23 inbound: Receiver<Inbound>,
24}
25
26impl CliBot {
27 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 pub fn hub(&self) -> CliHub {
41 self.hub.clone()
42 }
43
44 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 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 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 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 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 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 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 }
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
195fn 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
220fn 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
229fn 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 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 #[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 #[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 #[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}