//! Mattermost's JSON, typed: the posts and users we read, the events that arrive over the //! WebSocket, and the requests we send over it. Mattermost's JSON is not ours: unknown fields are //! ignored, but every id we keep must be a valid id. pub mod rest; use std::time::Duration; use serde::Deserialize; use crate::config::valid_id; /// At most this many changed posts come back from one `posts?since` call (Mattermost v11.11.0, /// `SqlPostStore::GetPostsSince`); a full answer may have left some out. pub const SINCE_LIMIT: usize = 1000; #[derive(Debug)] pub enum MmError { /// No answer: connecting, TLS, or the HTTP exchange failed. Net(String), /// 401 or 403: the token is refused. Auth(u16), /// 429, still, after waiting as asked. RateLimited(Duration), /// Any other status that is not 2xx, with the start of the body. Status(u16, String), /// The answer is not the JSON we expect. Json(String), } impl std::fmt::Display for MmError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { MmError::Net(why) => write!(f, "{why}"), MmError::Auth(status) => write!(f, "Mattermost refused the token ({status})"), MmError::RateLimited(wait) => { write!(f, "rate limited; asked to wait {} s", wait.as_secs()) } // Quoted: the body is the server's text, and must not forge a log line. MmError::Status(status, body) => write!(f, "status {status}: {body:?}"), MmError::Json(why) => write!(f, "unexpected JSON: {why}"), } } } impl std::error::Error for MmError {} /// This bot, from `GET /users/me`. #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] pub struct Me { pub id: String, pub username: String, } /// The fields of a post that `gatewayd` uses. #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] pub struct Post { pub id: String, pub user_id: String, pub channel_id: String, #[serde(default)] pub root_id: String, #[serde(default)] pub message: String, pub create_at: i64, #[serde(default)] pub delete_at: i64, /// Empty for a message a user wrote; anything else is a system message. #[serde(default, rename = "type")] pub kind: String, } impl Post { /// Every id is a Mattermost id (the root may be empty): they end up in session ids and paths. pub(crate) fn check(self) -> Result { // id, user_id and channel_id must be `valid_id`, and root_id empty or `valid_id`; otherwise // Json("a post with an invalid id: "). if !valid_id(&self.id) || !valid_id(&self.user_id) || !valid_id(&self.channel_id) { return Err(MmError::Json(format!( "a post with an invalid id: {:?}", self.id ))); } if !self.root_id.is_empty() && !valid_id(&self.root_id) { return Err(MmError::Json(format!( "a post with an invalid id: {:?}", self.root_id ))); } Ok(self) } } /// An event from the WebSocket. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Event { Hello, /// A new post, and the type of its channel: `D` direct, `G` group, `O` open, `P` private. Posted { post: Post, channel_type: String, }, /// Any other event, or a reply to one of our requests, by its name (empty for a reply). Other(String), } #[derive(Deserialize)] struct RawEvent { #[serde(default)] event: String, #[serde(default)] data: serde_json::Map, } pub(crate) fn json(bytes: &[u8]) -> Result { // `serde_json::from_slice`; its error becomes Json(e.to_string()). serde_json::from_slice(bytes).map_err(|e| MmError::Json(e.to_string())) } /// One WebSocket text message as an event. `posted` carries the post as a JSON **string**. pub fn parse_event(text: &str) -> Result { // Parse into RawEvent. "hello" -> Hello. "posted" -> data.post must be a JSON *string*; parse // that string as a Post and `check` it (a missing or non-string post is Json). channel_type is // data.channel_type when it is a string, else "". Any other event name -> Other(name) (a reply // has no event: Other("")). let raw: RawEvent = json(text.as_bytes())?; match raw.event.as_str() { "hello" => Ok(Event::Hello), "posted" => { let post = raw .data .get("post") .and_then(|p| p.as_str()) .ok_or_else(|| { MmError::Json("a posted event carries no post string".to_string()) })?; let post: Post = json(post.as_bytes())?; let channel_type = raw .data .get("channel_type") .and_then(|c| c.as_str()) .unwrap_or("") .to_string(); Ok(Event::Posted { post: post.check()?, channel_type, }) } other => Ok(Event::Other(other.to_string())), } } /// The WebSocket request that shows this bot as typing in a thread (`parent` is the root). pub fn typing(seq: u64, channel: &str, parent: &str) -> String { // serde_json::json!({"action": "user_typing", "seq": seq, "data": {"channel_id": channel, // "parent_id": parent}}) as a string. serde_json::json!({ "action": "user_typing", "seq": seq, "data": {"channel_id": channel, "parent_id": parent} }) .to_string() } /// What a `posts?since` call gave back. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Since { /// Posts created after the time, not deleted, oldest first. pub posts: Vec, /// Mattermost's limit was reached: some posts may be missing. pub full: bool, } #[derive(Deserialize)] struct PostList { #[serde(default)] order: Vec, #[serde(default)] posts: std::collections::HashMap, } /// The body of `GET /channels/{id}/posts?since=`. Only ids in `order` changed after /// `since`; `posts` also holds the roots of their threads, which may be older. Edited and deleted /// posts come back too: only new posts count. pub fn since_list(body: &[u8], since: i64) -> Result { // Parse a PostList. full = order.len() >= SINCE_LIMIT. For each id in `order` (never the keys // of `posts`), skip it if it is not in `posts`; keep the post if create_at > since and // delete_at == 0, after `check`. Sort by (create_at, id), remove repeated ids. let list: PostList = json(body)?; let full = list.order.len() >= SINCE_LIMIT; let mut seen = std::collections::HashSet::new(); let mut posts = Vec::new(); for id in &list.order { let post = match list.posts.get(id) { Some(p) => p.clone(), None => continue, }; let post = post.check()?; if post.create_at <= since || post.delete_at != 0 { continue; } if seen.insert(post.id.clone()) { posts.push(post); } } posts.sort_by(|a, b| a.create_at.cmp(&b.create_at).then_with(|| a.id.cmp(&b.id))); Ok(Since { posts, full }) }