Skip to main content

botkit_core/
bot.rs

1use std::collections::HashMap;
2use std::future::Future;
3
4use crate::BotError;
5use crate::handler::{AnyHandler, IntoHandler};
6use crate::shutdown::Shutdown;
7
8// Handlers and futures are only thread-safe off wasm32, where there are no
9// threads to be safe across; the bounds follow the rest of the crate.
10#[cfg(not(target_arch = "wasm32"))]
11pub trait BotBounds: Send {}
12#[cfg(not(target_arch = "wasm32"))]
13impl<T: Send + ?Sized> BotBounds for T {}
14
15#[cfg(target_arch = "wasm32")]
16pub trait BotBounds {}
17#[cfg(target_arch = "wasm32")]
18impl<T: ?Sized> BotBounds for T {}
19
20#[cfg(not(target_arch = "wasm32"))]
21pub trait BotFutureBounds: Future + Send {}
22#[cfg(not(target_arch = "wasm32"))]
23impl<T: Future + Send + ?Sized> BotFutureBounds for T {}
24
25#[cfg(target_arch = "wasm32")]
26pub trait BotFutureBounds: Future {}
27#[cfg(target_arch = "wasm32")]
28impl<T: Future + ?Sized> BotFutureBounds for T {}
29
30/// Unified Bot trait that hides connection mode differences
31///
32/// Every adapter — Discord's gateway, Telegram's long polling, and Matrix's
33/// sync loop — exposes the same entry point: run until told to stop.
34///
35/// # Example
36/// ```ignore
37/// use botkit_core::{Bot, Shutdown};
38///
39/// // Run until the process is killed.
40/// bot.run().await?;
41///
42/// // Or run until something else signals shutdown.
43/// let (signal, shutdown) = Shutdown::channel();
44/// let task = spawn(bot.run_until(shutdown));
45/// signal.shutdown();
46/// task.await?;
47/// ```
48pub trait Bot: Sized + BotBounds {
49    /// Run the bot until `shutdown` fires or a fatal error occurs
50    ///
51    /// Returns `Ok(())` when stopped via the shutdown signal.
52    fn run_until(self, shutdown: Shutdown) -> impl BotFutureBounds<Output = Result<(), BotError>>;
53
54    /// Run the bot until a fatal error occurs
55    ///
56    /// Equivalent to [`Bot::run_until`] with a signal that never fires.
57    fn run(self) -> impl BotFutureBounds<Output = Result<(), BotError>> {
58        self.run_until(Shutdown::never())
59    }
60}
61
62/// An incoming event, resolved to the shape the router dispatches on
63///
64/// Adapters translate their platform payload into one of these and hand it to
65/// [`BotBuilder::route`].
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum Event<'a> {
68    /// A command invocation, by command name (without any prefix or slash)
69    Command(&'a str),
70    /// A button press / callback, by its custom id
71    Button(&'a str),
72    /// A plain message that is not a command
73    Message,
74}
75
76/// A registered command and the description shown in platform command menus
77#[derive(Debug, Clone, Copy)]
78pub struct CommandInfo<'a> {
79    /// Command name, without any prefix or slash
80    pub name: &'a str,
81    /// Description, or an empty string when none was given
82    pub description: &'a str,
83}
84
85/// Builder for constructing bots with handlers
86///
87/// Handlers are indexed as they are registered, so dispatch is a hash lookup
88/// rather than a scan. When two handlers claim the same command or button id,
89/// the first one registered wins.
90#[derive(Default)]
91pub struct BotBuilder {
92    /// Command name -> handler. Also keeps registration order for `commands()`.
93    commands: HashMap<String, CommandEntry>,
94    /// Button ids without a wildcard, matched by equality.
95    buttons: HashMap<String, AnyHandler>,
96    /// Button patterns ending in `*`, matched by prefix in registration order.
97    button_prefixes: Vec<(String, AnyHandler)>,
98    /// Catch-all message handler.
99    message: Option<AnyHandler>,
100    /// Handler for events no registered route claimed.
101    fallback: Option<AnyHandler>,
102}
103
104struct CommandEntry {
105    handler: AnyHandler,
106    description: Option<String>,
107    /// Registration index, so `commands()` can yield a stable order.
108    order: usize,
109}
110
111impl BotBuilder {
112    /// Create a new bot builder
113    pub fn new() -> Self {
114        Self::default()
115    }
116
117    /// Register a command handler
118    ///
119    /// Handlers use the extractor/responder pattern:
120    /// ```ignore
121    /// // Simple handler
122    /// async fn ping() -> &'static str {
123    ///     "Pong!"
124    /// }
125    ///
126    /// // With extractors
127    /// async fn greet(user: User) -> String {
128    ///     format!("Hello, {}!", user.name)
129    /// }
130    ///
131    /// bot.command("ping", ping)
132    ///    .command("greet", greet)
133    /// ```
134    pub fn command<H, Args>(self, name: impl Into<String>, handler: H) -> Self
135    where
136        H: IntoHandler<Args>,
137    {
138        self.insert_command(name.into(), None, handler.into_handler())
139    }
140
141    /// Register a command handler with a description
142    ///
143    /// The description is used for slash command menus (e.g., Telegram's /command list).
144    pub fn command_with_description<H, Args>(
145        self,
146        name: impl Into<String>,
147        description: impl Into<String>,
148        handler: H,
149    ) -> Self
150    where
151        H: IntoHandler<Args>,
152    {
153        self.insert_command(
154            name.into(),
155            Some(description.into()),
156            handler.into_handler(),
157        )
158    }
159
160    fn insert_command(
161        mut self,
162        name: String,
163        description: Option<String>,
164        handler: AnyHandler,
165    ) -> Self {
166        let order = self.commands.len();
167        self.commands.entry(name).or_insert(CommandEntry {
168            handler,
169            description,
170            order,
171        });
172        self
173    }
174
175    /// Register a button handler with pattern matching
176    ///
177    /// Pattern can end with `*` for prefix matching (e.g., "confirm_*").
178    /// Exact ids take priority over prefix patterns.
179    pub fn button<H, Args>(mut self, pattern: impl Into<String>, handler: H) -> Self
180    where
181        H: IntoHandler<Args>,
182    {
183        let pattern = pattern.into();
184        let handler = handler.into_handler();
185
186        match pattern.strip_suffix('*') {
187            Some(prefix) => self.button_prefixes.push((prefix.to_string(), handler)),
188            None => {
189                self.buttons.entry(pattern).or_insert(handler);
190            }
191        }
192        self
193    }
194
195    /// Register a catch-all message handler
196    ///
197    /// Only one message handler is used; later registrations are ignored.
198    pub fn message<H, Args>(mut self, handler: H) -> Self
199    where
200        H: IntoHandler<Args>,
201    {
202        self.message.get_or_insert_with(|| handler.into_handler());
203        self
204    }
205
206    /// Register a handler for events nothing else claimed
207    ///
208    /// Consulted after command, button, and message routing: an unregistered
209    /// command or an unmatched button reaches the fallback rather than being
210    /// dropped. Plain messages still prefer the [`message`](Self::message)
211    /// handler when one is registered.
212    ///
213    /// Only one fallback is used; later registrations are ignored.
214    pub fn fallback<H, Args>(mut self, handler: H) -> Self
215    where
216        H: IntoHandler<Args>,
217    {
218        self.fallback.get_or_insert_with(|| handler.into_handler());
219        self
220    }
221
222    /// Get all registered commands with their descriptions, in registration order
223    pub fn commands(&self) -> impl Iterator<Item = CommandInfo<'_>> {
224        let mut entries: Vec<_> = self.commands.iter().collect();
225        entries.sort_by_key(|(_, entry)| entry.order);
226        entries.into_iter().map(|(name, entry)| CommandInfo {
227            name: name.as_str(),
228            description: entry.description.as_deref().unwrap_or(""),
229        })
230    }
231
232    /// Whether any command handler is registered
233    pub fn has_commands(&self) -> bool {
234        !self.commands.is_empty()
235    }
236
237    /// Find the handler that should serve an event
238    ///
239    /// Commands and exact button ids resolve with a single hash lookup; only
240    /// wildcard button patterns fall back to a scan. An event no route claims
241    /// resolves to the [`fallback`](Self::fallback) handler, if any.
242    pub fn route(&self, event: Event<'_>) -> Option<&AnyHandler> {
243        let routed = match event {
244            Event::Command(name) => self.commands.get(name).map(|entry| &entry.handler),
245            Event::Button(id) => self.buttons.get(id).or_else(|| {
246                self.button_prefixes
247                    .iter()
248                    .find(|(prefix, _)| id.starts_with(prefix.as_str()))
249                    .map(|(_, handler)| handler)
250            }),
251            Event::Message => self.message.as_ref(),
252        };
253        routed.or(self.fallback.as_ref())
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use crate::Context;
261
262    async fn reply() -> &'static str {
263        "reply"
264    }
265
266    fn builder() -> BotBuilder {
267        BotBuilder::new()
268            .command("ping", reply)
269            .command_with_description("help", "Show help", reply)
270            .button("exact", reply)
271            .button("confirm_*", reply)
272            .message(reply)
273    }
274
275    #[test]
276    fn routes_commands_by_name() {
277        let builder = builder();
278        assert!(builder.route(Event::Command("ping")).is_some());
279        assert!(builder.route(Event::Command("help")).is_some());
280        assert!(builder.route(Event::Command("missing")).is_none());
281    }
282
283    #[test]
284    fn routes_buttons_exactly_then_by_prefix() {
285        let builder = builder();
286        assert!(builder.route(Event::Button("exact")).is_some());
287        assert!(builder.route(Event::Button("confirm_yes")).is_some());
288        assert!(builder.route(Event::Button("confirm_")).is_some());
289        assert!(builder.route(Event::Button("cancel")).is_none());
290    }
291
292    #[test]
293    fn command_and_button_namespaces_do_not_collide() {
294        let builder = builder();
295        assert!(builder.route(Event::Button("ping")).is_none());
296        assert!(builder.route(Event::Command("exact")).is_none());
297    }
298
299    #[test]
300    fn message_handler_is_a_catch_all() {
301        assert!(builder().route(Event::Message).is_some());
302        assert!(BotBuilder::new().route(Event::Message).is_none());
303    }
304
305    #[test]
306    fn fallback_catches_unrouted_events_only() {
307        // Without a fallback, unclaimed commands and buttons are dropped.
308        assert!(builder().route(Event::Command("unknown")).is_none());
309        assert!(builder().route(Event::Button("unmatched")).is_none());
310
311        let routed = builder().fallback(reply);
312        // Claimed routes still win.
313        assert!(routed.route(Event::Command("ping")).is_some());
314        assert!(routed.route(Event::Button("exact")).is_some());
315        // Unclaimed commands and buttons fall through to the fallback.
316        assert!(routed.route(Event::Command("unknown")).is_some());
317        assert!(routed.route(Event::Button("unmatched")).is_some());
318    }
319
320    #[test]
321    fn first_registration_wins() {
322        async fn first() -> &'static str {
323            "first"
324        }
325        async fn second() -> &'static str {
326            "second"
327        }
328
329        let builder = BotBuilder::new()
330            .command("dup", first)
331            .command("dup", second);
332        let handler = builder.route(Event::Command("dup")).unwrap().clone();
333        let response =
334            futures_lite::future::block_on(handler.call(Context::new(crate::test_util::StubData)));
335        assert_eq!(response.content(), Some("first"));
336    }
337
338    #[test]
339    fn commands_keep_registration_order_and_descriptions() {
340        let commands: Vec<_> = builder()
341            .commands()
342            .map(|c| (c.name.to_string(), c.description.to_string()))
343            .collect();
344        assert_eq!(
345            commands,
346            vec![
347                ("ping".to_string(), String::new()),
348                ("help".to_string(), "Show help".to_string()),
349            ]
350        );
351    }
352}