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
12pub fn reaction_button_id(emoji: &str) -> String {
18 format!("reaction:{emoji}")
19}
20
21pub struct MatrixContextData {
23 room: Room,
25 client: MatrixClient,
27 room_id: String,
29 user_id: String,
30 user_name: String,
31 command_name: Option<String>,
32 command_args: Option<String>,
33 button_id: Option<String>,
35 message_content: Option<String>,
36}
37
38impl MatrixContextData {
39 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 let user_name = event.sender.localpart().to_string();
51
52 let message_content = match &event.content.msgtype {
54 MessageType::Text(text) => Some(text.body.clone()),
55 _ => None,
56 };
57
58 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 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 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 pub fn room(&self) -> &Room {
127 &self.room
128 }
129
130 pub fn client(&self) -> &MatrixClient {
132 &self.client
133 }
134
135 pub fn command(&self) -> Option<&str> {
140 self.command_name.as_deref()
141 }
142
143 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 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}