bob_chat/event.rs
1//! Chat event types for adapter-to-bot communication.
2//!
3//! Events represent incoming payloads from chat adapters: messages, mentions,
4//! reactions, interactive actions, slash commands, and modal interactions.
5
6use std::collections::HashMap;
7
8use serde::{Deserialize, Serialize};
9
10use crate::{
11 emoji::EmojiValue,
12 message::{Author, IncomingMessage},
13};
14
15// ---------------------------------------------------------------------------
16// ActionEvent
17// ---------------------------------------------------------------------------
18
19/// An interactive element (button, menu, etc.) was activated by a user.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct ActionEvent {
22 /// Identifier of the action element that was triggered.
23 pub action_id: String,
24 /// Thread where the action originated.
25 pub thread_id: String,
26 /// Message containing the interactive element.
27 pub message_id: String,
28 /// The user who performed the action.
29 pub user: Author,
30 /// Optional value associated with the action element.
31 pub value: Option<String>,
32 /// Trigger identifier for opening modals or other follow-ups.
33 pub trigger_id: Option<String>,
34 /// Name of the adapter that delivered this event.
35 pub adapter_name: String,
36}
37
38// ---------------------------------------------------------------------------
39// ReactionEvent
40// ---------------------------------------------------------------------------
41
42/// A reaction was added to or removed from a message.
43#[derive(Debug, Clone)]
44pub struct ReactionEvent {
45 /// Thread containing the reacted message.
46 pub thread_id: String,
47 /// The message that was reacted to.
48 pub message_id: String,
49 /// The user who added or removed the reaction.
50 pub user: Author,
51 /// The emoji used for the reaction.
52 pub emoji: EmojiValue,
53 /// `true` when the reaction was added, `false` when removed.
54 pub added: bool,
55 /// Name of the adapter that delivered this event.
56 pub adapter_name: String,
57}
58
59// ---------------------------------------------------------------------------
60// SlashCommandEvent
61// ---------------------------------------------------------------------------
62
63/// A slash command was invoked by a user.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct SlashCommandEvent {
66 /// The command name (e.g. `/deploy`).
67 pub command: String,
68 /// Any text following the command name.
69 pub text: String,
70 /// Channel where the command was issued.
71 pub channel_id: String,
72 /// The user who invoked the command.
73 pub user: Author,
74 /// Trigger identifier for opening modals or other follow-ups.
75 pub trigger_id: Option<String>,
76 /// Name of the adapter that delivered this event.
77 pub adapter_name: String,
78}
79
80// ---------------------------------------------------------------------------
81// ModalSubmitEvent
82// ---------------------------------------------------------------------------
83
84/// A modal view was submitted by a user.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct ModalSubmitEvent {
87 /// Callback identifier used to route this submission.
88 pub callback_id: String,
89 /// Platform-assigned view identifier.
90 pub view_id: String,
91 /// The user who submitted the modal.
92 pub user: Author,
93 /// Key-value pairs from the modal's input elements.
94 pub values: HashMap<String, String>,
95 /// Opaque metadata string passed through from the modal open call.
96 pub private_metadata: Option<String>,
97 /// Name of the adapter that delivered this event.
98 pub adapter_name: String,
99}
100
101// ---------------------------------------------------------------------------
102// ModalCloseEvent
103// ---------------------------------------------------------------------------
104
105/// A modal view was closed (dismissed) by a user without submitting.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct ModalCloseEvent {
108 /// Callback identifier used to route this close event.
109 pub callback_id: String,
110 /// Platform-assigned view identifier.
111 pub view_id: String,
112 /// The user who closed the modal.
113 pub user: Author,
114 /// Name of the adapter that delivered this event.
115 pub adapter_name: String,
116}
117
118// ---------------------------------------------------------------------------
119// ChatEvent
120// ---------------------------------------------------------------------------
121
122/// Top-level event dispatched from a chat adapter to the bot.
123///
124/// Covers all supported interaction types: plain messages, mentions,
125/// reactions, interactive actions, slash commands, and modal lifecycle.
126#[derive(Debug, Clone)]
127pub enum ChatEvent {
128 /// A regular message was posted in a channel or thread.
129 Message {
130 /// Thread the message belongs to.
131 thread_id: String,
132 /// The received message.
133 message: IncomingMessage,
134 },
135 /// The bot was explicitly mentioned in a message.
136 Mention {
137 /// Thread the message belongs to.
138 thread_id: String,
139 /// The received message containing the mention.
140 message: IncomingMessage,
141 },
142 /// A reaction was added or removed.
143 Reaction(ReactionEvent),
144 /// An interactive element was activated.
145 Action(ActionEvent),
146 /// A slash command was invoked.
147 SlashCommand(SlashCommandEvent),
148 /// A modal was submitted.
149 ModalSubmit(ModalSubmitEvent),
150 /// A modal was closed without submitting.
151 ModalClose(ModalCloseEvent),
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 fn sample_author() -> Author {
159 Author {
160 user_id: "u1".into(),
161 user_name: "alice".into(),
162 full_name: "Alice".into(),
163 is_bot: false,
164 }
165 }
166
167 fn sample_incoming() -> IncomingMessage {
168 IncomingMessage {
169 id: "m1".into(),
170 text: "hello".into(),
171 author: sample_author(),
172 attachments: vec![],
173 is_mention: false,
174 thread_id: "t1".into(),
175 timestamp: None,
176 }
177 }
178
179 #[test]
180 fn chat_event_message_variant() {
181 let event = ChatEvent::Message { thread_id: "t1".into(), message: sample_incoming() };
182 assert!(matches!(event, ChatEvent::Message { .. }));
183 }
184
185 #[test]
186 fn chat_event_mention_variant() {
187 let event = ChatEvent::Mention { thread_id: "t1".into(), message: sample_incoming() };
188 assert!(matches!(event, ChatEvent::Mention { .. }));
189 }
190
191 #[test]
192 fn chat_event_action_variant() {
193 let event = ChatEvent::Action(ActionEvent {
194 action_id: "btn_approve".into(),
195 thread_id: "t1".into(),
196 message_id: "m1".into(),
197 user: sample_author(),
198 value: Some("yes".into()),
199 trigger_id: None,
200 adapter_name: "slack".into(),
201 });
202 assert!(matches!(event, ChatEvent::Action(_)));
203 }
204
205 #[test]
206 fn chat_event_slash_command_variant() {
207 let event = ChatEvent::SlashCommand(SlashCommandEvent {
208 command: "/deploy".into(),
209 text: "prod".into(),
210 channel_id: "c1".into(),
211 user: sample_author(),
212 trigger_id: None,
213 adapter_name: "slack".into(),
214 });
215 assert!(matches!(event, ChatEvent::SlashCommand(_)));
216 }
217
218 #[test]
219 fn chat_event_modal_submit_variant() {
220 let event = ChatEvent::ModalSubmit(ModalSubmitEvent {
221 callback_id: "feedback".into(),
222 view_id: "v1".into(),
223 user: sample_author(),
224 values: HashMap::from([("rating".into(), "5".into())]),
225 private_metadata: None,
226 adapter_name: "slack".into(),
227 });
228 assert!(matches!(event, ChatEvent::ModalSubmit(_)));
229 }
230
231 #[test]
232 fn chat_event_modal_close_variant() {
233 let event = ChatEvent::ModalClose(ModalCloseEvent {
234 callback_id: "feedback".into(),
235 view_id: "v1".into(),
236 user: sample_author(),
237 adapter_name: "slack".into(),
238 });
239 assert!(matches!(event, ChatEvent::ModalClose(_)));
240 }
241
242 #[test]
243 fn chat_event_clone_and_debug() {
244 let event = ChatEvent::Action(ActionEvent {
245 action_id: "a".into(),
246 thread_id: "t".into(),
247 message_id: "m".into(),
248 user: sample_author(),
249 value: None,
250 trigger_id: None,
251 adapter_name: "test".into(),
252 });
253 let cloned = event.clone();
254 // Verify Debug output is non-empty.
255 let dbg = format!("{cloned:?}");
256 assert!(!dbg.is_empty());
257 }
258}