gatewayd: mm, Mattermost's events and REST calls, typed
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
|
||||
pub mod config;
|
||||
pub mod http;
|
||||
pub mod mm;
|
||||
pub mod net;
|
||||
pub mod secrets;
|
||||
pub mod ws;
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
//! 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<Post, MmError> {
|
||||
// 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: <id quoted with {:?}>").
|
||||
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<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
pub(crate) fn json<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> Result<T, MmError> {
|
||||
// `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<Event, MmError> {
|
||||
// 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<Post>,
|
||||
/// Mattermost's limit was reached: some posts may be missing.
|
||||
pub full: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PostList {
|
||||
#[serde(default)]
|
||||
order: Vec<String>,
|
||||
#[serde(default)]
|
||||
posts: std::collections::HashMap<String, Post>,
|
||||
}
|
||||
|
||||
/// The body of `GET /channels/{id}/posts?since=<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<Since, MmError> {
|
||||
// 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 })
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
//! Mattermost's REST calls, one connection per request, with the token in the `Authorization`
|
||||
//! header. A 429 waits as asked; a 5xx is tried twice more; a 401 or 403 is `Auth` at once.
|
||||
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::config::valid_id;
|
||||
use crate::http::{self, Response, rate_limit_wait};
|
||||
use crate::mm::{Me, MmError, Post, Since, json, since_list};
|
||||
use crate::net::Connector;
|
||||
use crate::secrets::Secret;
|
||||
use crate::ws::conn::host_header;
|
||||
|
||||
/// The pause before trying a call again after a 5xx.
|
||||
pub const RETRY_5XX: Duration = Duration::from_millis(500);
|
||||
/// How many times a call is tried again after a 5xx, and waits after a 429.
|
||||
pub const RETRIES: u32 = 2;
|
||||
/// How much of an error body is kept for the message.
|
||||
const BODY_KEPT: usize = 200;
|
||||
|
||||
pub struct Client {
|
||||
connector: Connector,
|
||||
token: Secret,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Channel {
|
||||
id: String,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// `timeout` bounds connecting and each read: no bytes for that long is an error.
|
||||
pub fn new(connector: Connector, token: Secret, timeout: Duration) -> Client {
|
||||
// Store the three.
|
||||
Client {
|
||||
connector,
|
||||
token,
|
||||
timeout,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn connector(&self) -> &Connector {
|
||||
// The Connector.
|
||||
&self.connector
|
||||
}
|
||||
|
||||
pub fn token(&self) -> &Secret {
|
||||
// The Secret.
|
||||
&self.token
|
||||
}
|
||||
|
||||
fn once(&self, method: &str, path: &str, body: Option<&[u8]>) -> Result<Response, MmError> {
|
||||
// 1. `self.connector.connect(self.timeout)`, then `set_read_timeout(Some(self.timeout))`.
|
||||
let mut stream = self
|
||||
.connector
|
||||
.connect(self.timeout)
|
||||
.map_err(|e| MmError::Net(format!("{method} {path}: {e}")))?;
|
||||
stream
|
||||
.set_read_timeout(Some(self.timeout))
|
||||
.map_err(|e| MmError::Net(format!("{method} {path}: {e}")))?;
|
||||
// 2. Headers: Authorization "Bearer <token>" (the only use of `expose`), Accept
|
||||
// "application/json", and Content-Type "application/json" when there is a body. Host is
|
||||
// `host_header(self.connector.server())`.
|
||||
let auth = format!("Bearer {}", self.token.expose());
|
||||
let mut headers: Vec<(&str, &str)> = vec![
|
||||
("Authorization", auth.as_str()),
|
||||
("Accept", "application/json"),
|
||||
];
|
||||
if body.is_some() {
|
||||
headers.push(("Content-Type", "application/json"));
|
||||
}
|
||||
// 3. `http::request`. Every error on the way is Net("<method> <path>: <error>").
|
||||
http::request(
|
||||
&mut stream,
|
||||
method,
|
||||
&host_header(self.connector.server()),
|
||||
path,
|
||||
&headers,
|
||||
body,
|
||||
)
|
||||
.map_err(|e| MmError::Net(format!("{method} {path}: {e}")))
|
||||
}
|
||||
|
||||
/// One call, tried again as the module comment says; the body of a 2xx answer.
|
||||
fn call(&self, method: &str, path: &str, body: Option<&[u8]>) -> Result<Vec<u8>, MmError> {
|
||||
// Loop over `once`: 2xx -> the body. 401 or 403 -> Auth(status) at once. 429 -> after
|
||||
// RETRIES waits already, RateLimited(wait); else sleep `rate_limit_wait(&head,
|
||||
// SystemTime::now())` and try again. 5xx -> up to RETRIES more tries, sleeping RETRY_5XX
|
||||
// before each. Anything else -> Status(status, the first BODY_KEPT characters of the body,
|
||||
// lossy UTF-8).
|
||||
let mut tries: u32 = 0;
|
||||
loop {
|
||||
let answer = self.once(method, path, body)?;
|
||||
let status = answer.head.status;
|
||||
if (200..300).contains(&status) {
|
||||
return Ok(answer.body);
|
||||
}
|
||||
match status {
|
||||
401 | 403 => return Err(MmError::Auth(status)),
|
||||
429 => {
|
||||
let wait = rate_limit_wait(&answer.head, SystemTime::now());
|
||||
if tries < RETRIES {
|
||||
tries += 1;
|
||||
std::thread::sleep(wait);
|
||||
continue;
|
||||
}
|
||||
return Err(MmError::RateLimited(wait));
|
||||
}
|
||||
_ if status >= 500 => {
|
||||
if tries < RETRIES {
|
||||
tries += 1;
|
||||
std::thread::sleep(RETRY_5XX);
|
||||
continue;
|
||||
}
|
||||
return Err(status_error(status, &answer.body));
|
||||
}
|
||||
_ => return Err(status_error(status, &answer.body)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/v4/users/me`: who this token is.
|
||||
pub fn me(&self) -> Result<Me, MmError> {
|
||||
// GET /api/v4/users/me into Me; an id that is not `valid_id` or an empty username is Json.
|
||||
let body = self.call("GET", "/api/v4/users/me", None)?;
|
||||
let me: Me = json(&body)?;
|
||||
if !valid_id(&me.id) || me.username.is_empty() {
|
||||
return Err(MmError::Json(
|
||||
"the user me has no id or no username".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(me)
|
||||
}
|
||||
|
||||
/// `POST /api/v4/posts`: a post in `channel`, in the thread of `root` (empty: top level).
|
||||
pub fn create_post(&self, channel: &str, root: &str, message: &str) -> Result<Post, MmError> {
|
||||
// POST /api/v4/posts with {"channel_id", "root_id", "message"}; the answer is a Post,
|
||||
// `check`ed.
|
||||
let payload = serde_json::json!({
|
||||
"channel_id": channel,
|
||||
"root_id": root,
|
||||
"message": message
|
||||
})
|
||||
.to_string();
|
||||
let body = self.call("POST", "/api/v4/posts", Some(payload.as_bytes()))?;
|
||||
let post: Post = json(&body)?;
|
||||
post.check()
|
||||
}
|
||||
|
||||
/// `GET /api/v4/channels/{channel}/posts?since=<ms>`: the posts created after `since`.
|
||||
pub fn posts_since(&self, channel: &str, since: i64) -> Result<Since, MmError> {
|
||||
// A channel that is not `valid_id` is Json, and nothing is sent. Otherwise GET
|
||||
// /api/v4/channels/<channel>/posts?since=<since>, through `since_list`.
|
||||
if !valid_id(channel) {
|
||||
return Err(MmError::Json(
|
||||
"a channel path must be a Mattermost id".to_string(),
|
||||
));
|
||||
}
|
||||
let body = self.call(
|
||||
"GET",
|
||||
&format!("/api/v4/channels/{channel}/posts?since={since}"),
|
||||
None,
|
||||
)?;
|
||||
since_list(&body, since)
|
||||
}
|
||||
|
||||
/// `POST /api/v4/channels/direct`: the id of the direct channel between two users.
|
||||
pub fn direct_channel(&self, a: &str, b: &str) -> Result<String, MmError> {
|
||||
// POST /api/v4/channels/direct with the JSON array [a, b]; the answer has an "id", which
|
||||
// must be `valid_id` (else Json).
|
||||
let payload = serde_json::to_vec(&serde_json::json!([a, b]))
|
||||
.map_err(|e| MmError::Json(e.to_string()))?;
|
||||
let body = self.call("POST", "/api/v4/channels/direct", Some(payload.as_slice()))?;
|
||||
let channel: Channel = json(&body)?;
|
||||
if !valid_id(&channel.id) {
|
||||
return Err(MmError::Json("the direct channel has no id".to_string()));
|
||||
}
|
||||
Ok(channel.id)
|
||||
}
|
||||
}
|
||||
|
||||
fn status_error(status: u16, body: &[u8]) -> MmError {
|
||||
// Any non-2xx, non-4xx-that-isn't-401/403/429/5xx: the status and the first `BODY_KEPT`
|
||||
// characters, lossy UTF-8.
|
||||
MmError::Status(
|
||||
status,
|
||||
String::from_utf8_lossy(body)
|
||||
.chars()
|
||||
.take(BODY_KEPT)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user