Skip to main content

axfive_matrix_dicebot/
bot.rs

1use crate::matrix::{Event, MessageContent, RoomEvent, SyncCommand, NoticeMessage};
2use crate::commands::parse_command;
3use reqwest::{Client, Url};
4use serde::{self, Deserialize, Serialize};
5use std::fs;
6use std::path::PathBuf;
7
8const USER_AGENT: &str =
9    "AxFive Matrix DiceBot/0.1.0 (+https://gitlab.com/Taywee/axfive-matrix-dicebot)";
10
11/// The "matrix" section of the config, which gives home server, login information, and etc.
12#[derive(Serialize, Deserialize, Debug)]
13pub struct MatrixConfig {
14    /// Your homeserver of choice, as an FQDN without scheme or path
15    pub home_server: String,
16
17    /// The next batch to grab.  This should be set automatically
18    #[serde(default)]
19    pub next_batch: Option<String>,
20
21    /// The transaction ID.  This should be set automatically
22    #[serde(default)]
23    pub txn_id: u64,
24    
25    /// The login table.  This may be set to whatever you wish, depending on your login method,
26    /// though multi-step logins (like challenge-based) won't work here.
27    pub login: toml::Value,
28}
29
30/// The base config, which is read from and written to by the bot
31#[derive(Serialize, Deserialize, Debug)]
32pub struct Config {
33    pub matrix: MatrixConfig,
34}
35
36/// The actual dicebot structure, which drives the entire operation.
37///
38/// This is the core of the dicebot program.
39pub struct DiceBot {
40    config_path: Option<PathBuf>,
41    config: Config,
42    access_token: String,
43    next_batch: Option<String>,
44    client: Client,
45    home_server: Url,
46    txn_id: u64,
47}
48
49#[derive(Deserialize, Debug)]
50struct LoginResponse {
51    access_token: String,
52}
53
54impl DiceBot {
55    /// Create a new dicebot from the given config path and config
56    pub async fn new(
57        config_path: Option<PathBuf>,
58        config: Config,
59    ) -> Result<Self, Box<dyn std::error::Error>> {
60        let home_server: Url = format!("https://{}", config.matrix.home_server).parse()?;
61        let client = Client::new();
62        let request = serde_json::to_string(&config.matrix.login)?;
63        let mut login_url = home_server.clone();
64        login_url.set_path("/_matrix/client/r0/login");
65        let response = client
66            .post(login_url)
67            .header("user-agent", USER_AGENT)
68            .body(request)
69            .send()
70            .await?;
71        let body: LoginResponse = serde_json::from_str(&response.text().await?)?;
72        let next_batch = config.matrix.next_batch.clone();
73        let txn_id = config.matrix.txn_id;
74        Ok(DiceBot {
75            home_server,
76            config_path,
77            client,
78            config,
79            access_token: body.access_token,
80            next_batch,
81            txn_id,
82        })
83    }
84
85    /// Create a new dicebot, storing the config path to write it out  
86    pub async fn from_path<P: Into<PathBuf>>(
87        config_path: P,
88    ) -> Result<Self, Box<dyn std::error::Error>> {
89        let config_path = config_path.into();
90        let config = {
91            let contents = fs::read_to_string(&config_path)?;
92            toml::from_str(&contents)?
93        };
94        DiceBot::new(Some(config_path), config).await
95    }
96
97    /// Build a url using the current home server and the given path, as well as appending the
98    /// access token
99    fn url<S: AsRef<str>>(&self, path: S, query: &[(&str, &str)]) -> Url {
100        let mut url = self.home_server.clone();
101        url.set_path(path.as_ref());
102        {
103            let mut query_pairs = url.query_pairs_mut();
104            query_pairs.append_pair("access_token", &self.access_token);
105
106            for pair in query.iter() {
107                query_pairs.append_pair(pair.0, pair.1);
108            }
109        }
110
111        url
112    }
113
114    /// Sync to the matrix homeserver, acting on events as necessary
115    pub async fn sync(&mut self) -> Result<(), Box<dyn std::error::Error>> {
116        let mut sync_url = self.url("/_matrix/client/r0/sync", &[("timeout", "30000")]);
117
118        // TODO: handle http 429
119        if let Some(since) = &self.next_batch {
120            sync_url.query_pairs_mut().append_pair("since", since);
121        }
122        let body = self
123            .client
124            .get(sync_url)
125            .header("user-agent", USER_AGENT)
126            .send()
127            .await?
128            .text()
129            .await?;
130        let sync: SyncCommand = serde_json::from_str(&body).unwrap();
131        // First join invited rooms
132        for room in sync.rooms.invite.keys() {
133            let join_url = self.url(format!("/_matrix/client/r0/rooms/{}/join", room), &[]);
134            self.client
135                .post(join_url)
136                .header("user-agent", USER_AGENT)
137                .send()
138                .await?;
139        }
140
141        for (room_id, room) in sync.rooms.join.iter() {
142            for event in &room.timeline.events {
143                if let Event::Room(RoomEvent {
144                    sender,
145                    event_id: _,
146                    content: MessageContent::Text(message),
147                    ..
148                }) = event
149                {
150                    let (plain, html): (String, String) = match parse_command(message.body()) {
151                        Ok(Some(command)) => {
152                            let command = command.execute();
153                            (command.plain().into(), command.html().into())
154                        },
155                        Ok(None) => continue,
156                        Err(e) => {
157                            let message = format!("Error parsing command: {}", e);
158                            let html_message = format!("<p><strong>{}</strong></p>", message);
159                            (message, html_message)
160                        },
161                    };
162
163                    let plain = format!("{}\n{}", sender, plain);
164                    let html = format!("<p>{}</p>\n{}", sender, html);
165
166                    let message = NoticeMessage {
167                        body: plain,
168                        format: Some("org.matrix.custom.html".into()),
169                        formatted_body: Some(html),
170                    };
171
172                    self.txn_id += 1;
173                    let send_url = self.url(format!("/_matrix/client/r0/rooms/{}/send/m.room.message/{}", room_id, self.txn_id), &[]);
174                    self.client
175                        .put(send_url)
176                        .header("user-agent", USER_AGENT)
177                        .body(serde_json::to_string(&message)?)
178                        .send()
179                        .await?;
180                }
181            }
182        }
183        self.next_batch = Some(sync.next_batch);
184        Ok(())
185    }
186
187    /// Log off of the matrix server, also writing out the config file if one was given in
188    /// construction
189    pub async fn logout(mut self) -> Result<(), Box<dyn std::error::Error>> {
190        let logout_url = self.url("/_matrix/client/r0/logout", &[]);
191        self.client
192            .post(logout_url)
193            .header("user-agent", USER_AGENT)
194            .body("{}")
195            .send()
196            .await?;
197
198        self.config.matrix.next_batch = self.next_batch;
199        self.config.matrix.txn_id = self.txn_id;
200
201        if let Some(config_path) = self.config_path {
202            let config = toml::to_string_pretty(&self.config)?;
203            fs::write(config_path, config)?;
204        }
205
206        Ok(())
207    }
208}