Skip to main content

botkit_matrix/
event.rs

1use std::any::Any;
2
3use botkit_core::action::AnyChatActionSender;
4use botkit_core::{ContextData, OptionValue};
5use matrix_sdk::Room;
6use matrix_sdk::ruma::events::reaction::OriginalSyncReactionEvent;
7use matrix_sdk::ruma::events::room::message::{MessageType, OriginalSyncRoomMessageEvent};
8
9use crate::action::MatrixActionSender;
10use crate::client::MatrixClient;
11
12/// The button id a reaction is routed under.
13///
14/// Reactions share the button table so one handler can serve a Discord button
15/// and a Matrix reaction; keeping the id in one place stops registration and
16/// dispatch from drifting apart.
17pub fn reaction_button_id(emoji: &str) -> String {
18    format!("reaction:{emoji}")
19}
20
21/// Matrix context data - implements ContextData for platform abstraction
22pub struct MatrixContextData {
23    /// Room where the event occurred
24    room: Room,
25    /// Client for API calls
26    client: MatrixClient,
27    // Cached values
28    room_id: String,
29    user_id: String,
30    user_name: String,
31    command_name: Option<String>,
32    command_args: Option<String>,
33    /// For reactions mapped to buttons
34    button_id: Option<String>,
35    message_content: Option<String>,
36}
37
38impl MatrixContextData {
39    /// Create context from a room message event
40    pub fn from_message(
41        event: &OriginalSyncRoomMessageEvent,
42        room: Room,
43        client: MatrixClient,
44        command_prefix: &str,
45    ) -> Self {
46        let room_id = room.room_id().to_string();
47        let user_id = event.sender.to_string();
48
49        // Get display name (fallback to user_id localpart)
50        let user_name = event.sender.localpart().to_string();
51
52        // Extract message content
53        let message_content = match &event.content.msgtype {
54            MessageType::Text(text) => Some(text.body.clone()),
55            _ => None,
56        };
57
58        // Parse command from message
59        let (command_name, command_args) =
60            Self::parse_command(message_content.as_deref(), command_prefix);
61
62        Self {
63            room,
64            client,
65            room_id,
66            user_id,
67            user_name,
68            command_name,
69            command_args,
70            button_id: None,
71            message_content,
72        }
73    }
74
75    /// Create context from a reaction event
76    pub fn from_reaction(
77        event: &OriginalSyncReactionEvent,
78        room: Room,
79        client: MatrixClient,
80    ) -> Self {
81        let room_id = room.room_id().to_string();
82        let user_id = event.sender.to_string();
83        let user_name = event.sender.localpart().to_string();
84
85        let button_id = Some(reaction_button_id(&event.content.relates_to.key));
86
87        Self {
88            room,
89            client,
90            room_id,
91            user_id,
92            user_name,
93            command_name: None,
94            command_args: None,
95            button_id,
96            message_content: None,
97        }
98    }
99
100    /// Split `<prefix><name> <args>` out of a message body.
101    ///
102    /// An empty prefix would make every message a command, and a bare prefix
103    /// with no name is not a command either.
104    fn parse_command(text: Option<&str>, prefix: &str) -> (Option<String>, Option<String>) {
105        if prefix.is_empty() {
106            return (None, None);
107        }
108
109        let Some(rest) = text.and_then(|t| t.strip_prefix(prefix)) else {
110            return (None, None);
111        };
112
113        let mut parts = rest.splitn(2, char::is_whitespace);
114        let name = parts.next().unwrap_or_default();
115
116        if name.is_empty() {
117            return (None, None);
118        }
119
120        let args = parts.next().map(str::trim).filter(|a| !a.is_empty());
121
122        (Some(name.to_string()), args.map(str::to_string))
123    }
124
125    /// Get the Matrix Room for advanced operations
126    pub fn room(&self) -> &Room {
127        &self.room
128    }
129
130    /// Get the client for making API calls
131    pub fn client(&self) -> &MatrixClient {
132        &self.client
133    }
134
135    /// The command this message invokes, if any
136    ///
137    /// Parsed once at construction, so routing and the `CommandName` extractor
138    /// always agree.
139    pub fn command(&self) -> Option<&str> {
140        self.command_name.as_deref()
141    }
142
143    /// The message body, absent for non-text messages and reactions
144    pub fn message_text(&self) -> Option<&str> {
145        self.message_content.as_deref()
146    }
147}
148
149impl ContextData for MatrixContextData {
150    fn channel_id(&self) -> &str {
151        &self.room_id
152    }
153
154    fn user_id(&self) -> &str {
155        &self.user_id
156    }
157
158    fn user_name(&self) -> &str {
159        &self.user_name
160    }
161
162    fn command_name(&self) -> Option<&str> {
163        self.command_name.as_deref()
164    }
165
166    fn command_args(&self) -> Option<&str> {
167        self.command_args.as_deref()
168    }
169
170    fn option(&self, _name: &str) -> Option<OptionValue> {
171        // Matrix doesn't have structured options like Discord
172        None
173    }
174
175    fn button_id(&self) -> Option<&str> {
176        self.button_id.as_deref()
177    }
178
179    fn message_content(&self) -> Option<&str> {
180        self.message_content.as_deref()
181    }
182
183    fn as_any(&self) -> &dyn Any {
184        self
185    }
186
187    fn action_sender(&self) -> Option<AnyChatActionSender> {
188        Some(AnyChatActionSender::new(MatrixActionSender::new(
189            self.room.clone(),
190        )))
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::MatrixContextData;
197
198    fn parse(text: &str, prefix: &str) -> (Option<String>, Option<String>) {
199        MatrixContextData::parse_command(Some(text), prefix)
200    }
201
202    #[test]
203    fn parses_a_command_with_arguments() {
204        let (name, args) = parse("!greet world and beyond", "!");
205        assert_eq!(name.as_deref(), Some("greet"));
206        assert_eq!(args.as_deref(), Some("world and beyond"));
207    }
208
209    #[test]
210    fn parses_a_bare_command() {
211        assert_eq!(parse("!ping", "!"), (Some("ping".into()), None));
212    }
213
214    #[test]
215    fn trailing_whitespace_is_not_an_argument() {
216        assert_eq!(parse("!ping   ", "!"), (Some("ping".into()), None));
217    }
218
219    #[test]
220    fn multi_character_prefixes_work() {
221        assert_eq!(
222            parse(">>ping now", ">>"),
223            (Some("ping".into()), Some("now".into()))
224        );
225    }
226
227    #[test]
228    fn messages_without_the_prefix_are_not_commands() {
229        assert_eq!(parse("ping", "!"), (None, None));
230        assert_eq!(parse("hey !ping", "!"), (None, None));
231    }
232
233    #[test]
234    fn a_bare_prefix_is_not_a_command() {
235        assert_eq!(parse("!", "!"), (None, None));
236        assert_eq!(parse("! ping", "!"), (None, None));
237    }
238
239    #[test]
240    fn an_empty_prefix_does_not_make_everything_a_command() {
241        assert_eq!(parse("hello", ""), (None, None));
242    }
243
244    #[test]
245    fn non_ascii_prefixes_split_on_character_boundaries() {
246        assert_eq!(
247            parse("🤖ping now", "🤖"),
248            (Some("ping".into()), Some("now".into()))
249        );
250    }
251
252    #[test]
253    fn absent_text_is_not_a_command() {
254        assert_eq!(MatrixContextData::parse_command(None, "!"), (None, None));
255    }
256}