Skip to main content

bob_chat/
bot.rs

1//! Central orchestrator for chat event handling.
2//!
3//! [`ChatBot`] is the top-level coordinator.  Adapters are registered on it,
4//! and event handlers are added via type-safe builder methods.  When
5//! [`ChatBot::run`] is called, incoming events from all adapters are polled
6//! concurrently and dispatched to the matching handlers.
7
8use std::{future::Future, pin::Pin, sync::Arc};
9
10use tracing::{Instrument as _, debug, info};
11
12use crate::{
13    adapter::ChatAdapter,
14    emoji::EmojiValue,
15    error::ChatError,
16    event::{
17        ActionEvent, ChatEvent, ModalCloseEvent, ModalSubmitEvent, ReactionEvent, SlashCommandEvent,
18    },
19    message::IncomingMessage,
20    thread::ThreadHandle,
21};
22
23// ---------------------------------------------------------------------------
24// Handler type aliases
25// ---------------------------------------------------------------------------
26
27/// A boxed, type-erased async handler that receives a thread handle and an
28/// incoming message.
29pub(crate) type MentionHandler = Box<
30    dyn Fn(ThreadHandle, IncomingMessage) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync,
31>;
32
33/// Message handler paired with an optional substring pattern.
34pub(crate) type MessageHandler = (
35    Option<String>,
36    Box<
37        dyn Fn(ThreadHandle, IncomingMessage) -> Pin<Box<dyn Future<Output = ()> + Send>>
38            + Send
39            + Sync,
40    >,
41);
42
43/// Handler for messages in subscribed threads.
44pub(crate) type SubscribedMessageHandler = Box<
45    dyn Fn(ThreadHandle, IncomingMessage) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync,
46>;
47
48/// Action handler paired with an optional set of action-id filters.
49pub(crate) type ActionHandler = (
50    Option<Vec<String>>,
51    Box<
52        dyn Fn(ActionEvent, ThreadHandle) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync,
53    >,
54);
55
56/// Reaction handler paired with an optional set of emoji filters.
57pub(crate) type ReactionHandler = (
58    Option<Vec<EmojiValue>>,
59    Box<dyn Fn(ReactionEvent) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>,
60);
61
62/// Slash-command handler paired with an optional set of command filters.
63pub(crate) type SlashCommandHandler = (
64    Option<Vec<String>>,
65    Box<dyn Fn(SlashCommandEvent) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>,
66);
67
68/// Modal-submit handler paired with an optional set of callback-id filters.
69pub(crate) type ModalSubmitHandler = (
70    Option<Vec<String>>,
71    Box<dyn Fn(ModalSubmitEvent) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>,
72);
73
74/// Handler invoked when a modal is dismissed without submitting.
75pub(crate) type ModalCloseHandler =
76    Box<dyn Fn(ModalCloseEvent) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
77
78// ---------------------------------------------------------------------------
79// ChatBotConfig
80// ---------------------------------------------------------------------------
81
82/// Configuration knobs for [`ChatBot`].
83#[derive(Debug, Clone)]
84pub struct ChatBotConfig {
85    /// Minimum interval (ms) between streaming edits.
86    pub streaming_update_interval_ms: u64,
87    /// Placeholder text shown while a stream is initialising.
88    pub fallback_streaming_placeholder: String,
89}
90
91impl Default for ChatBotConfig {
92    fn default() -> Self {
93        Self { streaming_update_interval_ms: 500, fallback_streaming_placeholder: "...".into() }
94    }
95}
96
97// ---------------------------------------------------------------------------
98// ChatBot
99// ---------------------------------------------------------------------------
100
101/// Central hub that connects chat adapters to handler logic.
102///
103/// # Construction
104///
105/// ```rust,ignore
106/// let bot = ChatBot::new(ChatBotConfig::default());
107/// ```
108///
109/// Handlers are registered via the `on_*` builder methods.  Each method
110/// accepts an async closure (returning a `Future`) and stores it for later
111/// dispatch.
112pub struct ChatBot {
113    /// Registered chat platform adapters.
114    pub(crate) adapters: Vec<Box<dyn ChatAdapter>>,
115    /// Configuration.
116    pub(crate) config: ChatBotConfig,
117
118    // -- handler vecs -----------------------------------------------------
119    pub(crate) mention_handlers: Vec<MentionHandler>,
120    pub(crate) message_handlers: Vec<MessageHandler>,
121    pub(crate) subscribed_message_handlers: Vec<SubscribedMessageHandler>,
122    pub(crate) action_handlers: Vec<ActionHandler>,
123    pub(crate) reaction_handlers: Vec<ReactionHandler>,
124    pub(crate) slash_command_handlers: Vec<SlashCommandHandler>,
125    pub(crate) modal_submit_handlers: Vec<ModalSubmitHandler>,
126    pub(crate) modal_close_handlers: Vec<ModalCloseHandler>,
127
128    /// Threads that the bot is actively subscribed to for follow-up messages.
129    pub(crate) subscriptions: Arc<scc::HashMap<String, ()>>,
130}
131
132impl ChatBot {
133    /// Create a new `ChatBot` with the given configuration.
134    #[must_use]
135    pub fn new(config: ChatBotConfig) -> Self {
136        Self {
137            adapters: Vec::new(),
138            config,
139            mention_handlers: Vec::new(),
140            message_handlers: Vec::new(),
141            subscribed_message_handlers: Vec::new(),
142            action_handlers: Vec::new(),
143            reaction_handlers: Vec::new(),
144            slash_command_handlers: Vec::new(),
145            modal_submit_handlers: Vec::new(),
146            modal_close_handlers: Vec::new(),
147            subscriptions: Arc::new(scc::HashMap::new()),
148        }
149    }
150
151    // -----------------------------------------------------------------
152    // Adapter registration
153    // -----------------------------------------------------------------
154
155    /// Add a chat platform adapter.
156    pub fn add_adapter(&mut self, adapter: impl ChatAdapter + 'static) {
157        self.adapters.push(Box::new(adapter));
158    }
159
160    // -----------------------------------------------------------------
161    // Handler registration
162    // -----------------------------------------------------------------
163
164    /// Register a handler invoked when the bot is mentioned.
165    pub fn on_mention<F, Fut>(&mut self, handler: F)
166    where
167        F: Fn(ThreadHandle, IncomingMessage) -> Fut + Send + Sync + 'static,
168        Fut: Future<Output = ()> + Send + 'static,
169    {
170        self.mention_handlers.push(Box::new(move |t, m| Box::pin(handler(t, m))));
171    }
172
173    /// Register a handler for incoming messages.
174    ///
175    /// If `pattern` is `Some`, only messages whose text contains the
176    /// substring will trigger the handler.
177    pub fn on_message<F, Fut>(&mut self, pattern: Option<String>, handler: F)
178    where
179        F: Fn(ThreadHandle, IncomingMessage) -> Fut + Send + Sync + 'static,
180        Fut: Future<Output = ()> + Send + 'static,
181    {
182        self.message_handlers.push((pattern, Box::new(move |t, m| Box::pin(handler(t, m)))));
183    }
184
185    /// Register a handler for messages in threads the bot has subscribed to.
186    pub fn on_subscribed_message<F, Fut>(&mut self, handler: F)
187    where
188        F: Fn(ThreadHandle, IncomingMessage) -> Fut + Send + Sync + 'static,
189        Fut: Future<Output = ()> + Send + 'static,
190    {
191        self.subscribed_message_handlers.push(Box::new(move |t, m| Box::pin(handler(t, m))));
192    }
193
194    /// Register a handler for interactive action events.
195    ///
196    /// If `action_ids` is `Some`, only actions whose `action_id` is in the
197    /// list will trigger the handler.
198    pub fn on_action<F, Fut>(&mut self, action_ids: Option<Vec<String>>, handler: F)
199    where
200        F: Fn(ActionEvent, ThreadHandle) -> Fut + Send + Sync + 'static,
201        Fut: Future<Output = ()> + Send + 'static,
202    {
203        self.action_handlers.push((action_ids, Box::new(move |a, t| Box::pin(handler(a, t)))));
204    }
205
206    /// Register a handler for reaction events.
207    ///
208    /// If `emojis` is `Some`, only reactions matching one of the listed
209    /// emoji values will trigger the handler.
210    pub fn on_reaction<F, Fut>(&mut self, emojis: Option<Vec<EmojiValue>>, handler: F)
211    where
212        F: Fn(ReactionEvent) -> Fut + Send + Sync + 'static,
213        Fut: Future<Output = ()> + Send + 'static,
214    {
215        self.reaction_handlers.push((emojis, Box::new(move |r| Box::pin(handler(r)))));
216    }
217
218    /// Register a handler for slash-command events.
219    ///
220    /// If `commands` is `Some`, only commands whose name matches one of the
221    /// listed strings will trigger the handler.
222    pub fn on_slash_command<F, Fut>(&mut self, commands: Option<Vec<String>>, handler: F)
223    where
224        F: Fn(SlashCommandEvent) -> Fut + Send + Sync + 'static,
225        Fut: Future<Output = ()> + Send + 'static,
226    {
227        self.slash_command_handlers.push((commands, Box::new(move |s| Box::pin(handler(s)))));
228    }
229
230    /// Register a handler for modal-submit events.
231    ///
232    /// If `callback_ids` is `Some`, only submissions whose `callback_id`
233    /// matches one of the listed strings will trigger the handler.
234    pub fn on_modal_submit<F, Fut>(&mut self, callback_ids: Option<Vec<String>>, handler: F)
235    where
236        F: Fn(ModalSubmitEvent) -> Fut + Send + Sync + 'static,
237        Fut: Future<Output = ()> + Send + 'static,
238    {
239        self.modal_submit_handlers.push((callback_ids, Box::new(move |ms| Box::pin(handler(ms)))));
240    }
241
242    /// Register a handler for modal-close (dismiss) events.
243    pub fn on_modal_close<F, Fut>(&mut self, handler: F)
244    where
245        F: Fn(ModalCloseEvent) -> Fut + Send + Sync + 'static,
246        Fut: Future<Output = ()> + Send + 'static,
247    {
248        self.modal_close_handlers.push(Box::new(move |mc| Box::pin(handler(mc))));
249    }
250
251    // -----------------------------------------------------------------
252    // Event dispatch loop
253    // -----------------------------------------------------------------
254
255    /// Start the event dispatch loop.
256    ///
257    /// This method takes ownership of the stored adapters via `&mut self`,
258    /// polls each adapter's [`recv_event`](ChatAdapter::recv_event) in a
259    /// round-robin fashion using `futures_util::stream::select_all`, and
260    /// dispatches every incoming [`ChatEvent`] to the registered handlers.
261    ///
262    /// The loop terminates when **all** adapters return `None` (i.e. their
263    /// event sources are exhausted).
264    ///
265    /// # Errors
266    ///
267    /// Returns `Err(ChatError::Closed)` if no adapters have been registered.
268    pub async fn run(&mut self) -> Result<(), ChatError> {
269        use futures_util::stream::{self, StreamExt as _};
270
271        if self.adapters.is_empty() {
272            return Err(ChatError::Closed);
273        }
274
275        // Take ownership of adapters so we can get `&mut` refs for
276        // `recv_event`.  Each adapter becomes a stream of events.
277        let adapters = std::mem::take(&mut self.adapters);
278
279        // Build one stream per adapter using `futures_util::stream::unfold`.
280        // Each stream is boxed so it satisfies the `Unpin` bound required
281        // by `select_all`.
282        let streams: Vec<_> = adapters
283            .into_iter()
284            .map(|adapter| {
285                Box::pin(stream::unfold(adapter, |mut a| async move {
286                    let event = a.recv_event().await;
287                    event.map(|e| (e, a))
288                }))
289            })
290            .collect();
291
292        let mut merged = stream::select_all(streams);
293
294        info!("ChatBot event loop started");
295
296        while let Some(event) = merged.next().await {
297            self.dispatch_event(event).await;
298        }
299
300        info!("ChatBot event loop finished — all adapters exhausted");
301        Ok(())
302    }
303
304    // -----------------------------------------------------------------
305    // Internal dispatch
306    // -----------------------------------------------------------------
307
308    /// Route a single [`ChatEvent`] to the appropriate handlers.
309    async fn dispatch_event(&self, event: ChatEvent) {
310        match event {
311            ChatEvent::Mention { thread_id, message } => {
312                let span = tracing::info_span!("dispatch_mention", thread_id = %thread_id);
313                async {
314                    let is_subscribed = self.subscriptions.contains_async(&thread_id).await;
315
316                    if is_subscribed {
317                        debug!(
318                            thread_id = %thread_id,
319                            "mention in subscribed thread — routing to subscribed handlers"
320                        );
321                        self.dispatch_subscribed_message(thread_id, message).await;
322                    } else {
323                        debug!(
324                            thread_id = %thread_id,
325                            handler_count = self.mention_handlers.len(),
326                            "dispatching mention"
327                        );
328                        for handler in &self.mention_handlers {
329                            let handle = self.make_thread_handle(&thread_id);
330                            handler(handle, message.clone()).await;
331                        }
332                    }
333                }
334                .instrument(span)
335                .await;
336            }
337
338            ChatEvent::Message { thread_id, message } => {
339                let span = tracing::info_span!("dispatch_message", thread_id = %thread_id);
340                async {
341                    let is_subscribed = self.subscriptions.contains_async(&thread_id).await;
342
343                    if is_subscribed {
344                        debug!(
345                            thread_id = %thread_id,
346                            "message in subscribed thread — routing to subscribed handlers"
347                        );
348                        self.dispatch_subscribed_message(thread_id, message).await;
349                    } else {
350                        self.dispatch_message_handlers(&thread_id, message).await;
351                    }
352                }
353                .instrument(span)
354                .await;
355            }
356
357            ChatEvent::Reaction(reaction) => {
358                let span = tracing::info_span!(
359                    "dispatch_reaction",
360                    thread_id = %reaction.thread_id,
361                    emoji = %reaction.emoji
362                );
363                async {
364                    for (filter, handler) in &self.reaction_handlers {
365                        let should_fire = match filter {
366                            Some(emojis) => emojis.contains(&reaction.emoji),
367                            None => true,
368                        };
369                        if should_fire {
370                            handler(reaction.clone()).await;
371                        }
372                    }
373                }
374                .instrument(span)
375                .await;
376            }
377
378            ChatEvent::Action(action) => {
379                let span = tracing::info_span!(
380                    "dispatch_action",
381                    action_id = %action.action_id,
382                    thread_id = %action.thread_id
383                );
384                async {
385                    for (filter, handler) in &self.action_handlers {
386                        let should_fire = match filter {
387                            Some(ids) => ids.contains(&action.action_id),
388                            None => true,
389                        };
390                        if should_fire {
391                            let handle = self.make_thread_handle(&action.thread_id);
392                            handler(action.clone(), handle).await;
393                        }
394                    }
395                }
396                .instrument(span)
397                .await;
398            }
399
400            ChatEvent::SlashCommand(cmd) => {
401                let span = tracing::info_span!(
402                    "dispatch_slash_command",
403                    command = %cmd.command,
404                    channel_id = %cmd.channel_id
405                );
406                async {
407                    for (filter, handler) in &self.slash_command_handlers {
408                        let should_fire = match filter {
409                            Some(commands) => commands.contains(&cmd.command),
410                            None => true,
411                        };
412                        if should_fire {
413                            handler(cmd.clone()).await;
414                        }
415                    }
416                }
417                .instrument(span)
418                .await;
419            }
420
421            ChatEvent::ModalSubmit(submit) => {
422                let span = tracing::info_span!(
423                    "dispatch_modal_submit",
424                    callback_id = %submit.callback_id
425                );
426                async {
427                    for (filter, handler) in &self.modal_submit_handlers {
428                        let should_fire = match filter {
429                            Some(ids) => ids.contains(&submit.callback_id),
430                            None => true,
431                        };
432                        if should_fire {
433                            handler(submit.clone()).await;
434                        }
435                    }
436                }
437                .instrument(span)
438                .await;
439            }
440
441            ChatEvent::ModalClose(close) => {
442                let span = tracing::info_span!(
443                    "dispatch_modal_close",
444                    callback_id = %close.callback_id
445                );
446                async {
447                    for handler in &self.modal_close_handlers {
448                        handler(close.clone()).await;
449                    }
450                }
451                .instrument(span)
452                .await;
453            }
454        }
455    }
456
457    /// Dispatch to all subscribed-message handlers for the given thread.
458    async fn dispatch_subscribed_message(&self, thread_id: String, message: IncomingMessage) {
459        debug!(
460            thread_id = %thread_id,
461            handler_count = self.subscribed_message_handlers.len(),
462            "dispatching subscribed message"
463        );
464        for handler in &self.subscribed_message_handlers {
465            let handle = self.make_thread_handle(&thread_id);
466            handler(handle, message.clone()).await;
467        }
468    }
469
470    /// Dispatch to matching `on_message` handlers (substring filter).
471    async fn dispatch_message_handlers(&self, thread_id: &str, message: IncomingMessage) {
472        debug!(
473            thread_id = %thread_id,
474            handler_count = self.message_handlers.len(),
475            "dispatching message"
476        );
477        for (pattern, handler) in &self.message_handlers {
478            let should_fire = match pattern {
479                Some(pat) => message.text.contains(pat.as_str()),
480                None => true,
481            };
482            if should_fire {
483                let handle = self.make_thread_handle(thread_id);
484                handler(handle, message.clone()).await;
485            }
486        }
487    }
488
489    /// Build a [`ThreadHandle`] for the given thread id.
490    ///
491    /// **Note:** This requires at least one adapter to be present. During
492    /// `run()`, adapters have been moved out, so this method uses a
493    /// placeholder. The `ThreadHandle` itself is designed to work with
494    /// an `Arc<dyn ChatAdapter>` which is populated properly by the
495    /// dispatch layer in Task 3.3.
496    ///
497    /// For now the handle is created with the adapter field unset — this
498    /// will be resolved when `ThreadHandle` gets its method
499    /// implementations.
500    fn make_thread_handle(&self, thread_id: &str) -> ThreadHandle {
501        ThreadHandle {
502            thread_id: thread_id.to_owned(),
503            adapter: Arc::new(NullAdapter),
504            subscriptions: Arc::clone(&self.subscriptions),
505        }
506    }
507}
508
509// ---------------------------------------------------------------------------
510// NullAdapter — placeholder for ThreadHandle when adapter ref is unavailable
511// ---------------------------------------------------------------------------
512
513/// A minimal no-op adapter used as a placeholder in thread handles when
514/// the real adapter reference is not yet available.
515struct NullAdapter;
516
517#[async_trait::async_trait]
518impl ChatAdapter for NullAdapter {
519    fn name(&self) -> &'static str {
520        "null"
521    }
522
523    async fn post_message(
524        &self,
525        _thread_id: &str,
526        _message: &crate::message::AdapterPostableMessage,
527    ) -> Result<crate::message::SentMessage, ChatError> {
528        Err(ChatError::Closed)
529    }
530
531    async fn edit_message(
532        &self,
533        _thread_id: &str,
534        _message_id: &str,
535        _message: &crate::message::AdapterPostableMessage,
536    ) -> Result<crate::message::SentMessage, ChatError> {
537        Err(ChatError::Closed)
538    }
539
540    async fn delete_message(&self, _thread_id: &str, _message_id: &str) -> Result<(), ChatError> {
541        Err(ChatError::Closed)
542    }
543
544    fn render_card(&self, _card: &crate::card::CardElement) -> String {
545        String::new()
546    }
547
548    fn render_message(&self, _message: &crate::message::AdapterPostableMessage) -> String {
549        String::new()
550    }
551
552    async fn recv_event(&mut self) -> Option<ChatEvent> {
553        None
554    }
555}
556
557impl std::fmt::Debug for ChatBot {
558    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
559        f.debug_struct("ChatBot")
560            .field("adapters", &self.adapters.len())
561            .field("config", &self.config)
562            .field("mention_handlers", &self.mention_handlers.len())
563            .field("message_handlers", &self.message_handlers.len())
564            .field("subscribed_message_handlers", &self.subscribed_message_handlers.len())
565            .field("action_handlers", &self.action_handlers.len())
566            .field("reaction_handlers", &self.reaction_handlers.len())
567            .field("slash_command_handlers", &self.slash_command_handlers.len())
568            .field("modal_submit_handlers", &self.modal_submit_handlers.len())
569            .field("modal_close_handlers", &self.modal_close_handlers.len())
570            .finish_non_exhaustive()
571    }
572}
573
574// ---------------------------------------------------------------------------
575// Static assertions
576// ---------------------------------------------------------------------------
577
578// Ensure `ChatBot` is Send + Sync so it can be shared across async tasks.
579const _: () = {
580    const fn assert_send_sync<T: Send + Sync>() {}
581    assert_send_sync::<ChatBot>();
582};
583
584// ---------------------------------------------------------------------------
585// Tests
586// ---------------------------------------------------------------------------
587
588#[cfg(test)]
589mod tests {
590    use std::{
591        collections::VecDeque,
592        sync::{
593            Mutex,
594            atomic::{AtomicUsize, Ordering},
595        },
596    };
597
598    use super::*;
599    use crate::message::{AdapterPostableMessage, Author, IncomingMessage, SentMessage};
600
601    // ---------------------------------------------------------------
602    // Shared test MockAdapter
603    // ---------------------------------------------------------------
604
605    /// A mock adapter that yields a pre-loaded queue of events.
606    struct MockAdapter {
607        name: &'static str,
608        events: Mutex<VecDeque<ChatEvent>>,
609    }
610
611    impl MockAdapter {
612        fn new(name: &'static str, events: Vec<ChatEvent>) -> Self {
613            Self { name, events: Mutex::new(VecDeque::from(events)) }
614        }
615    }
616
617    #[async_trait::async_trait]
618    impl ChatAdapter for MockAdapter {
619        fn name(&self) -> &str {
620            self.name
621        }
622
623        async fn post_message(
624            &self,
625            _thread_id: &str,
626            _message: &AdapterPostableMessage,
627        ) -> Result<SentMessage, ChatError> {
628            Ok(SentMessage {
629                id: "m1".into(),
630                thread_id: "t1".into(),
631                adapter_name: self.name.into(),
632                raw: None,
633            })
634        }
635
636        async fn edit_message(
637            &self,
638            _thread_id: &str,
639            _message_id: &str,
640            _message: &AdapterPostableMessage,
641        ) -> Result<SentMessage, ChatError> {
642            Ok(SentMessage {
643                id: "m1".into(),
644                thread_id: "t1".into(),
645                adapter_name: self.name.into(),
646                raw: None,
647            })
648        }
649
650        async fn delete_message(
651            &self,
652            _thread_id: &str,
653            _message_id: &str,
654        ) -> Result<(), ChatError> {
655            Ok(())
656        }
657
658        fn render_card(&self, _card: &crate::card::CardElement) -> String {
659            String::new()
660        }
661
662        fn render_message(&self, _message: &AdapterPostableMessage) -> String {
663            String::new()
664        }
665
666        async fn recv_event(&mut self) -> Option<ChatEvent> {
667            let Ok(mut q) = self.events.lock() else {
668                return None;
669            };
670            q.pop_front()
671        }
672    }
673
674    // ---------------------------------------------------------------
675    // Test helpers
676    // ---------------------------------------------------------------
677
678    fn sample_author() -> Author {
679        Author {
680            user_id: "u1".into(),
681            user_name: "alice".into(),
682            full_name: "Alice".into(),
683            is_bot: false,
684        }
685    }
686
687    fn sample_message(text: &str) -> IncomingMessage {
688        IncomingMessage {
689            id: "m1".into(),
690            text: text.into(),
691            author: sample_author(),
692            attachments: vec![],
693            is_mention: false,
694            thread_id: "t1".into(),
695            timestamp: None,
696        }
697    }
698
699    // ---------------------------------------------------------------
700    // Registration tests (existing)
701    // ---------------------------------------------------------------
702
703    /// `ChatBot` must be `Send + Sync`.
704    #[test]
705    fn chatbot_is_send_sync() {
706        fn assert_send<T: Send>() {}
707        fn assert_sync<T: Sync>() {}
708        assert_send::<ChatBot>();
709        assert_sync::<ChatBot>();
710    }
711
712    #[test]
713    fn default_config() {
714        let cfg = ChatBotConfig::default();
715        assert_eq!(cfg.streaming_update_interval_ms, 500);
716        assert_eq!(cfg.fallback_streaming_placeholder, "...");
717    }
718
719    #[test]
720    fn new_chatbot_has_empty_handlers() {
721        let bot = ChatBot::new(ChatBotConfig::default());
722        assert!(bot.adapters.is_empty());
723        assert!(bot.mention_handlers.is_empty());
724        assert!(bot.message_handlers.is_empty());
725        assert!(bot.subscribed_message_handlers.is_empty());
726        assert!(bot.action_handlers.is_empty());
727        assert!(bot.reaction_handlers.is_empty());
728        assert!(bot.slash_command_handlers.is_empty());
729        assert!(bot.modal_submit_handlers.is_empty());
730        assert!(bot.modal_close_handlers.is_empty());
731    }
732
733    #[test]
734    fn register_mention_handler() {
735        let mut bot = ChatBot::new(ChatBotConfig::default());
736        bot.on_mention(|_thread, _msg| async {});
737        assert_eq!(bot.mention_handlers.len(), 1);
738    }
739
740    #[test]
741    fn register_message_handler_with_pattern() {
742        let mut bot = ChatBot::new(ChatBotConfig::default());
743        bot.on_message(Some("hello".into()), |_thread, _msg| async {});
744        assert_eq!(bot.message_handlers.len(), 1);
745        assert_eq!(bot.message_handlers[0].0.as_deref(), Some("hello"));
746    }
747
748    #[test]
749    fn register_message_handler_without_pattern() {
750        let mut bot = ChatBot::new(ChatBotConfig::default());
751        bot.on_message(None, |_thread, _msg| async {});
752        assert_eq!(bot.message_handlers.len(), 1);
753        assert!(bot.message_handlers[0].0.is_none());
754    }
755
756    #[test]
757    fn register_subscribed_message_handler() {
758        let mut bot = ChatBot::new(ChatBotConfig::default());
759        bot.on_subscribed_message(|_thread, _msg| async {});
760        assert_eq!(bot.subscribed_message_handlers.len(), 1);
761    }
762
763    #[test]
764    fn register_action_handler() {
765        let mut bot = ChatBot::new(ChatBotConfig::default());
766        bot.on_action(Some(vec!["btn_ok".into()]), |_action, _thread| async {});
767        assert_eq!(bot.action_handlers.len(), 1);
768    }
769
770    #[test]
771    fn register_reaction_handler() {
772        let mut bot = ChatBot::new(ChatBotConfig::default());
773        bot.on_reaction(None, |_reaction| async {});
774        assert_eq!(bot.reaction_handlers.len(), 1);
775    }
776
777    #[test]
778    fn register_slash_command_handler() {
779        let mut bot = ChatBot::new(ChatBotConfig::default());
780        bot.on_slash_command(Some(vec!["/deploy".into()]), |_cmd| async {});
781        assert_eq!(bot.slash_command_handlers.len(), 1);
782    }
783
784    #[test]
785    fn register_modal_submit_handler() {
786        let mut bot = ChatBot::new(ChatBotConfig::default());
787        bot.on_modal_submit(Some(vec!["feedback".into()]), |_submit| async {});
788        assert_eq!(bot.modal_submit_handlers.len(), 1);
789    }
790
791    #[test]
792    fn register_modal_close_handler() {
793        let mut bot = ChatBot::new(ChatBotConfig::default());
794        bot.on_modal_close(|_close| async {});
795        assert_eq!(bot.modal_close_handlers.len(), 1);
796    }
797
798    #[test]
799    fn register_multiple_handlers() {
800        let mut bot = ChatBot::new(ChatBotConfig::default());
801        bot.on_mention(|_t, _m| async {});
802        bot.on_mention(|_t, _m| async {});
803        bot.on_message(None, |_t, _m| async {});
804        bot.on_action(None, |_a, _t| async {});
805        assert_eq!(bot.mention_handlers.len(), 2);
806        assert_eq!(bot.message_handlers.len(), 1);
807        assert_eq!(bot.action_handlers.len(), 1);
808    }
809
810    // ---------------------------------------------------------------
811    // Dispatch loop tests
812    // ---------------------------------------------------------------
813
814    #[tokio::test]
815    async fn run_returns_closed_when_no_adapters() {
816        let mut bot = ChatBot::new(ChatBotConfig::default());
817        let result = bot.run().await;
818        assert!(matches!(result, Err(ChatError::Closed)));
819    }
820
821    #[tokio::test]
822    async fn run_returns_ok_when_adapter_exhausted() {
823        let mut bot = ChatBot::new(ChatBotConfig::default());
824        bot.add_adapter(MockAdapter::new("mock", vec![]));
825        let result = bot.run().await;
826        assert!(result.is_ok());
827    }
828
829    #[tokio::test]
830    async fn dispatch_mention_event() {
831        let counter = Arc::new(AtomicUsize::new(0));
832        let c = Arc::clone(&counter);
833
834        let mut bot = ChatBot::new(ChatBotConfig::default());
835        bot.on_mention(move |_thread, _msg| {
836            let c = Arc::clone(&c);
837            async move {
838                c.fetch_add(1, Ordering::SeqCst);
839            }
840        });
841        bot.add_adapter(MockAdapter::new(
842            "mock",
843            vec![ChatEvent::Mention {
844                thread_id: "t1".into(),
845                message: sample_message("hey @bot"),
846            }],
847        ));
848
849        bot.run().await.expect("run should succeed");
850        assert_eq!(counter.load(Ordering::SeqCst), 1);
851    }
852
853    #[tokio::test]
854    async fn dispatch_message_with_pattern() {
855        let counter = Arc::new(AtomicUsize::new(0));
856        let c = Arc::clone(&counter);
857
858        let mut bot = ChatBot::new(ChatBotConfig::default());
859        bot.on_message(Some("hello".into()), move |_thread, _msg| {
860            let c = Arc::clone(&c);
861            async move {
862                c.fetch_add(1, Ordering::SeqCst);
863            }
864        });
865        bot.add_adapter(MockAdapter::new(
866            "mock",
867            vec![
868                ChatEvent::Message {
869                    thread_id: "t1".into(),
870                    message: sample_message("hello world"),
871                },
872                ChatEvent::Message {
873                    thread_id: "t1".into(),
874                    message: sample_message("goodbye world"),
875                },
876            ],
877        ));
878
879        bot.run().await.expect("run should succeed");
880        // Only the first message matches "hello"
881        assert_eq!(counter.load(Ordering::SeqCst), 1);
882    }
883
884    #[tokio::test]
885    async fn dispatch_message_no_pattern_catches_all() {
886        let counter = Arc::new(AtomicUsize::new(0));
887        let c = Arc::clone(&counter);
888
889        let mut bot = ChatBot::new(ChatBotConfig::default());
890        bot.on_message(None, move |_thread, _msg| {
891            let c = Arc::clone(&c);
892            async move {
893                c.fetch_add(1, Ordering::SeqCst);
894            }
895        });
896        bot.add_adapter(MockAdapter::new(
897            "mock",
898            vec![
899                ChatEvent::Message { thread_id: "t1".into(), message: sample_message("anything") },
900                ChatEvent::Message { thread_id: "t1".into(), message: sample_message("else") },
901            ],
902        ));
903
904        bot.run().await.expect("run should succeed");
905        assert_eq!(counter.load(Ordering::SeqCst), 2);
906    }
907
908    #[tokio::test]
909    async fn subscribed_thread_routes_to_subscribed_handlers() {
910        let sub_counter = Arc::new(AtomicUsize::new(0));
911        let msg_counter = Arc::new(AtomicUsize::new(0));
912
913        let sc = Arc::clone(&sub_counter);
914        let mc = Arc::clone(&msg_counter);
915
916        let mut bot = ChatBot::new(ChatBotConfig::default());
917
918        // Pre-subscribe to thread "t1"
919        let _ = bot.subscriptions.insert_async("t1".into(), ()).await;
920
921        bot.on_subscribed_message(move |_thread, _msg| {
922            let sc = Arc::clone(&sc);
923            async move {
924                sc.fetch_add(1, Ordering::SeqCst);
925            }
926        });
927        bot.on_message(None, move |_thread, _msg| {
928            let mc = Arc::clone(&mc);
929            async move {
930                mc.fetch_add(1, Ordering::SeqCst);
931            }
932        });
933
934        bot.add_adapter(MockAdapter::new(
935            "mock",
936            vec![
937                ChatEvent::Message {
938                    thread_id: "t1".into(),
939                    message: sample_message("in subscribed thread"),
940                },
941                ChatEvent::Message {
942                    thread_id: "t2".into(),
943                    message: sample_message("in non-subscribed thread"),
944                },
945            ],
946        ));
947
948        bot.run().await.expect("run should succeed");
949        assert_eq!(sub_counter.load(Ordering::SeqCst), 1);
950        assert_eq!(msg_counter.load(Ordering::SeqCst), 1);
951    }
952
953    #[tokio::test]
954    async fn dispatch_action_with_filter() {
955        let counter = Arc::new(AtomicUsize::new(0));
956        let c = Arc::clone(&counter);
957
958        let mut bot = ChatBot::new(ChatBotConfig::default());
959        bot.on_action(Some(vec!["approve".into()]), move |_action, _thread| {
960            let c = Arc::clone(&c);
961            async move {
962                c.fetch_add(1, Ordering::SeqCst);
963            }
964        });
965        bot.add_adapter(MockAdapter::new(
966            "mock",
967            vec![
968                ChatEvent::Action(ActionEvent {
969                    action_id: "approve".into(),
970                    thread_id: "t1".into(),
971                    message_id: "m1".into(),
972                    user: sample_author(),
973                    value: None,
974                    trigger_id: None,
975                    adapter_name: "mock".into(),
976                }),
977                ChatEvent::Action(ActionEvent {
978                    action_id: "reject".into(),
979                    thread_id: "t1".into(),
980                    message_id: "m1".into(),
981                    user: sample_author(),
982                    value: None,
983                    trigger_id: None,
984                    adapter_name: "mock".into(),
985                }),
986            ],
987        ));
988
989        bot.run().await.expect("run should succeed");
990        // Only "approve" should fire
991        assert_eq!(counter.load(Ordering::SeqCst), 1);
992    }
993
994    #[tokio::test]
995    async fn dispatch_reaction_with_emoji_filter() {
996        let counter = Arc::new(AtomicUsize::new(0));
997        let c = Arc::clone(&counter);
998
999        let thumbs_up = EmojiValue::from_well_known(crate::emoji::WellKnownEmoji::ThumbsUp);
1000        let thumbs_down = EmojiValue::from_well_known(crate::emoji::WellKnownEmoji::ThumbsDown);
1001
1002        let mut bot = ChatBot::new(ChatBotConfig::default());
1003        bot.on_reaction(Some(vec![thumbs_up.clone()]), move |_reaction| {
1004            let c = Arc::clone(&c);
1005            async move {
1006                c.fetch_add(1, Ordering::SeqCst);
1007            }
1008        });
1009
1010        bot.add_adapter(MockAdapter::new(
1011            "mock",
1012            vec![
1013                ChatEvent::Reaction(ReactionEvent {
1014                    thread_id: "t1".into(),
1015                    message_id: "m1".into(),
1016                    user: sample_author(),
1017                    emoji: thumbs_up,
1018                    added: true,
1019                    adapter_name: "mock".into(),
1020                }),
1021                ChatEvent::Reaction(ReactionEvent {
1022                    thread_id: "t1".into(),
1023                    message_id: "m1".into(),
1024                    user: sample_author(),
1025                    emoji: thumbs_down,
1026                    added: true,
1027                    adapter_name: "mock".into(),
1028                }),
1029            ],
1030        ));
1031
1032        bot.run().await.expect("run should succeed");
1033        // Only thumbs_up should fire
1034        assert_eq!(counter.load(Ordering::SeqCst), 1);
1035    }
1036
1037    #[tokio::test]
1038    async fn dispatch_slash_command_with_filter() {
1039        let counter = Arc::new(AtomicUsize::new(0));
1040        let c = Arc::clone(&counter);
1041
1042        let mut bot = ChatBot::new(ChatBotConfig::default());
1043        bot.on_slash_command(Some(vec!["/deploy".into()]), move |_cmd| {
1044            let c = Arc::clone(&c);
1045            async move {
1046                c.fetch_add(1, Ordering::SeqCst);
1047            }
1048        });
1049        bot.add_adapter(MockAdapter::new(
1050            "mock",
1051            vec![
1052                ChatEvent::SlashCommand(SlashCommandEvent {
1053                    command: "/deploy".into(),
1054                    text: "prod".into(),
1055                    channel_id: "c1".into(),
1056                    user: sample_author(),
1057                    trigger_id: None,
1058                    adapter_name: "mock".into(),
1059                }),
1060                ChatEvent::SlashCommand(SlashCommandEvent {
1061                    command: "/help".into(),
1062                    text: "".into(),
1063                    channel_id: "c1".into(),
1064                    user: sample_author(),
1065                    trigger_id: None,
1066                    adapter_name: "mock".into(),
1067                }),
1068            ],
1069        ));
1070
1071        bot.run().await.expect("run should succeed");
1072        assert_eq!(counter.load(Ordering::SeqCst), 1);
1073    }
1074
1075    #[tokio::test]
1076    async fn dispatch_modal_submit_with_filter() {
1077        let counter = Arc::new(AtomicUsize::new(0));
1078        let c = Arc::clone(&counter);
1079
1080        let mut bot = ChatBot::new(ChatBotConfig::default());
1081        bot.on_modal_submit(Some(vec!["feedback".into()]), move |_submit| {
1082            let c = Arc::clone(&c);
1083            async move {
1084                c.fetch_add(1, Ordering::SeqCst);
1085            }
1086        });
1087        bot.add_adapter(MockAdapter::new(
1088            "mock",
1089            vec![
1090                ChatEvent::ModalSubmit(ModalSubmitEvent {
1091                    callback_id: "feedback".into(),
1092                    view_id: "v1".into(),
1093                    user: sample_author(),
1094                    values: std::collections::HashMap::new(),
1095                    private_metadata: None,
1096                    adapter_name: "mock".into(),
1097                }),
1098                ChatEvent::ModalSubmit(ModalSubmitEvent {
1099                    callback_id: "other".into(),
1100                    view_id: "v2".into(),
1101                    user: sample_author(),
1102                    values: std::collections::HashMap::new(),
1103                    private_metadata: None,
1104                    adapter_name: "mock".into(),
1105                }),
1106            ],
1107        ));
1108
1109        bot.run().await.expect("run should succeed");
1110        assert_eq!(counter.load(Ordering::SeqCst), 1);
1111    }
1112
1113    #[tokio::test]
1114    async fn dispatch_modal_close() {
1115        let counter = Arc::new(AtomicUsize::new(0));
1116        let c = Arc::clone(&counter);
1117
1118        let mut bot = ChatBot::new(ChatBotConfig::default());
1119        bot.on_modal_close(move |_close| {
1120            let c = Arc::clone(&c);
1121            async move {
1122                c.fetch_add(1, Ordering::SeqCst);
1123            }
1124        });
1125        bot.add_adapter(MockAdapter::new(
1126            "mock",
1127            vec![ChatEvent::ModalClose(ModalCloseEvent {
1128                callback_id: "feedback".into(),
1129                view_id: "v1".into(),
1130                user: sample_author(),
1131                adapter_name: "mock".into(),
1132            })],
1133        ));
1134
1135        bot.run().await.expect("run should succeed");
1136        assert_eq!(counter.load(Ordering::SeqCst), 1);
1137    }
1138
1139    #[tokio::test]
1140    async fn mention_in_subscribed_thread_routes_to_subscribed_handler() {
1141        let mention_counter = Arc::new(AtomicUsize::new(0));
1142        let sub_counter = Arc::new(AtomicUsize::new(0));
1143
1144        let mc = Arc::clone(&mention_counter);
1145        let sc = Arc::clone(&sub_counter);
1146
1147        let mut bot = ChatBot::new(ChatBotConfig::default());
1148
1149        // Pre-subscribe to thread "t1"
1150        let _ = bot.subscriptions.insert_async("t1".into(), ()).await;
1151
1152        bot.on_mention(move |_thread, _msg| {
1153            let mc = Arc::clone(&mc);
1154            async move {
1155                mc.fetch_add(1, Ordering::SeqCst);
1156            }
1157        });
1158        bot.on_subscribed_message(move |_thread, _msg| {
1159            let sc = Arc::clone(&sc);
1160            async move {
1161                sc.fetch_add(1, Ordering::SeqCst);
1162            }
1163        });
1164
1165        bot.add_adapter(MockAdapter::new(
1166            "mock",
1167            vec![ChatEvent::Mention {
1168                thread_id: "t1".into(),
1169                message: sample_message("hey @bot"),
1170            }],
1171        ));
1172
1173        bot.run().await.expect("run should succeed");
1174        // Should route to subscribed handler, not mention handler
1175        assert_eq!(mention_counter.load(Ordering::SeqCst), 0);
1176        assert_eq!(sub_counter.load(Ordering::SeqCst), 1);
1177    }
1178
1179    #[tokio::test]
1180    async fn multiple_adapters_all_events_processed() {
1181        let counter = Arc::new(AtomicUsize::new(0));
1182        let c = Arc::clone(&counter);
1183
1184        let mut bot = ChatBot::new(ChatBotConfig::default());
1185        bot.on_message(None, move |_thread, _msg| {
1186            let c = Arc::clone(&c);
1187            async move {
1188                c.fetch_add(1, Ordering::SeqCst);
1189            }
1190        });
1191        bot.add_adapter(MockAdapter::new(
1192            "adapter-a",
1193            vec![ChatEvent::Message { thread_id: "t1".into(), message: sample_message("from A") }],
1194        ));
1195        bot.add_adapter(MockAdapter::new(
1196            "adapter-b",
1197            vec![ChatEvent::Message { thread_id: "t2".into(), message: sample_message("from B") }],
1198        ));
1199
1200        bot.run().await.expect("run should succeed");
1201        assert_eq!(counter.load(Ordering::SeqCst), 2);
1202    }
1203}