1use std::sync::Arc;
2
3use botkit_core::{Bot, BotBuilder, BotError, Context, Event, IntoHandler, Response, Shutdown};
4use matrix_sdk::config::SyncSettings;
5use matrix_sdk::ruma::api::client::session::get_login_types::v3::LoginType;
6use matrix_sdk::ruma::events::reaction::OriginalSyncReactionEvent;
7use matrix_sdk::ruma::events::room::member::StrippedRoomMemberEvent;
8use matrix_sdk::ruma::events::room::message::OriginalSyncRoomMessageEvent;
9use matrix_sdk::{Client, LoopCtrl, Room, RoomState};
10use tracing::{error, info, warn};
11
12use crate::client::MatrixClient;
13use crate::config::{MatrixAuth, MatrixConfig};
14use crate::event::{MatrixContextData, reaction_button_id};
15
16pub struct MatrixBot {
40 config: MatrixConfig,
41 builder: BotBuilder,
42}
43
44impl MatrixBot {
45 pub fn new(config: MatrixConfig) -> Self {
47 Self {
48 config,
49 builder: BotBuilder::new(),
50 }
51 }
52
53 pub fn command<H, Args>(mut self, name: impl Into<String>, handler: H) -> Self
57 where
58 H: IntoHandler<Args>,
59 {
60 self.builder = self.builder.command(name, handler);
61 self
62 }
63
64 pub fn command_with_description<H, Args>(
66 mut self,
67 name: impl Into<String>,
68 description: impl Into<String>,
69 handler: H,
70 ) -> Self
71 where
72 H: IntoHandler<Args>,
73 {
74 self.builder = self
75 .builder
76 .command_with_description(name, description, handler);
77 self
78 }
79
80 pub fn reaction<H, Args>(mut self, emoji: impl AsRef<str>, handler: H) -> Self
86 where
87 H: IntoHandler<Args>,
88 {
89 self.builder = self
90 .builder
91 .button(reaction_button_id(emoji.as_ref()), handler);
92 self
93 }
94
95 pub fn message<H, Args>(mut self, handler: H) -> Self
97 where
98 H: IntoHandler<Args>,
99 {
100 self.builder = self.builder.message(handler);
101 self
102 }
103
104 pub fn fallback<H, Args>(mut self, handler: H) -> Self
109 where
110 H: IntoHandler<Args>,
111 {
112 self.builder = self.builder.fallback(handler);
113 self
114 }
115
116 async fn build_client(&self) -> Result<Client, BotError> {
118 #[cfg(not(target_arch = "wasm32"))]
119 install_crypto_provider();
120
121 #[cfg(not(target_arch = "wasm32"))]
122 let client_builder = {
123 let client_builder = Client::builder().homeserver_url(&self.config.homeserver_url);
124
125 match &self.config.state_store_path {
126 Some(path) => client_builder.sqlite_store(path, None),
127 None => client_builder,
128 }
129 };
130
131 #[cfg(target_arch = "wasm32")]
132 let client_builder = Client::builder().homeserver_url(&self.config.homeserver_url);
133
134 let client = client_builder
135 .build()
136 .await
137 .map_err(|e| BotError::Connection(e.to_string()))?;
138
139 match &self.config.auth {
140 MatrixAuth::Password { user_id, password } => {
141 if user_id.is_empty() {
142 return Err(BotError::Auth(
143 "no credentials configured; call password_auth or access_token_auth"
144 .to_string(),
145 ));
146 }
147
148 let login_types = client
149 .matrix_auth()
150 .get_login_types()
151 .await
152 .map_err(|e| BotError::Auth(e.to_string()))?;
153
154 if !login_types
155 .flows
156 .iter()
157 .any(|f| matches!(f, LoginType::Password(_)))
158 {
159 return Err(BotError::Auth(
160 "Homeserver does not support password login".to_string(),
161 ));
162 }
163
164 let mut login = client.matrix_auth().login_username(user_id, password);
165 if let Some(device_name) = &self.config.device_name {
166 login = login.initial_device_display_name(device_name);
167 }
168
169 login.await.map_err(|e| BotError::Auth(e.to_string()))?;
170 info!("Logged in as {user_id}");
171 }
172 MatrixAuth::AccessToken {
173 user_id,
174 access_token,
175 device_id,
176 } => {
177 use matrix_sdk::authentication::matrix::MatrixSession;
178 use matrix_sdk::{SessionMeta, SessionTokens};
179
180 let session = MatrixSession {
181 meta: SessionMeta {
182 user_id: user_id.clone(),
183 device_id: device_id.clone(),
184 },
185 tokens: SessionTokens {
186 access_token: access_token.clone(),
187 refresh_token: None,
188 },
189 };
190
191 client
192 .restore_session(session)
193 .await
194 .map_err(|e| BotError::Auth(e.to_string()))?;
195
196 info!("Restored session for {user_id}");
197 }
198 }
199
200 Ok(client)
201 }
202}
203
204impl Bot for MatrixBot {
205 async fn run_until(self, shutdown: Shutdown) -> Result<(), BotError> {
206 let client = self.build_client().await?;
207 let matrix_client = MatrixClient::new(client.clone());
208
209 let bot = Arc::new(BotState {
210 builder: self.builder,
211 client: matrix_client,
212 command_prefix: self.config.command_prefix.clone(),
213 });
214
215 register_handlers(&client, &bot, self.config.auto_join_rooms);
216
217 info!("Starting initial sync...");
220 client
221 .sync_once(SyncSettings::default())
222 .await
223 .map_err(|e| BotError::Connection(e.to_string()))?;
224
225 info!("Matrix bot connected and syncing");
226
227 client
230 .sync_with_callback(SyncSettings::default(), |_| {
231 let shutdown = shutdown.clone();
232 async move {
233 if shutdown.is_shutdown() {
234 LoopCtrl::Break
235 } else {
236 LoopCtrl::Continue
237 }
238 }
239 })
240 .await
241 .map_err(|e| BotError::Connection(e.to_string()))?;
242
243 Ok(())
244 }
245}
246
247fn register_handlers(client: &Client, bot: &Arc<BotState>, auto_join_rooms: bool) {
248 let message_bot = Arc::clone(bot);
249 client.add_event_handler(move |event: OriginalSyncRoomMessageEvent, room: Room| {
250 let bot = Arc::clone(&message_bot);
251 async move {
252 if !is_actionable(&room, &event.sender) {
253 return;
254 }
255 if let Err(e) = handle_message(&bot, &event, room).await {
256 error!("Error handling message: {e}");
257 }
258 }
259 });
260
261 let reaction_bot = Arc::clone(bot);
262 client.add_event_handler(move |event: OriginalSyncReactionEvent, room: Room| {
263 let bot = Arc::clone(&reaction_bot);
264 async move {
265 if !is_actionable(&room, &event.sender) {
266 return;
267 }
268 if let Err(e) = handle_reaction(&bot, &event, room).await {
269 error!("Error handling reaction: {e}");
270 }
271 }
272 });
273
274 if auto_join_rooms {
275 client.add_event_handler(
276 |event: StrippedRoomMemberEvent, room: Room, client: Client| async move {
277 if client.user_id() != Some(&event.state_key) || room.state() != RoomState::Invited
279 {
280 return;
281 }
282
283 info!("Joining room {}", room.room_id());
284 if let Err(e) = room.join().await {
285 warn!("Failed to join room {}: {e}", room.room_id());
286 }
287 },
288 );
289 }
290}
291
292fn is_actionable(room: &Room, sender: &matrix_sdk::ruma::UserId) -> bool {
294 room.state() == RoomState::Joined && room.client().user_id() != Some(sender)
295}
296
297#[cfg(not(target_arch = "wasm32"))]
298fn install_crypto_provider() {
306 if rustls::crypto::CryptoProvider::get_default().is_none() {
307 let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
309 }
310}
311
312struct BotState {
314 builder: BotBuilder,
315 client: MatrixClient,
316 command_prefix: String,
317}
318
319async fn handle_message(
320 bot: &BotState,
321 event: &OriginalSyncRoomMessageEvent,
322 room: Room,
323) -> Result<(), BotError> {
324 let data = MatrixContextData::from_message(
327 event,
328 room.clone(),
329 bot.client.clone(),
330 &bot.command_prefix,
331 );
332
333 if data.message_text().is_none() {
335 return Ok(());
336 }
337
338 let event = match data.command() {
339 Some(command) => Event::Command(command),
340 None => Event::Message,
341 };
342
343 let Some(handler) = bot.builder.route(event).cloned() else {
344 return Ok(());
345 };
346
347 let response = handler.call(Context::new(data)).await;
348 send_response(&bot.client, &room, response).await
349}
350
351async fn handle_reaction(
352 bot: &BotState,
353 event: &OriginalSyncReactionEvent,
354 room: Room,
355) -> Result<(), BotError> {
356 let button_id = reaction_button_id(&event.content.relates_to.key);
357
358 let Some(handler) = bot.builder.route(Event::Button(&button_id)).cloned() else {
359 return Ok(());
360 };
361
362 let data = MatrixContextData::from_reaction(event, room.clone(), bot.client.clone());
363 let response = handler.call(Context::new(data)).await;
364 send_response(&bot.client, &room, response).await
365}
366
367async fn send_response(
368 client: &MatrixClient,
369 room: &Room,
370 mut response: Response,
371) -> Result<(), BotError> {
372 if response.is_empty() || response.is_acknowledge() {
373 return Ok(());
374 }
375
376 if let Some(file) = response.take_file() {
377 let filename = file.filename.as_deref().unwrap_or("file").to_owned();
378 let bytes = file
379 .file
380 .read()
381 .await
382 .map_err(|e| BotError::Other(format!("failed to read attachment: {e}")))?;
383
384 client.send_file(room, &filename, bytes).await?;
385
386 if let Some(caption) = file.caption {
389 client.send_message(room, &caption).await?;
390 }
391
392 return Ok(());
393 }
394
395 let content = response.content().unwrap_or("");
396 let embeds = response.embeds();
397 let components = response.components();
398
399 let html = render_html(content, embeds, components);
402
403 match html {
404 Some(html) => client.send_formatted_message(room, content, &html).await?,
405 None if !content.is_empty() => client.send_message(room, content).await?,
406 None => return Ok(()),
407 };
408
409 Ok(())
410}
411
412fn render_html(
416 text: &str,
417 embeds: &[botkit_core::types::Embed],
418 components: &[botkit_core::types::Component],
419) -> Option<String> {
420 let links = component_links(components);
421 if embeds.is_empty() && links.is_empty() {
422 return None;
423 }
424
425 let mut html = String::new();
426
427 if !text.is_empty() {
428 html.push_str("<p>");
429 escape_html_into(&mut html, text);
430 html.push_str("</p>");
431 }
432
433 for embed in embeds {
434 html.push_str("<blockquote>");
435
436 if let Some(title) = &embed.title {
437 html.push_str("<strong>");
438 escape_html_into(&mut html, title);
439 html.push_str("</strong><br/>");
440 }
441
442 if let Some(description) = &embed.description {
443 escape_html_into(&mut html, description);
444 html.push_str("<br/>");
445 }
446
447 for field in &embed.fields {
448 html.push_str("<em>");
449 escape_html_into(&mut html, &field.name);
450 html.push_str(":</em> ");
451 escape_html_into(&mut html, &field.value);
452 html.push_str("<br/>");
453 }
454
455 if let Some(footer) = &embed.footer {
456 html.push_str("<small>");
457 escape_html_into(&mut html, &footer.text);
458 html.push_str("</small>");
459 }
460
461 html.push_str("</blockquote>");
462 }
463
464 if !links.is_empty() {
465 html.push_str("<ul>");
466 for (label, url) in links {
467 html.push_str("<li><a href=\"");
468 escape_html_into(&mut html, url);
469 html.push_str("\">");
470 escape_html_into(&mut html, label);
471 html.push_str("</a></li>");
472 }
473 html.push_str("</ul>");
474 }
475
476 Some(html)
477}
478
479fn component_links(components: &[botkit_core::types::Component]) -> Vec<(&str, &str)> {
482 use botkit_core::types::Component;
483
484 fn collect<'a>(components: &'a [Component], out: &mut Vec<(&'a str, &'a str)>) {
485 for component in components {
486 match component {
487 Component::ActionRow(row) => collect(&row.components, out),
488 Component::Button(button) => {
489 if let Some(url) = &button.url {
490 out.push((button.label.as_str(), url.as_str()));
491 }
492 }
493 Component::SelectMenu(_) => {}
494 }
495 }
496 }
497
498 let mut links = Vec::new();
499 collect(components, &mut links);
500 links
501}
502
503fn escape_html_into(out: &mut String, s: &str) {
505 out.reserve(s.len());
506 for ch in s.chars() {
507 match ch {
508 '&' => out.push_str("&"),
509 '<' => out.push_str("<"),
510 '>' => out.push_str(">"),
511 '"' => out.push_str("""),
512 '\'' => out.push_str("'"),
513 _ => out.push(ch),
514 }
515 }
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521 use botkit_core::types::{ActionRow, Button, Component, Embed};
522
523 fn escape(s: &str) -> String {
524 let mut out = String::new();
525 escape_html_into(&mut out, s);
526 out
527 }
528
529 #[test]
530 fn escaping_covers_every_metacharacter() {
531 assert_eq!(
532 escape(r#"<a href="x">&'</a>"#),
533 "<a href="x">&'</a>"
534 );
535 }
536
537 #[test]
538 fn escaping_leaves_ordinary_text_alone() {
539 assert_eq!(escape("hello 🎉 world"), "hello 🎉 world");
540 }
541
542 #[test]
543 fn plain_text_needs_no_html() {
544 assert_eq!(render_html("hi", &[], &[]), None);
545 }
546
547 #[test]
548 fn embeds_render_as_blockquotes() {
549 let embed = Embed::new()
550 .title("Title")
551 .description("Body")
552 .field("Key", "Value", false)
553 .footer("Footer");
554
555 let html = render_html("Intro", std::slice::from_ref(&embed), &[]).expect("html");
556 assert_eq!(
557 html,
558 "<p>Intro</p><blockquote><strong>Title</strong><br/>Body<br/>\
559 <em>Key:</em> Value<br/><small>Footer</small></blockquote>"
560 );
561 }
562
563 #[test]
564 fn embed_content_is_escaped() {
565 let embed = Embed::new().description("<script>alert(1)</script>");
566 let html = render_html("", std::slice::from_ref(&embed), &[]).expect("html");
567 assert!(!html.contains("<script>"));
568 assert!(html.contains("<script>"));
569 }
570
571 #[test]
572 fn link_buttons_become_a_list() {
573 let components = vec![Component::ActionRow(ActionRow::buttons(vec![
574 Button::link("https://example.com", "Docs"),
575 Button::primary("noop", "Press"),
577 ]))];
578
579 let html = render_html("See:", &[], &components).expect("html");
580 assert_eq!(
581 html,
582 "<p>See:</p><ul><li><a href=\"https://example.com\">Docs</a></li></ul>"
583 );
584 }
585
586 #[test]
587 fn callback_only_components_need_no_html() {
588 let components = vec![Component::Button(Button::primary("noop", "Press"))];
589 assert_eq!(render_html("hi", &[], &components), None);
590 }
591
592 #[test]
593 fn reaction_ids_match_button_registration() {
594 assert_eq!(reaction_button_id("👍"), "reaction:👍");
595 }
596}