1use 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
28const CACHE_TTL: Duration = Duration::from_secs(600);
32const MAX_MESSAGE_CHARS: usize = 15_000;
34
35#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct ChatFailure {
38 pub message: String,
40 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
73pub trait DirectMessenger: Send + Sync {
76 fn send(&self, username: &str, text: &str) -> BoxFuture<'_, Result<(), ChatFailure>>;
77}
78
79#[derive(Clone, Copy, Debug, PartialEq, Eq)]
81pub enum Step {
82 LookupUser,
84 LookupBot,
86 OpenChannel,
88 Post,
90}
91
92pub 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
110pub fn deactivated(username: &str) -> ChatFailure {
112 ChatFailure::permanent(format!(
113 "Mattermost user \"{username}\" is deactivated – check your Mattermost username"
114 ))
115}
116
117pub 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
139pub 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 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 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
318fn 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 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}