Skip to main content

teamcraft/
mattermost.rs

1//! Direct messages through the Mattermost REST API as a bot account.
2//!
3//! A notification goes to **one person only**: the bot looks the user up by
4//! username (`GET /api/v4/users/username/{name}`, cached), opens the direct
5//! channel bot <-> user (`POST /api/v4/channels/direct`) and posts into it
6//! (`POST /api/v4/posts`). Nothing is ever posted into a channel and there is
7//! no fallback to one: an unknown or deactivated user is a failure of the chat
8//! delivery with a reason the user reads under *Account → Notifications*.
9//!
10//! The server URL and the bot token are set by the administrator (not by
11//! users), the HTTP client follows no redirects (the bearer token must not
12//! travel to another host) and ignores proxy environment variables.
13//!
14//! [`classify`] and [`dm_text`] are pure and unit tested; [`BotClient`] is the
15//! one network implementation of [`DirectMessenger`].
16
17use std::{
18    collections::HashMap,
19    sync::Mutex,
20    time::{Duration, Instant},
21};
22
23use serde_json::{json, Value};
24use tokio::sync::OnceCell;
25
26use crate::delivery::BoxFuture;
27
28/// How long a user lookup (and the direct channel) is reused. A user who is
29/// deactivated meanwhile is noticed by the next lookup after this time (or
30/// by Mattermost refusing the post).
31const CACHE_TTL: Duration = Duration::from_secs(600);
32/// Mattermost refuses posts above 16383 characters.
33const MAX_MESSAGE_CHARS: usize = 15_000;
34
35/// Why a direct message was not delivered.
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct ChatFailure {
38    /// Shown to the user (no secrets).
39    pub message: String,
40    /// `true`: retrying cannot help until somebody changes something (unknown
41    /// or deactivated user); the delivery is marked failed at once.
42    pub permanent: bool,
43}
44
45impl ChatFailure {
46    pub fn transient(message: impl Into<String>) -> ChatFailure {
47        ChatFailure {
48            message: message.into(),
49            permanent: false,
50        }
51    }
52
53    pub fn permanent(message: impl Into<String>) -> ChatFailure {
54        ChatFailure {
55            message: message.into(),
56            permanent: true,
57        }
58    }
59}
60
61impl std::fmt::Display for ChatFailure {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        f.write_str(&self.message)
64    }
65}
66
67impl From<anyhow::Error> for ChatFailure {
68    fn from(e: anyhow::Error) -> ChatFailure {
69        ChatFailure::transient(e.to_string())
70    }
71}
72
73/// Sends a text as a direct message to a Mattermost user (the bot in
74/// production, a recorder in tests).
75pub trait DirectMessenger: Send + Sync {
76    fn send(&self, username: &str, text: &str) -> BoxFuture<'_, Result<(), ChatFailure>>;
77}
78
79/// The API call a failure belongs to.
80#[derive(Clone, Copy, Debug, PartialEq, Eq)]
81pub enum Step {
82    /// `GET /api/v4/users/username/{name}` (the person).
83    LookupUser,
84    /// `GET /api/v4/users/me` (the bot itself).
85    LookupBot,
86    /// `POST /api/v4/channels/direct`.
87    OpenChannel,
88    /// `POST /api/v4/posts`.
89    Post,
90}
91
92/// Maps an HTTP status of a failed call to what the user (and the retry
93/// logic) should learn.
94pub fn classify(step: Step, status: u16, username: &str) -> ChatFailure {
95    match (step, status) {
96        (Step::LookupUser, 404) => ChatFailure::permanent(format!(
97            "Mattermost user \"{username}\" not found – check your Mattermost username"
98        )),
99        (_, 401) => ChatFailure::transient(
100            "Mattermost rejected the bot token (HTTP 401) – the administrator must check TEAMCRAFT_MATTERMOST_BOT_TOKEN",
101        ),
102        (_, 403) => ChatFailure::transient(format!(
103            "the Mattermost bot may not do this (HTTP 403) – the administrator must check its permissions for \"{username}\""
104        )),
105        (_, 429) => ChatFailure::transient("Mattermost is rate limiting the bot (HTTP 429)"),
106        (_, code) => ChatFailure::transient(format!("Mattermost answered HTTP {code}")),
107    }
108}
109
110/// The failure for a user that exists but is deactivated (`delete_at` set).
111pub fn deactivated(username: &str) -> ChatFailure {
112    ChatFailure::permanent(format!(
113        "Mattermost user \"{username}\" is deactivated – check your Mattermost username"
114    ))
115}
116
117/// Text of the direct message: the title links to the object.
118pub fn dm_text(title: &str, body: &str, url: &str) -> String {
119    let text = if body.is_empty() {
120        format!("**[{title}]({url})**")
121    } else {
122        format!("**[{title}]({url})**\n{body}")
123    };
124    if text.chars().count() > MAX_MESSAGE_CHARS {
125        let mut cut: String = text.chars().take(MAX_MESSAGE_CHARS).collect();
126        cut.push('…');
127        cut
128    } else {
129        text
130    }
131}
132
133struct Cached {
134    at: Instant,
135    user_id: String,
136    channel_id: Option<String>,
137}
138
139/// The Mattermost REST API as a bot.
140pub struct BotClient {
141    http: reqwest::Client,
142    base: reqwest::Url,
143    token: String,
144    bot_id: OnceCell<String>,
145    cache: Mutex<HashMap<String, Cached>>,
146}
147
148impl BotClient {
149    pub fn new(url: &str, token: &str) -> anyhow::Result<BotClient> {
150        let base = reqwest::Url::parse(url)
151            .map_err(|e| anyhow::anyhow!("TEAMCRAFT_MATTERMOST_URL is not usable: {e}"))?;
152        if !matches!(base.scheme(), "http" | "https") || base.cannot_be_a_base() {
153            anyhow::bail!("TEAMCRAFT_MATTERMOST_URL must be an http(s) URL");
154        }
155        let http = reqwest::Client::builder()
156            .timeout(Duration::from_secs(10))
157            .connect_timeout(Duration::from_secs(5))
158            .redirect(reqwest::redirect::Policy::none())
159            .no_proxy()
160            .user_agent(concat!("TeamCraft-Mattermost/", env!("CARGO_PKG_VERSION")))
161            .build()?;
162        Ok(BotClient {
163            http,
164            base,
165            token: token.to_string(),
166            bot_id: OnceCell::new(),
167            cache: Mutex::new(HashMap::new()),
168        })
169    }
170
171    /// `<base>/api/v4/<segments…>`; the segments are percent-encoded (a
172    /// username cannot add path parts). A server below a path prefix works.
173    fn endpoint(&self, segments: &[&str]) -> reqwest::Url {
174        let mut url = self.base.clone();
175        if let Ok(mut path) = url.path_segments_mut() {
176            path.pop_if_empty().extend(["api", "v4"]).extend(segments);
177        }
178        url
179    }
180
181    async fn call(
182        &self,
183        step: Step,
184        username: &str,
185        request: reqwest::RequestBuilder,
186    ) -> Result<Value, ChatFailure> {
187        let response = request.bearer_auth(&self.token).send().await.map_err(|e| {
188            ChatFailure::transient(format!("Mattermost is not reachable: {}", short(&e)))
189        })?;
190        let status = response.status();
191        if !status.is_success() {
192            return Err(classify(step, status.as_u16(), username));
193        }
194        response
195            .json::<Value>()
196            .await
197            .map_err(|_| ChatFailure::transient("Mattermost sent an answer TeamCraft cannot read"))
198    }
199
200    async fn bot_id(&self) -> Result<String, ChatFailure> {
201        self.bot_id
202            .get_or_try_init(|| async {
203                let me = self
204                    .call(
205                        Step::LookupBot,
206                        "",
207                        self.http.get(self.endpoint(&["users", "me"])),
208                    )
209                    .await?;
210                id_of(&me)
211            })
212            .await
213            .cloned()
214    }
215
216    fn cached(&self, username: &str) -> Option<(String, Option<String>)> {
217        let cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());
218        cache
219            .get(username)
220            .filter(|c| c.at.elapsed() < CACHE_TTL)
221            .map(|c| (c.user_id.clone(), c.channel_id.clone()))
222    }
223
224    fn remember(&self, username: &str, user_id: &str, channel_id: Option<&str>) {
225        let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());
226        if cache.len() > 5000 {
227            cache.clear();
228        }
229        cache.insert(
230            username.to_string(),
231            Cached {
232                at: Instant::now(),
233                user_id: user_id.to_string(),
234                channel_id: channel_id.map(str::to_string),
235            },
236        );
237    }
238
239    fn forget(&self, username: &str) {
240        self.cache
241            .lock()
242            .unwrap_or_else(|e| e.into_inner())
243            .remove(username);
244    }
245
246    async fn deliver(&self, username: &str, text: &str) -> Result<(), ChatFailure> {
247        let (user_id, channel_id) = match self.cached(username) {
248            Some(hit) => hit,
249            None => {
250                let user = self
251                    .call(
252                        Step::LookupUser,
253                        username,
254                        self.http
255                            .get(self.endpoint(&["users", "username", username])),
256                    )
257                    .await?;
258                if user["delete_at"].as_i64().unwrap_or(0) > 0 {
259                    return Err(deactivated(username));
260                }
261                let id = id_of(&user)?;
262                self.remember(username, &id, None);
263                (id, None)
264            }
265        };
266        let channel_id = match channel_id {
267            Some(id) => id,
268            None => {
269                let bot = self.bot_id().await?;
270                let channel = self
271                    .call(
272                        Step::OpenChannel,
273                        username,
274                        self.http
275                            .post(self.endpoint(&["channels", "direct"]))
276                            .json(&json!([bot, user_id])),
277                    )
278                    .await?;
279                let id = id_of(&channel)?;
280                self.remember(username, &user_id, Some(&id));
281                id
282            }
283        };
284        self.call(
285            Step::Post,
286            username,
287            self.http
288                .post(self.endpoint(&["posts"]))
289                .json(&json!({ "channel_id": channel_id, "message": text })),
290        )
291        .await?;
292        Ok(())
293    }
294}
295
296impl DirectMessenger for BotClient {
297    fn send(&self, username: &str, text: &str) -> BoxFuture<'_, Result<(), ChatFailure>> {
298        let (username, text) = (username.to_string(), text.to_string());
299        Box::pin(async move {
300            let result = self.deliver(&username, &text).await;
301            if result.is_err() {
302                // a changed account or channel is looked up again on the retry
303                self.forget(&username);
304            }
305            result
306        })
307    }
308}
309
310fn id_of(value: &Value) -> Result<String, ChatFailure> {
311    value["id"]
312        .as_str()
313        .filter(|id| !id.is_empty())
314        .map(str::to_string)
315        .ok_or_else(|| ChatFailure::transient("Mattermost sent an answer without an id"))
316}
317
318/// A transport error without the URL (it carries no secret, but is noise).
319fn short(e: &reqwest::Error) -> &'static str {
320    if e.is_timeout() {
321        "timeout"
322    } else if e.is_connect() {
323        "connection failed"
324    } else {
325        "request failed"
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn unknown_user_is_permanent_and_names_the_user() {
335        let f = classify(Step::LookupUser, 404, "xy");
336        assert!(f.permanent);
337        assert_eq!(
338            f.message,
339            "Mattermost user \"xy\" not found – check your Mattermost username"
340        );
341        assert!(deactivated("xy").permanent);
342    }
343
344    #[test]
345    fn configuration_problems_are_retried_and_explained() {
346        let f = classify(Step::Post, 401, "xy");
347        assert!(!f.permanent && f.message.contains("bot token"));
348        assert!(!classify(Step::OpenChannel, 403, "xy").permanent);
349        assert!(!classify(Step::Post, 429, "xy").permanent);
350        assert_eq!(
351            classify(Step::Post, 500, "xy").message,
352            "Mattermost answered HTTP 500"
353        );
354        // a 404 of another step is not "user not found"
355        assert!(!classify(Step::Post, 404, "xy").permanent);
356    }
357
358    #[test]
359    fn message_links_the_title_and_is_capped() {
360        assert_eq!(dm_text("T", "B", "https://x/y"), "**[T](https://x/y)**\nB");
361        assert_eq!(dm_text("T", "", "https://x/y"), "**[T](https://x/y)**");
362        let long = dm_text("T", &"x".repeat(20_000), "https://x/y");
363        assert!(long.chars().count() <= MAX_MESSAGE_CHARS + 1);
364    }
365
366    #[test]
367    fn endpoints_encode_the_username_and_keep_a_path_prefix() {
368        let c = BotClient::new("https://chat.example.org/mm/", "t").unwrap();
369        assert_eq!(
370            c.endpoint(&["users", "username", "a/b c"]).as_str(),
371            "https://chat.example.org/mm/api/v4/users/username/a%2Fb%20c"
372        );
373        assert!(BotClient::new("ftp://chat.example.org", "t").is_err());
374    }
375}