Skip to main content

botkit_matrix/
bot.rs

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
16/// Matrix bot builder
17///
18/// Create a bot with command and reaction handlers, then call `run()` to start.
19///
20/// # Example
21/// ```ignore
22/// use botkit_matrix::{MatrixBot, MatrixConfig};
23/// use botkit_core::{Bot, User};
24///
25/// async fn ping() -> &'static str { "Pong!" }
26/// async fn greet(user: User) -> String { format!("Hello, {}!", user.name) }
27///
28/// let config = MatrixConfig::new("https://matrix.org")
29///     .password_auth("@bot:matrix.org", "password")
30///     .command_prefix("!");
31///
32/// MatrixBot::new(config)
33///     .command("ping", ping)
34///     .command("greet", greet)
35///     .run()
36///     .await
37///     .unwrap();
38/// ```
39pub struct MatrixBot {
40    config: MatrixConfig,
41    builder: BotBuilder,
42}
43
44impl MatrixBot {
45    /// Create a new Matrix bot with the given configuration
46    pub fn new(config: MatrixConfig) -> Self {
47        Self {
48            config,
49            builder: BotBuilder::new(),
50        }
51    }
52
53    /// Register a command handler (e.g., !ping, !help)
54    ///
55    /// Commands are parsed from message text using the configured prefix.
56    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    /// Register a command handler with description
65    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    /// Register a reaction handler
81    ///
82    /// Reactions are routed through the same table as buttons, under the id
83    /// `reaction:<emoji>`, so a single handler can serve a Discord button and a
84    /// Matrix reaction.
85    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    /// Register a message handler (for non-command messages)
96    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    /// Register a handler for events nothing else claimed
105    ///
106    /// Unregistered commands and unmatched reactions reach the fallback
107    /// rather than being dropped. See [`BotBuilder::fallback`].
108    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    /// Build and connect the Matrix client
117    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        // The first sync catches up on room state without replaying the entire
218        // backlog through the handlers registered above.
219        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        // `sync_with_callback` gives us a checkpoint between batches, which is
228        // where a shutdown request can take effect.
229        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                // Only act on invites addressed to this bot.
278                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
292/// Events from rooms we haven't joined, or from the bot itself, are noise.
293fn 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"))]
298/// Make sure rustls has a process-wide crypto provider before any TLS happens.
299///
300/// rustls only auto-detects a provider when exactly one is compiled in. A bot
301/// that talks to Matrix *and* Discord or Telegram pulls both `ring` and
302/// `aws-lc-rs` into the build, and rustls then refuses to guess — it panics on
303/// the first handshake. Installing one explicitly is what keeps a unified bot
304/// working; whichever adapter gets there first wins, and the rest are no-ops.
305fn install_crypto_provider() {
306    if rustls::crypto::CryptoProvider::get_default().is_none() {
307        // Fails only if another thread won the race, which is just as good.
308        let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
309    }
310}
311
312/// Shared, immutable state each dispatched event needs.
313struct 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    // The context owns the command parsing, so routing and the `CommandName`
325    // extractor can never disagree about where a command ends.
326    let data = MatrixContextData::from_message(
327        event,
328        room.clone(),
329        bot.client.clone(),
330        &bot.command_prefix,
331    );
332
333    // Not a text message at all.
334    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        // Matrix carries no caption on an attachment, so send it as its own
387        // message rather than dropping it.
388        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    // Matrix has no interactive components; render buttons as links so a
400    // response built for Discord still says something useful here.
401    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
412/// Render the parts of a response Matrix can only express as formatted text.
413///
414/// Returns `None` when plain text says everything the response carries.
415fn 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
479/// Collect the link buttons out of a component tree; the rest have no Matrix
480/// equivalent and are dropped.
481fn 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
503/// Append `s` to `out` with HTML metacharacters escaped, in a single pass.
504fn 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("&amp;"),
509            '<' => out.push_str("&lt;"),
510            '>' => out.push_str("&gt;"),
511            '"' => out.push_str("&quot;"),
512            '\'' => out.push_str("&#39;"),
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            "&lt;a href=&quot;x&quot;&gt;&amp;&#39;&lt;/a&gt;"
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("&lt;script&gt;"));
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            // Callback buttons have nothing to point at on Matrix.
576            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}