rdzobot 0.1.0

Modular, but monolithic Matrix bot
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
// SPDX-License-Identifier: AGPL-3.0-or-later
// SPDX-FileCopyrightText: 2025 Wojtek Porczyk <woju@hackerspace.pl>

//! The Rdzobot™
//!
//! # How to run the bot
//!
//! Because of how Matrix works, initialising the bot is tricky. After loading the bot, you need to
//! run one cycle of `/sync`, to get events from between last bot login and now. Only then you add
//! all handlers (using [`Rdzobot::load_modules()`]) and enter the sync loop. If you add handlers
//! before first sync, bot will react to past events, which is usually not what you want.

use std::collections::HashMap;
use std::io::Write;
use std::os::unix::ffi::OsStrExt;
use std::path::{
    Path,
    PathBuf,
};
use std::pin::Pin;
use std::sync::{
    Arc,
    Mutex,
    MutexGuard,
    RwLock,
};

use anyhow::{
    Context,
    anyhow,
};

use serde::de::Error;

use matrix_sdk::event_handler::{
    EventHandler,
    EventHandlerHandle,
    SyncEvent,
};
use matrix_sdk::ruma::{
    OwnedUserId,
    RoomId,
    UserId,
};
use matrix_sdk::{
    AuthSession,
    Client,
    Room,
    SendOutsideWasm,
};

use rusqlite;

use serde::Deserialize;
use serde::de::DeserializeOwned;

use tokio::task::JoinSet;

use crate::prelude::*;
use crate::utils;

#[allow(dead_code)]
const R_OK: i32 = 4;
#[allow(dead_code)]
const W_OK: i32 = 2;
#[allow(dead_code)]
const X_OK: i32 = 1;
#[allow(dead_code)]
const F_OK: i32 = 0;

fn access(pathname: &std::path::Path, mode: i32) -> bool {
    #[link(name = "c")]
    unsafe extern "C" {
        fn access(pathname: *const std::os::raw::c_char, mode: i32) -> u32;
    }
    let mut bytes: Vec<u8> = pathname.as_os_str().as_bytes().to_vec();
    bytes.push(0u8);
    let ptr = std::ffi::CStr::from_bytes_with_nul(bytes.as_slice())
        .unwrap_or_else(|_| panic!("&Path had wrong null terminators: {pathname:?}"))
        .as_ptr();
    let ret = unsafe { access(ptr, mode) };
    ret == 0
}

// Partial resolver for “XDG Base Directory Specification”, does not support $*_DIRS.
const XDG_PACKAGE: &str = "rdzobot";
fn xdg_dir_user(envvar: &str, path: &str) -> PathBuf {
    if let Ok(value) = std::env::var(envvar) {
        [value.as_str(), XDG_PACKAGE].iter().collect()
    } else {
        #[allow(deprecated)]
        [
            std::env::home_dir().unwrap(),
            path.into(),
            XDG_PACKAGE.into(),
        ]
        .iter()
        .collect()
    }
}


/// Global configuration for [`Rdzobot`], that is, excluding `mod` subtree, which gets parsed by
/// modules. If writing modules, you're free to query this structure using [`Rdzobot::config()`].
#[derive(Debug, Deserialize)]
pub struct Config {
    /// `!`-id of the bot (it's mandatory to specify it)
    pub user_id: String,

    /// Optional `@`-id of the bot owner, only who can perform certain actions like ordering bot
    /// to leave a&nbsp;room. If not specified, said actions are generally unavailable.
    pub owner: Option<OwnedUserId>,

    /// Path to TOML file with secrets. By default this is `secrets-@<user_id>.toml` in the same
    /// directory where config file was found.
    pub secrets: Option<PathBuf>,

    /// Timezone in which the bot operates. This affects multiple modules. Default is
    /// `Europe/Warsaw`.
    #[serde(default = "timezone_default")]
    #[serde(deserialize_with = "timezone_de")]
    pub timezone: chrono_tz::Tz,

    /// IP and port on which to listen (default: `0.0.0.0:5000`)
    #[serde(default = "web_listen_default")]
    pub web_listen: String,

    /// Configuration of individual modules.
    #[serde(default)]
    pub module: crate::ModConfig,
}

fn timezone_default() -> chrono_tz::Tz { chrono_tz::Europe::Warsaw }

fn timezone_de<'de, D>(deserializer: D) -> Result<chrono_tz::Tz, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let s: String = serde::Deserialize::deserialize(deserializer)?;
    s.parse().map_err(D::Error::custom)
}

fn web_listen_default() -> String { "0.0.0.0:5000".to_string() }


#[doc(hidden)]
pub trait HandlerFuture: Future<Output = anyhow::Result<()>> + Send + 'static {}
impl<T> HandlerFuture for T where T: Future<Output = anyhow::Result<()>> + Send + 'static {}

/*
 * TODO after https://github.com/rust-lang/rust/issues/63063, R = impl HandlerFuture
 */
type CommandHandler<R> =
    fn(clap::ArgMatches, OriginalSyncRoomMessageEvent, Client, Room, Rdzobot) -> R;
type StoredCommandHandler = Box<
    dyn Fn(
            clap::ArgMatches,
            OriginalSyncRoomMessageEvent,
            Client,
            Room,
            Rdzobot,
        ) -> Pin<Box<dyn HandlerFuture>>
        + Send
        + Sync
        + 'static,
>;

type RegexHandler<R> =
    fn(regex::Regex, String, OriginalSyncRoomMessageEvent, Client, Room, Rdzobot) -> R;
type StoredRegexHandler = Box<
    dyn Fn(
            regex::Regex,
            String,
            OriginalSyncRoomMessageEvent,
            Client,
            Room,
            Rdzobot,
        ) -> Pin<Box<dyn HandlerFuture>>
        + Send
        + Sync
        + 'static,
>;

/// Main bot struct. It's available as `Ctx<Rdzobot>` in `matrix_sdk` handlers and as one of the
/// arguments for command and regex handlers.
#[derive(Clone)]
pub struct Rdzobot {
    pub(crate) inner: Arc<RdzobotInner>,

    /// [`matrix_sdk::Client`] that this bot uses
    pub client: Client,
}

pub(crate) struct RdzobotInner {
    config: Config,
    secrets: RwLock<HashMap<(String, String), String>>,
    state_dir: PathBuf,
    sqlite: Mutex<rusqlite::Connection>,

    command: RwLock<clap::Command>,
    web_router: RwLock<axum::Router<Rdzobot>>,

    event_handlers: Mutex<Vec<EventHandlerHandle>>,
    command_handlers: RwLock<HashMap<String, StoredCommandHandler>>,
    regex_handlers: RwLock<Vec<(regex::Regex, StoredRegexHandler)>>,
}

/// Event handling and related stuff
impl Rdzobot {
    /// Add a&nbsp;handler for a&nbsp;command.
    ///
    /// If a&nbsp;message starts with `!`, the bot will interpret it as an explicit command. It
    /// will then look through all registered commands and try to parse it according to the rules
    /// the command specifier.
    ///
    /// Rdzobot uses [`clap`] for all command-related stuff. You can use both builder API and
    /// derive API (see `clap`'s documentation for explanation of these concepts). The first
    /// argument is a&nbsp;[`clap::Command`] constructed by the bot (one of its modules) and the
    /// second argument is a&nbsp;handler that will be called when the command is encountered and
    /// it's specified arguments are correctly parsed. The handler should be an `async fn` that
    /// returns [`anyhow::Result<()>`][`anyhow::Result`] and accepts the following arguments:
    ///
    /// * `arg_matches: clap::ArgMatches` — the parsed arguments; if you're doing clap derive API,
    ///   you can make it `mut` and construct your struct using `::from_arg_matches_mut()` (see
    ///   example below)
    /// * `event: OriginalSyncRoomMessageEvent` — the message event we're reacting to; apart from
    ///   the message body it contains various metadata you can use e.g. to make a&nbsp;reply
    ///   message
    /// * `client: Client` — common [`matrix_sdk::Client`]; you can use it to run more or less
    ///   arbitrary actions in response to the message
    /// * `room: Room` — an instance of [`matrix_sdk::Room`] representing the room where the
    ///   message was posted
    /// * `bot: Rdzobot` — a&nbsp;[`Rdzobot`] instance
    ///
    /// ```
    /// # use rdzobot::prelude::*;
    /// #[derive(clap::Parser)]
    /// #[command(name = "!hello")]
    /// #[command(about = "Greet a&nbsp;person or the whole world")]
    /// struct HelloArgs {
    ///     name: Option<String>,
    /// }
    ///
    /// # fn load(bot: Rdzobot) {
    /// bot.add_command(
    ///     HelloArgs::command(),
    ///     on_cmd_hello,
    /// );
    /// # }
    ///
    /// async fn on_cmd_hello(
    ///     mut arg_matches: clap::ArgMatches,
    ///     event: OriginalSyncRoomMessageEvent,
    ///     client: Client,
    ///     room: Room,
    ///     bot: Rdzobot,
    /// ) -> anyhow::Result<()> {
    ///     let args = HelloArgs::from_arg_matches_mut(&mut arg_matches).unwrap();
    ///     room.send(RoomMessageEventContent::notice_plain(
    ///         format!("Hello, {}!", args.name.unwrap_or("world".to_string())))).await?;
    ///     Ok(())
    /// }
    /// ```
    pub fn add_command(&self, cmd: clap::Command, handler: CommandHandler<impl HandlerFuture>) {
        let all_aliases = cmd.get_all_aliases().map(|i| i.to_string()).collect::<Vec<_>>();
        tracing::debug!(
            "adding command {}{}",
            cmd.get_name(),
            if all_aliases.is_empty() {
                "".to_string()
            } else {
                format!(" with aliases {}", all_aliases.join(" "))
            }
        );
        let mut command_handlers = self.inner.command_handlers.write().unwrap();

        let mut all_names: Vec<String> = Vec::new();
        all_names.push(cmd.get_name().to_string());
        all_names.extend(all_aliases);

        for alias in all_names.into_iter() {
            command_handlers.insert(
                alias,
                Box::new(move |arg_matches, event, client, room, bot| {
                    Box::pin(handler(arg_matches, event, client, room, bot))
                }),
            );
        }
        let mut command = self.inner.command.write().unwrap();
        *command = command.clone().subcommand(cmd);
    }

    /// Add a&nbsp;handler for regular expression.
    ///
    /// For every text message, the bot will evaluate the regex, and if it matches
    /// ([`regex::Regex::is_match()`]), the handler will be called once per message. The handler
    /// should be an `async fn` returning `anyhow::Result<()>` and accepting arguments:
    ///
    /// * `re: regex::Regex` — (a&nbsp;clone of) the [`regex::Regex`] object that matched the body
    ///   of the message
    /// * `body: String` — an&nbsp;owned copy of the message body
    /// * `event: OriginalSyncRoomMessageEvent` — the message event we're reacting to; apart from
    ///   the message body it contains various metadata you can use e.g. to make a&nbsp;reply
    ///   message
    /// * `client: Client` — common [`matrix_sdk::Client`]; you can use it to run more or less
    ///   arbitrary actions in response to the message
    /// * `room: Room` — an instance of [`matrix_sdk::Room`] representing the room where the
    ///   message was posted
    /// * `bot: Rdzobot` — a&nbsp;[`Rdzobot`] instance
    ///
    /// ```
    /// # use rdzobot::prelude::*;
    /// # fn load(mut bot: Rdzobot) {
    /// bot.add_regex(regex::Regex::new(r"^[Aa]la\s+ma\s+kota").unwrap(), on_regex);
    /// # }
    ///
    /// pub async fn on_regex(
    ///     re: regex::Regex,
    ///     body: String,
    ///     event: OriginalSyncRoomMessageEvent,
    ///     client: Client,
    ///     room: Room,
    ///     bot: Rdzobot,
    /// ) -> anyhow::Result<()> {
    ///     room.send(RoomMessageEventContent::notice_plain("Cześć, Ala!")).await?;
    ///     Ok(())
    /// }
    /// ```
    pub fn add_regex(&mut self, pattern: regex::Regex, handler: RegexHandler<impl HandlerFuture>) {
        self.inner.regex_handlers.write().unwrap().push((
            pattern,
            Box::new(move |re, body, event, client, room, bot| {
                Box::pin(handler(re, body, event, client, room, bot))
            }),
        ));
    }

    /// Add a&nbsp;handler for an event in plain [`matrix_sdk`] API.
    ///
    /// This function should be used instead of [`matrix_sdk::Client::add_event_handler`], so that
    /// the bot will be able to cache the removal handle. The handle will be used for unloading the
    /// handler from the SDK Client when reloading plugins (a&nbsp;feature that is not yet
    /// implemented in [`Rdzobot`] at the time of this writing).
    pub fn add_event_handler<Ev, Ctx, H>(&self, handler: H) -> EventHandlerHandle
    where
        Ev: SyncEvent + DeserializeOwned + SendOutsideWasm + 'static,
        H: EventHandler<Ev, Ctx>,
    {
        let handle = self.client.add_event_handler(handler);
        self.inner
            .event_handlers
            .lock()
            .expect("can't lock event_handlers")
            .push(handle.clone());
        handle
    }

    /// Add a&nbsp;handler for an event, but only for a&nbsp;particular room.
    ///
    /// This replaces [`matrix_sdk::Client::add_room_event_handler`] for the purpose of reloading
    /// plugin config (TBD at the time of this writing). See [`Self::add_event_handler`]
    /// documentation for more info.
    pub fn add_room_event_handler<Ev, Ctx, H>(
        &self,
        room_id: &RoomId,
        handler: H,
    ) -> EventHandlerHandle
    where
        Ev: SyncEvent + DeserializeOwned + SendOutsideWasm + 'static,
        H: EventHandler<Ev, Ctx>,
    {
        let handle = self.client.add_room_event_handler(room_id, handler);
        self.inner
            .event_handlers
            .lock()
            .expect("can't lock event_handlers")
            .push(handle.clone());
        handle
    }

    /// Add a set of routes to the web handler.
    pub fn merge_web_router(&self, router: axum::Router<Self>) {
        let mut web_router = self.inner.web_router.write().unwrap();
        *web_router = web_router.clone().merge(router);
    }
}

/// Constructing new instance and managing lifecycle
impl Rdzobot {
    /// Create a new bot, reading default config and loading the state. Requires that the bot is
    /// alreaddy logged in.
    pub async fn new() -> anyhow::Result<Self> {
        Self::new_from_config(Self::read_config().context("failed to read config file")?).await
    }

    /// Creates new bot from default config, but does not restore the session. Use this constructor
    /// if you want to perform login itself.
    pub async fn new_no_restore() -> anyhow::Result<Self> {
        Self::new_from_config_no_restore(Self::read_config().expect("failed to read config file"))
            .await
    }

    /// Creates a new bot from given config and restores the session.
    async fn new_from_config(config: Config) -> anyhow::Result<Self> {
        let bot = Self::new_from_config_no_restore(config).await?;

        let session_path = bot.session_path();
        if !session_path.exists() {
            return Err(anyhow!("no session file, did you log in?"));
        }
        let file = std::fs::File::open(session_path)?;
        let reader = std::io::BufReader::new(file);
        let session = AuthSession::Matrix(
            serde_json::from_reader(reader).expect("failed to parse session.json"),
        );

        bot.client.restore_session(session).await.expect("failed to restore session");
        Ok(bot)
    }

    /// Creates new instance (the constructor proper).
    async fn new_from_config_no_restore(config: Config) -> anyhow::Result<Self> {
        let user_id =
            <OwnedUserId>::try_from(config.user_id.as_str()).context("failed to parse user_id")?;

        let xdg_state_dir_user: PathBuf = [
            xdg_dir_user("XDG_STATE_HOME", ".local/state"),
            user_id.as_str().into(),
        ]
        .iter()
        .collect();
        let xdg_state_dir_system: PathBuf =
            ["/var/lib", XDG_PACKAGE, user_id.as_str()].iter().collect();

        let state_dir = {
            if xdg_state_dir_system.is_dir() && access(&xdg_state_dir_system, W_OK) {
                xdg_state_dir_system
            } else if xdg_state_dir_user.is_dir() {
                xdg_state_dir_user
            } else if std::fs::create_dir_all(&xdg_state_dir_system).is_ok() {
                xdg_state_dir_system
            } else if std::fs::create_dir_all(&xdg_state_dir_user).is_ok() {
                xdg_state_dir_user
            } else {
                return Err(anyhow!("failed to create state directory"));
            }
        };

        let client = matrix_sdk::Client::builder()
            .server_name(user_id.server_name())
            .sqlite_store(&state_dir, None)
            .build()
            .await
            .expect("failed to build matrix client");

        let mut sqlite_path = state_dir.clone();
        sqlite_path.push("rdzobot.sqlite3");

        let secrets = Self::load_secrets(&config.secrets)?;

        let this = Self {
            inner: Arc::new(RdzobotInner {
                config,
                secrets: RwLock::new(secrets),
                state_dir,
                sqlite: Mutex::new(
                    rusqlite::Connection::open(sqlite_path).context("failed to open sqlite")?,
                ),

                command: RwLock::new(
                    clap::Command::new("rdzobot")
                        .color(clap::ColorChoice::Never)
                        .multicall(true)
                        .disable_help_subcommand(true),
                ),
                web_router: RwLock::new(axum::Router::new()),

                event_handlers: Mutex::new(Vec::new()),
                command_handlers: RwLock::new(HashMap::new()),
                regex_handlers: RwLock::new(Vec::new()),
            }),

            client,
        };
        this.run_migrations()?;

        Ok(this)
    }

    /// Initialise SQLite database.
    fn run_migrations(&self) -> anyhow::Result<()> {
        Ok(self.sqlite().execute_batch(include_str!("../migrations/000_init.sql"))?)
    }

    /// Load all modules.
    pub fn load_modules(&self) {
        // global contexts (currently only the bot itself)
        self.client.add_event_handler_context(self.clone());

        // global commands (!help)
        self.add_command(clap::Command::new("!help"), on_cmd_help);

        crate::load_modules(self.clone());

        // last, add handler that services commands and regexes
        self.client.add_event_handler(on_room_message_command_or_regex);
    }

    /// Path to session.json (e.g. `~/.local/state/rdzobot/@<userid>/session.json`).
    fn session_path(&self) -> PathBuf { self.inner.state_dir.join("session.json") }

    /// Read config file and extract `[mod]` array.
    fn read_config() -> anyhow::Result<Config> {
        let xdg_config_dir_system: PathBuf = ["/etc", XDG_PACKAGE, "rdzobot.toml"].iter().collect();
        let mut xdg_config_dir_user = xdg_dir_user("XDG_CONFIG_HOME", ".config");
        xdg_config_dir_user.push("rdzobot.toml");

        for path in [xdg_config_dir_user, xdg_config_dir_system] {
            // TODO should only check for existence: if file exists, but can't be read, bail out
            if let Ok(contents) = std::fs::read_to_string(&path) {
                let mut config: Config = toml::from_str(&contents)?;
                if config.secrets.is_none() {
                    config.secrets = Some(
                        path.with_file_name(format!("secrets-{}.toml", config.user_id.as_str())),
                    );
                }
                return Ok(config);
            }
        }
        Err(anyhow!("failed to read config"))
    }

    fn load_secrets(path: &Option<PathBuf>) -> anyhow::Result<HashMap<(String, String), String>> {
        let mut secrets = HashMap::new();
        match path {
            None => {
                tracing::warn!("no path to secrets configured, no secrets available");
            }
            Some(path) => {
                if let Ok(contents) = std::fs::read_to_string(path) {
                    tracing::debug!("loading secrets from {}", path.display());
                    let table: toml::Table = toml::from_str(&contents)?;
                    for (group, value) in table.iter() {
                        for (iden, secret) in
                            value.as_table().ok_or(anyhow::anyhow!("malformed secrets"))?.iter()
                        {
                            secrets.insert(
                                (group.to_owned(), iden.to_owned()),
                                secret
                                    .as_str()
                                    .ok_or(anyhow::anyhow!("malformed secrets"))?
                                    .to_owned(),
                            );
                        }
                    }
                } else {
                    tracing::warn!("failed to load secrets from {}", path.display());
                }
            }
        }

        Ok(secrets)
    }


    /// Run the bot
    ///
    /// This method performs an initial sync, then loads modules and enters sync loop.
    /// It might return when there's an unrecoverable error with syncing.
    pub async fn run(&self) -> anyhow::Result<()> {
        let sync_settings = matrix_sdk::config::SyncSettings::default().filter(
            matrix_sdk::ruma::api::client::filter::FilterDefinition::with_lazy_loading().into(),
        );

        tracing::info!("initial sync");

        loop {
            match self.client.sync_once(sync_settings.clone()).await {
                Ok(_) => {
                    break;
                }
                Err(err) => {
                    tracing::error!("error occured during initial sync: {err}; trying again");
                }
            }
        }

        self.load_modules();

        let listener =
            tokio::net::TcpListener::bind(self.config().web_listen.as_str()).await.unwrap();

        // TODO kill task when .run() is killed
        let _web_handle = tokio::spawn(
            axum::serve(
                listener,
                self.inner.web_router.read().unwrap().clone().with_state(self.clone()),
            )
            .into_future(),
        );

        loop {
            tracing::info!("starting sync");
            let err = self.client.sync(sync_settings.clone()).await.unwrap_err();
            match err {
                matrix_sdk::Error::Http(matrix_sdk::HttpError::Reqwest(e)) if e.is_timeout() => {
                    tracing::debug!("sync timed out, continuing");
                }
                _ => {
                    tracing::error!("sync error: {:?}", err);
                    return Err(err.into());
                }
            }
        }
    }

    /// Perform login to the server and save the token to local storage.
    pub async fn login(&self, password: &str) -> anyhow::Result<()> {
        let session_path = self.session_path();
        if session_path.exists() {
            return Err(anyhow!("already logged in at {}", self.inner.state_dir.display()));
        }

        let file = std::fs::File::create(&session_path).context("can't create session file")?;

        let matrix_auth = self.client.matrix_auth();
        matrix_auth
            .login_username(self.user_id()?, password)
            .initial_device_display_name("rdzobot")
            .send()
            .await
            .context("failed to login")?;

        let session = matrix_auth.session().expect("a logged-in client should have session?");
        let mut writer = std::io::BufWriter::new(file);
        serde_json::to_writer_pretty(&mut writer, &session)?;
        writer.write_all(b"\n")?;
        writer.flush()?;
        Ok(())
    }
}

/// Shared state and config
impl Rdzobot {
    /// A reference to global config
    pub fn config(&self) -> &Config { &self.inner.config }

    /// And instance of [`rusqlite::Connection`] guarded by [`MutexGuard`].
    pub fn sqlite(&self) -> MutexGuard<'_, rusqlite::Connection> {
        self.inner.sqlite.lock().unwrap()
    }

    /// Get a secret from secret storage.
    ///
    /// For operational security reasons the secrets are not stored in config, but in separate file
    /// (to avoid accidental committing them to a repository and/or copy-pasting when posting the
    /// config publicly).
    pub fn get_secret(&self, group: &str, iden: &str) -> Option<String> {
        self.inner
            .secrets
            .read()
            .unwrap()
            .get(&(group.to_string(), iden.to_string()))
            .cloned()
    }

    /// Own `@`-id, as configured.
    pub fn user_id(&self) -> anyhow::Result<&UserId> {
        <&UserId>::try_from(self.inner.config.user_id.as_str()).context("failed to parse user_id")
    }

    /// Path to state directory (e.g. `~/.local/state/rdzobot/@<userid>/`).
    pub fn state_dir(&self) -> &Path { &self.inner.state_dir }
}


/*
 * built-in commands and handlers
 */

/// Handles all `!commands` and all regexes. Implemented as handler for
/// [`matrix_sdk::Client::add_event_handler`]
async fn on_room_message_command_or_regex(
    event: OriginalSyncRoomMessageEvent,
    client: Client,
    room: Room,
    bot: Ctx<Rdzobot>,
) -> anyhow::Result<()> {
    let Some(body) = text_message_gate(&event, &client, &room) else {
        return Ok(());
    };

    // TODO make the prefix configurable
    if body.starts_with("!") {
        let args: Vec<_> = shlex::Shlex::new(&body).collect();

        let result = { bot.inner.command.read().unwrap().clone().try_get_matches_from_mut(args) };
        match result {
            Ok(mut matches) => {
                let (sub_name, sub_matches) = matches.remove_subcommand().unwrap();
                let fut = {
                    bot.inner.command_handlers.read().unwrap().get(&sub_name).unwrap()(
                        sub_matches,
                        event,
                        client,
                        room,
                        bot.0.clone(),
                    )
                };
                fut.await?;
            }
            Err(err) => match err.kind() {
                clap::error::ErrorKind::DisplayHelp => {
                    room.send(RoomMessageEventContent::notice_plain(format!("{}", err.render())))
                        .await?;
                }
                _ => {
                    tracing::debug!("error parsing command: {:?}", err);
                    utils::nie_zesraj_się(&event, &room).await?;
                }
            },
        }
    } else {
        let mut joins = JoinSet::new();

        for (re, handler) in bot.0.inner.regex_handlers.read().unwrap().iter() {
            if re.is_match(body.as_str()) {
                joins.spawn(handler(
                    re.clone(),
                    body.clone(),
                    event.clone(),
                    client.clone(),
                    room.clone(),
                    bot.0.clone(),
                ));
            }
        }

        while let Some(result) = joins.join_next().await {
            // this unwraps JoinError
            let out = result?;

            // this unwraps error from the handler
            if let Err(e) = out {
                tracing::error!("handler errored out: {}", e);
            }
        }
    }

    Ok(())
}

/// `!help` command
async fn on_cmd_help(
    _arg_matches: clap::ArgMatches,
    _event: OriginalSyncRoomMessageEvent,
    _client: Client,
    room: Room,
    bot: Rdzobot,
) -> anyhow::Result<()> {
    let mut names: Vec<String> = Vec::new();
    for subcommand in bot.inner.command.read().unwrap().get_subcommands() {
        let mut name = subcommand.get_name().to_string();
        let aliases_count = subcommand.get_visible_aliases().count();
        if aliases_count > 0 {
            name.push_str(
                format!(
                    " ({}: {})",
                    if aliases_count == 1 {
                        "alias"
                    } else {
                        "aliases"
                    },
                    subcommand.get_all_aliases().collect::<Vec<_>>().join(", "),
                )
                .as_str(),
            )
        }
        names.push(name);
    }

    names.sort();
    #[rustfmt::skip]
    room.send(RoomMessageEventContent::notice_plain(
        format!("Available commands: {}\nUse !<command> --help for more info", names.join(", "))
    )).await?;
    Ok(())
}


#[cfg(test)]
#[rustfmt::skip]
mod tests {
    use super::*;

    #[test]
    fn test_parse_empty_config() {
        assert!(toml::from_str::<Config>("").is_err());
    }

    // Run this with `cargo test --lib -- --nocapture` to see what you'll get from mostly empty
    // config
    #[test]
    fn test_parse_minimal_config() {
        dbg!(toml::from_str::<Config>("
            user_id = '@woju:hackerspace.pl'
        ").unwrap());
    }

    #[test]
    fn test_parse_timezone() {
        dbg!(toml::from_str::<Config>("
            user_id = '@woju:hackerspace.pl'
            timezone = 'Europe/Brussels'
        ").unwrap());
    }
}