pub mod types;
use std::sync::{Mutex, PoisonError};
use reqwest::{Method, RequestBuilder, Response, StatusCode};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::config::Credentials;
use types::{
CategoryList, Channel, ChannelId, ChannelMember, Post, PostId, PostList, Reaction, Status,
Team, TeamId, User, UserId, UserStatus,
};
#[derive(Debug, Error)]
pub enum Error {
#[error("request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("server answered {status}: {message}")]
Status { status: u16, message: String },
#[error("login refused: {0}")]
Login(String),
#[error("a multi-factor code is required")]
MfaRequired,
#[error("the server sent no session token")]
NoToken,
}
#[derive(Deserialize)]
struct AppError {
#[serde(default)]
id: String,
#[serde(default)]
message: String,
}
#[derive(Serialize)]
struct LoginBody<'a> {
login_id: &'a str,
password: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
token: Option<&'a str>,
}
#[derive(Debug)]
pub struct Client {
http: reqwest::Client,
base: String,
credentials: Credentials,
token: Mutex<String>,
}
impl Client {
pub async fn connect(
url: &str,
credentials: Credentials,
mfa: Option<&str>,
) -> Result<Self, Error> {
let http = reqwest::Client::builder()
.user_agent(concat!("mm/", env!("CARGO_PKG_VERSION")))
.build()?;
let base = format!("{}/api/v4", url.trim_end_matches('/'));
let token = match &credentials {
Credentials::Password { login, password } => {
login_token(&http, &base, login, password, mfa).await?
}
Credentials::Token(token) => token.clone(),
};
Ok(Self {
http,
base,
credentials,
token: Mutex::new(token),
})
}
pub async fn me(&self) -> Result<User, Error> {
self.fetch(Method::GET, "/users/me", &[], None::<&()>).await
}
pub async fn teams(&self) -> Result<Vec<Team>, Error> {
self.fetch(Method::GET, "/users/me/teams", &[], None::<&()>)
.await
}
pub async fn channels(&self, team: &TeamId) -> Result<Vec<Channel>, Error> {
let path = format!("/users/me/teams/{}/channels", team.0);
self.fetch(Method::GET, &path, &[], None::<&()>).await
}
pub async fn members(&self, team: &TeamId) -> Result<Vec<ChannelMember>, Error> {
let path = format!("/users/me/teams/{}/channels/members", team.0);
self.fetch(Method::GET, &path, &[], None::<&()>).await
}
pub async fn categories(&self, team: &TeamId) -> Result<CategoryList, Error> {
let path = format!("/users/me/teams/{}/channels/categories", team.0);
self.fetch(Method::GET, &path, &[], None::<&()>).await
}
pub async fn view(&self, channel: &ChannelId, prev: Option<&ChannelId>) -> Result<(), Error> {
#[derive(Serialize)]
struct Body<'a> {
channel_id: &'a str,
prev_channel_id: &'a str,
}
let body = Body {
channel_id: &channel.0,
prev_channel_id: prev.map_or("", |p| p.0.as_str()),
};
let _: serde_json::Value = self
.fetch(Method::POST, "/channels/members/me/view", &[], Some(&body))
.await?;
Ok(())
}
pub async fn create_post(
&self,
channel: &ChannelId,
message: &str,
root: Option<&PostId>,
) -> Result<Post, Error> {
#[derive(Serialize)]
struct Body<'a> {
channel_id: &'a str,
message: &'a str,
root_id: &'a str,
}
let body = Body {
channel_id: &channel.0,
message,
root_id: root.map_or("", |r| r.0.as_str()),
};
self.fetch(Method::POST, "/posts", &[], Some(&body)).await
}
pub async fn edit_post(&self, id: &PostId, message: &str) -> Result<Post, Error> {
#[derive(Serialize)]
struct Body<'a> {
message: &'a str,
}
let path = format!("/posts/{}/patch", id.0);
self.fetch(Method::PUT, &path, &[], Some(&Body { message }))
.await
}
pub async fn delete_post(&self, id: &PostId) -> Result<(), Error> {
let path = format!("/posts/{}", id.0);
let _: serde_json::Value = self.fetch(Method::DELETE, &path, &[], None::<&()>).await?;
Ok(())
}
pub async fn react(
&self,
user: &UserId,
post: &PostId,
emoji: &str,
) -> Result<Reaction, Error> {
#[derive(Serialize)]
struct Body<'a> {
user_id: &'a str,
post_id: &'a str,
emoji_name: &'a str,
}
let body = Body {
user_id: &user.0,
post_id: &post.0,
emoji_name: emoji,
};
self.fetch(Method::POST, "/reactions", &[], Some(&body))
.await
}
pub async fn unreact(&self, user: &UserId, post: &PostId, emoji: &str) -> Result<(), Error> {
let path = format!("/users/{}/posts/{}/reactions/{emoji}", user.0, post.0);
let _: serde_json::Value = self.fetch(Method::DELETE, &path, &[], None::<&()>).await?;
Ok(())
}
pub async fn create_direct(&self, me: &UserId, other: &UserId) -> Result<Channel, Error> {
let ids = [&me.0, &other.0];
self.fetch(Method::POST, "/channels/direct", &[], Some(&ids))
.await
}
pub async fn join(&self, channel: &ChannelId, user: &UserId) -> Result<(), Error> {
#[derive(Serialize)]
struct Body<'a> {
user_id: &'a str,
}
let path = format!("/channels/{}/members", channel.0);
let body = Body { user_id: &user.0 };
let _: serde_json::Value = self.fetch(Method::POST, &path, &[], Some(&body)).await?;
Ok(())
}
pub async fn leave(&self, channel: &ChannelId, user: &UserId) -> Result<(), Error> {
let path = format!("/channels/{}/members/{}", channel.0, user.0);
let _: serde_json::Value = self.fetch(Method::DELETE, &path, &[], None::<&()>).await?;
Ok(())
}
pub async fn set_status(&self, user: &UserId, status: Status) -> Result<(), Error> {
#[derive(Serialize)]
struct Body<'a> {
user_id: &'a str,
status: &'a str,
}
let path = format!("/users/{}/status", user.0);
let body = Body {
user_id: &user.0,
status: status.word(),
};
let _: serde_json::Value = self.fetch(Method::PUT, &path, &[], Some(&body)).await?;
Ok(())
}
pub async fn pin(&self, post: &PostId, pinned: bool) -> Result<(), Error> {
let path = format!("/posts/{}/{}", post.0, if pinned { "pin" } else { "unpin" });
let _: serde_json::Value = self.fetch(Method::POST, &path, &[], None::<&()>).await?;
Ok(())
}
pub async fn user_named(&self, name: &str) -> Result<User, Error> {
let path = format!("/users/username/{name}");
self.fetch(Method::GET, &path, &[], None::<&()>).await
}
pub async fn channel_named(&self, team: &TeamId, name: &str) -> Result<Channel, Error> {
let path = format!("/teams/{}/channels/name/{name}", team.0);
self.fetch(Method::GET, &path, &[], None::<&()>).await
}
pub async fn channel_users(&self, channel: &ChannelId) -> Result<Vec<User>, Error> {
let query = [
("in_channel", channel.0.clone()),
("per_page", "200".to_owned()),
];
self.fetch(Method::GET, "/users", &query, None::<&()>).await
}
pub async fn statuses(&self, ids: &[UserId]) -> Result<Vec<UserStatus>, Error> {
self.fetch(Method::POST, "/users/status/ids", &[], Some(ids))
.await
}
pub async fn download(&self, file: &str) -> Result<Vec<u8>, Error> {
let path = format!("/files/{file}");
let resp = self.answer(Method::GET, &path, &[], None::<&()>).await?;
Ok(resp.bytes().await?.to_vec())
}
pub async fn inspect(&self, path: &str) -> Result<serde_json::Value, Error> {
self.fetch(Method::GET, path, &[], None::<&()>).await
}
pub async fn posts(
&self,
channel: &ChannelId,
before: Option<&PostId>,
per_page: u32,
) -> Result<PostList, Error> {
let mut query = vec![("per_page", per_page.to_string())];
if let Some(before) = before {
query.push(("before", before.0.clone()));
}
let path = format!("/channels/{}/posts", channel.0);
self.fetch(Method::GET, &path, &query, None::<&()>).await
}
pub async fn users(&self, ids: &[UserId]) -> Result<Vec<User>, Error> {
self.fetch(Method::POST, "/users/ids", &[], Some(ids)).await
}
async fn fetch<T: DeserializeOwned>(
&self,
method: Method,
path: &str,
query: &[(&str, String)],
body: Option<&(impl Serialize + ?Sized)>,
) -> Result<T, Error> {
Ok(self.answer(method, path, query, body).await?.json().await?)
}
async fn answer(
&self,
method: Method,
path: &str,
query: &[(&str, String)],
body: Option<&(impl Serialize + ?Sized)>,
) -> Result<Response, Error> {
let url = format!("{}{path}", self.base);
let build = |token: String| {
let req = self
.http
.request(method.clone(), &url)
.query(query)
.bearer_auth(token);
match body {
Some(body) => req.json(body),
None => req,
}
};
let mut resp = send(build(self.token())).await?;
if resp.status() == StatusCode::UNAUTHORIZED && self.relogin().await? {
resp = send(build(self.token())).await?;
}
if resp.status().is_success() {
return Ok(resp);
}
Err(status_error(resp).await)
}
pub async fn relogin(&self) -> Result<bool, Error> {
let Credentials::Password { login, password } = &self.credentials else {
return Ok(false);
};
let token = login_token(&self.http, &self.base, login, password, None).await?;
*self.token.lock().unwrap_or_else(PoisonError::into_inner) = token;
Ok(true)
}
pub fn ws_url(&self) -> String {
let (secure, rest) = match self.base.strip_prefix("https://") {
Some(rest) => (true, rest),
None => (false, self.base.trim_start_matches("http://")),
};
format!("{}://{rest}/websocket", if secure { "wss" } else { "ws" })
}
pub fn token(&self) -> String {
self.token
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clone()
}
}
async fn send(req: RequestBuilder) -> Result<Response, Error> {
Ok(req.send().await?)
}
async fn status_error(resp: Response) -> Error {
let status = resp.status().as_u16();
let message = match resp.json::<AppError>().await {
Ok(e) => e.message,
Err(_) => String::new(),
};
Error::Status { status, message }
}
async fn login_token(
http: &reqwest::Client,
base: &str,
login: &str,
password: &str,
mfa: Option<&str>,
) -> Result<String, Error> {
let body = LoginBody {
login_id: login,
password,
token: mfa,
};
let resp = http
.post(format!("{base}/users/login"))
.json(&body)
.send()
.await?;
if resp.status().is_success() {
let token = resp.headers().get("token").and_then(|v| v.to_str().ok());
return token.map(str::to_owned).ok_or(Error::NoToken);
}
let status = resp.status();
let err = resp.json::<AppError>().await.unwrap_or(AppError {
id: String::new(),
message: String::new(),
});
if err.id.contains("mfa") {
return Err(Error::MfaRequired);
}
if status.is_client_error() {
return Err(Error::Login(err.message));
}
Err(Error::Status {
status: status.as_u16(),
message: err.message,
})
}
#[cfg(test)]
mod tests {
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::*;
fn password() -> Credentials {
Credentials::Password {
login: "paul".into(),
password: "hunter2".into(),
}
}
fn login_ok(token: &str) -> Mock {
Mock::given(method("POST"))
.and(path("/api/v4/users/login"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Token", token)
.set_body_json(serde_json::json!({"id": "u1", "username": "paul"})),
)
}
fn me_ok(token: &str) -> Mock {
Mock::given(method("GET"))
.and(path("/api/v4/users/me"))
.and(header("authorization", format!("Bearer {token}").as_str()))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"id": "u1", "username": "paul"})),
)
}
#[ignore = "needs a local socket"]
#[tokio::test]
async fn login_takes_token_from_header() {
let server = MockServer::start().await;
login_ok("tok1").mount(&server).await;
me_ok("tok1").mount(&server).await;
let client = Client::connect(&server.uri(), password(), None)
.await
.unwrap();
assert_eq!(client.me().await.unwrap().username, "paul");
}
#[ignore = "needs a local socket"]
#[tokio::test]
async fn mfa_is_reported() {
let server = MockServer::start().await;
Mock::given(path("/api/v4/users/login"))
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
"id": "mfa.validate_token.authenticate.app_error",
"message": "Invalid MFA token."
})))
.mount(&server)
.await;
let err = Client::connect(&server.uri(), password(), None)
.await
.unwrap_err();
assert!(matches!(err, Error::MfaRequired));
}
#[ignore = "needs a local socket"]
#[tokio::test]
async fn bad_password_is_refused() {
let server = MockServer::start().await;
Mock::given(path("/api/v4/users/login"))
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
"id": "api.user.login.invalid_credentials_email_username",
"message": "Enter a valid email or username."
})))
.mount(&server)
.await;
let err = Client::connect(&server.uri(), password(), None)
.await
.unwrap_err();
assert!(matches!(err, Error::Login(_)));
}
#[ignore = "needs a local socket"]
#[tokio::test]
async fn expired_session_logs_in_again() {
let server = MockServer::start().await;
login_ok("tok1").up_to_n_times(1).mount(&server).await;
login_ok("tok2").mount(&server).await;
Mock::given(path("/api/v4/users/me"))
.and(header("authorization", "Bearer tok1"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
me_ok("tok2").mount(&server).await;
let client = Client::connect(&server.uri(), password(), None)
.await
.unwrap();
assert_eq!(client.me().await.unwrap().username, "paul");
}
#[ignore = "needs a local socket"]
#[tokio::test]
async fn second_rejection_is_an_error() {
let server = MockServer::start().await;
login_ok("tok1").mount(&server).await;
Mock::given(path("/api/v4/users/me"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let client = Client::connect(&server.uri(), password(), None)
.await
.unwrap();
assert!(matches!(
client.me().await,
Err(Error::Status { status: 401, .. })
));
}
#[ignore = "needs a local socket"]
#[tokio::test]
async fn token_is_used_without_login() {
let server = MockServer::start().await;
me_ok("pat").mount(&server).await;
let client = Client::connect(&server.uri(), Credentials::Token("pat".into()), None)
.await
.unwrap();
assert_eq!(client.me().await.unwrap().id, UserId("u1".into()));
}
#[ignore = "needs a local socket"]
#[tokio::test]
async fn view_sends_the_channel_and_the_previous_one() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v4/channels/members/me/view"))
.and(wiremock::matchers::body_json(
serde_json::json!({"channel_id": "c2", "prev_channel_id": "c1"}),
))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"status": "OK", "last_viewed_at_times": {}})),
)
.expect(1)
.mount(&server)
.await;
let client = Client::connect(&server.uri(), Credentials::Token("t".into()), None)
.await
.unwrap();
client
.view(&ChannelId("c2".into()), Some(&ChannelId("c1".into())))
.await
.unwrap();
}
#[ignore = "needs a local socket"]
#[tokio::test]
async fn posts_ask_for_the_page_before_a_post() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v4/channels/c1/posts"))
.and(wiremock::matchers::query_param("before", "p9"))
.and(wiremock::matchers::query_param("per_page", "20"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"order": [], "posts": {}})),
)
.expect(1)
.mount(&server)
.await;
let client = Client::connect(&server.uri(), Credentials::Token("t".into()), None)
.await
.unwrap();
let list = client
.posts(&ChannelId("c1".into()), Some(&PostId("p9".into())), 20)
.await
.unwrap();
assert!(list.order.is_empty());
}
fn post_json(id: &str) -> serde_json::Value {
serde_json::json!({"id": id, "create_at": 1, "user_id": "u1", "channel_id": "c1", "message": "hi"})
}
#[ignore = "needs a local socket"]
#[tokio::test]
async fn writing_calls_send_the_documented_bodies() {
use wiremock::matchers::body_json;
let server = MockServer::start().await;
for (verb, route, body) in [
(
"POST",
"/api/v4/posts",
serde_json::json!({"channel_id": "c1", "message": "hi", "root_id": ""}),
),
(
"POST",
"/api/v4/posts",
serde_json::json!({"channel_id": "c1", "message": "re", "root_id": "p0"}),
),
(
"PUT",
"/api/v4/posts/p1/patch",
serde_json::json!({"message": "fix"}),
),
] {
Mock::given(method(verb))
.and(path(route))
.and(body_json(body))
.respond_with(ResponseTemplate::new(200).set_body_json(post_json("p1")))
.expect(1)
.mount(&server)
.await;
}
Mock::given(method("DELETE"))
.and(path("/api/v4/posts/p1"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"status": "OK"})),
)
.expect(1)
.mount(&server)
.await;
let client = Client::connect(&server.uri(), Credentials::Token("t".into()), None)
.await
.unwrap();
let channel = ChannelId("c1".into());
client.create_post(&channel, "hi", None).await.unwrap();
client
.create_post(&channel, "re", Some(&PostId("p0".into())))
.await
.unwrap();
client.edit_post(&PostId("p1".into()), "fix").await.unwrap();
client.delete_post(&PostId("p1".into())).await.unwrap();
}
#[ignore = "needs a local socket"]
#[tokio::test]
async fn reactions_and_presence_calls_send_the_documented_requests() {
use wiremock::matchers::{body_json, query_param};
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v4/reactions"))
.and(body_json(
serde_json::json!({"user_id": "u1", "post_id": "p1", "emoji_name": "+1"}),
))
.respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!({"user_id": "u1", "post_id": "p1", "emoji_name": "+1"}),
))
.expect(1)
.mount(&server)
.await;
Mock::given(method("DELETE"))
.and(path("/api/v4/users/u1/posts/p1/reactions/+1"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"status": "OK"})),
)
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/v4/users"))
.and(query_param("in_channel", "c1"))
.and(query_param("per_page", "200"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!([{"id": "u2", "username": "ana"}])),
)
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/v4/users/status/ids"))
.and(body_json(serde_json::json!(["u2"])))
.respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!([{"user_id": "u2", "status": "away", "last_activity_at": 1}]),
))
.expect(1)
.mount(&server)
.await;
let client = Client::connect(&server.uri(), Credentials::Token("t".into()), None)
.await
.unwrap();
let (me, post) = (UserId("u1".into()), PostId("p1".into()));
assert_eq!(
client.react(&me, &post, "+1").await.unwrap().emoji_name,
"+1"
);
client.unreact(&me, &post, "+1").await.unwrap();
let users = client.channel_users(&ChannelId("c1".into())).await.unwrap();
assert_eq!(users[0].username, "ana");
let statuses = client.statuses(&[UserId("u2".into())]).await.unwrap();
assert_eq!(statuses[0].status, "away");
}
#[ignore = "needs a local socket"]
#[tokio::test]
async fn users_posts_ids() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v4/users/ids"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!([{"id": "u2", "username": "ana"}])),
)
.mount(&server)
.await;
let client = Client::connect(&server.uri(), Credentials::Token("t".into()), None)
.await
.unwrap();
let users = client.users(&[UserId("u2".into())]).await.unwrap();
assert_eq!(users[0].username, "ana");
}
#[ignore = "needs a local socket"]
#[tokio::test]
async fn a_file_comes_down_as_bytes() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v4/files/f1"))
.and(header("authorization", "Bearer t"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(vec![0u8, 159, 146, 150]))
.mount(&server)
.await;
let client = Client::connect(&server.uri(), Credentials::Token("t".into()), None)
.await
.unwrap();
assert_eq!(client.download("f1").await.unwrap(), [0, 159, 146, 150]);
assert!(client.download("missing").await.is_err());
}
}