Author SHA1 Message Date
SalastilandClaude Sonnet 5.5 9382bc04ea Merge development into master: 1.3.3
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 16:26:18 -04:00
SalastilandClaude Sonnet 5.5 ec34657d92 Merge development into master: 1.3.2
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 23:36:05 -04:00
SalastilandClaude Sonnet 5.5 99253cbd5b Merge development into master: 1.3.1
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 21:03:46 -04:00
SalastilandClaude Sonnet 5.5 264ee7a1f5 Merge development into master: 1.3.0, Telegram
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 19:03:03 -04:00
SalastilandClaude Sonnet 5.5 931cd458af Merge development into master: Telegram
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 22:47:35 -04:00
SalastilandClaude Sonnet 5.5 5ab8eafceb Merge development into master: 1.2.1, Steam identity
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 17:14:18 -04:00
SalastilandClaude Sonnet 5.5 2355fcb6ac Merge development into master: 1.2.0, Twitch
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 21:54:56 -04:00
28 changed files with 8 additions and 5759 deletions

No files matched your search

+3 -215
View File
@@ -537,51 +537,6 @@ impl TelegramAccountConfig {
}
}
/// A Stoat account (formerly Revolt): a chat service shaped like Discord, signed in with an email and password (and a
/// second factor if the account has one) or a pasted session token. Stoat can be hosted by anyone, so the account names
/// its instance as a Matrix account names its homeserver; the default is the official one (see backend/stoat).
///
/// The session token is what the account is, and is as private as a Discord token is: it is kept here, never logged and
/// never put in an address.
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct StoatAccountConfig {
/// The instance's API address, without a trailing slash (`https://api.stoat.chat`).
#[serde(default)]
pub instance: String,
/// Stoat's id for the account, which survives a change of name. The account's id here is built from it.
#[serde(default)]
pub user_id: String,
/// The `username` (with its `#0000` discriminator where it has one), for the sidebar.
#[serde(default)]
pub username: String,
/// The session token, sent as `X-Session-Token`.
#[serde(default)]
pub token: String,
/// The session's own id, so it can be ended at Stoat when the account is removed. Empty for a pasted token.
#[serde(default)]
pub session_id: String,
/// What to call this account here. Local: Stoat is never told about it.
#[serde(default)]
pub display_name: Option<String>,
/// Whether this account's connections go through Tor or the SOCKS5 proxy set in the network settings.
#[serde(default)]
pub use_tor: bool,
/// Strict routing: everything the window loads for this account's conversations goes through the route too.
#[serde(default)]
pub strict_route: bool,
}
impl StoatAccountConfig {
pub fn account_id(&self) -> String {
format!("stoat:{}", self.user_id)
}
/// Whether there is a sign-in behind this account.
pub fn is_signed_in(&self) -> bool {
!self.user_id.is_empty() && !self.token.is_empty()
}
}
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
struct AccountsFile {
#[serde(default, rename = "account")]
@@ -604,8 +559,6 @@ struct AccountsFile {
twitch: Vec<TwitchAccountConfig>,
#[serde(default, rename = "telegram_account")]
telegram: Vec<TelegramAccountConfig>,
#[serde(default, rename = "stoat_account")]
stoat: Vec<StoatAccountConfig>,
}
/// Account/credential persistence at ~/.config/nobilis/accounts.toml,
@@ -627,8 +580,6 @@ pub struct AccountStore {
twitch: Mutex<HashMap<String, TwitchAccountConfig>>,
/// And this one.
telegram: Mutex<HashMap<String, TelegramAccountConfig>>,
/// And this one.
stoat: Mutex<HashMap<String, StoatAccountConfig>>,
}
impl AccountStore {
@@ -639,7 +590,7 @@ impl AccountStore {
}
pub fn open(path: PathBuf) -> Result<Self> {
let (irc, discord, sneedchat, matrix, kick, steam, twitch, telegram, stoat) = if path.exists() {
let (irc, discord, sneedchat, matrix, kick, steam, twitch, telegram) = if path.exists() {
let text = std::fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
let file: AccountsFile = toml::from_str(&text)
@@ -652,10 +603,9 @@ impl AccountStore {
let steam = file.steam.into_iter().map(|a| (a.account_id(), a)).collect();
let twitch = file.twitch.into_iter().map(|a| (a.account_id(), a)).collect();
let telegram = file.telegram.into_iter().map(|a| (a.account_id(), a)).collect();
let stoat = file.stoat.into_iter().map(|a| (a.account_id(), a)).collect();
(irc, discord, sneedchat, matrix, kick, steam, twitch, telegram, stoat)
(irc, discord, sneedchat, matrix, kick, steam, twitch, telegram)
} else {
(HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new())
(HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new())
};
Ok(Self {
path,
@@ -667,7 +617,6 @@ impl AccountStore {
steam: Mutex::new(steam),
twitch: Mutex::new(twitch),
telegram: Mutex::new(telegram),
stoat: Mutex::new(stoat),
})
}
@@ -691,7 +640,6 @@ impl AccountStore {
steam: self.steam.lock().unwrap().values().cloned().collect(),
twitch: self.twitch.lock().unwrap().values().cloned().collect(),
telegram: self.telegram.lock().unwrap().values().cloned().collect(),
stoat: self.stoat.lock().unwrap().values().cloned().collect(),
};
let text = toml::to_string_pretty(&file)?;
// Atomic write-temp-then-rename, same spirit as libpurple's periodic
@@ -971,48 +919,6 @@ impl AccountStore {
Ok(true)
}
pub fn get_stoat(&self, account_id: &str) -> Option<StoatAccountConfig> {
self.stoat.lock().unwrap().get(account_id).cloned()
}
pub fn all_stoat(&self) -> Vec<StoatAccountConfig> {
self.stoat.lock().unwrap().values().cloned().collect()
}
/// Upserts: signing in again as the same account is how a session Stoat has ended gets replaced. What the person
/// set up on the account - its route, its name here - stays.
pub fn add_stoat(&self, mut config: StoatAccountConfig) -> Result<StoatAccountConfig> {
let id = config.account_id();
{
let mut stoat = self.stoat.lock().unwrap();
if let Some(old) = stoat.get(&id) {
config.strict_route |= old.strict_route;
config.use_tor |= old.use_tor;
if config.display_name.is_none() {
config.display_name = old.display_name.clone();
}
}
stoat.insert(id, config.clone());
}
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sneedchat.lock().unwrap(), &self.matrix.lock().unwrap(), &self.kick.lock().unwrap())?;
Ok(config)
}
/// Changes one Stoat account, and writes the file. `false` when there is no such account.
///
/// The lock is let go before the write: `persist` takes it again.
pub fn mutate_stoat(&self, account_id: &str, change: impl FnOnce(&mut StoatAccountConfig)) -> Result<bool> {
{
let mut stoat = self.stoat.lock().unwrap();
match stoat.get_mut(account_id) {
None => return Ok(false),
Some(account) => change(account),
}
}
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sneedchat.lock().unwrap(), &self.matrix.lock().unwrap(), &self.kick.lock().unwrap())?;
Ok(true)
}
/// Upserts, like the others: signing in to Kick again with the same
/// username is how a rejected token gets replaced.
pub fn add_kick(&self, mut config: KickAccountConfig) -> Result<KickAccountConfig> {
@@ -1159,10 +1065,6 @@ impl AccountStore {
a.use_tor = routed;
a.strict_route = strict;
true
} else if let Some(a) = self.stoat.lock().unwrap().get_mut(account_id) {
a.use_tor = routed;
a.strict_route = strict;
true
} else {
false
};
@@ -1196,9 +1098,6 @@ impl AccountStore {
if let Some(a) = self.get_telegram(account_id) {
return pick(a.use_tor, a.strict_route);
}
if let Some(a) = self.get_stoat(account_id) {
return pick(a.use_tor, a.strict_route);
}
self.get_kick(account_id).and_then(|a| pick(a.use_tor, a.strict_route))
}
@@ -1293,13 +1192,6 @@ impl AccountStore {
return Ok(true);
}
}
{
let removed = self.stoat.lock().unwrap().remove(account_id).is_some();
if removed {
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sneedchat.lock().unwrap(), &self.matrix.lock().unwrap(), &self.kick.lock().unwrap())?;
return Ok(true);
}
}
let mut kick = self.kick.lock().unwrap();
if kick.remove(account_id).is_some() {
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sneedchat.lock().unwrap(), &self.matrix.lock().unwrap(), &kick)?;
@@ -1467,9 +1359,6 @@ impl AccountStore {
if self.mutate_telegram(account_id, |a| a.display_name = value.clone())? {
return Ok(true);
}
if self.mutate_stoat(account_id, |a| a.display_name = value.clone())? {
return Ok(true);
}
let mut kick = self.kick.lock().unwrap();
match kick.get_mut(account_id) {
None => Ok(false),
@@ -1509,9 +1398,6 @@ impl AccountStore {
if let Some(a) = self.telegram.lock().unwrap().get(account_id) {
return named(&a.display_name);
}
if let Some(a) = self.stoat.lock().unwrap().get(account_id) {
return named(&a.display_name);
}
self.kick.lock().unwrap().get(account_id).and_then(|a| named(&a.display_name))
}
@@ -1901,48 +1787,6 @@ pub fn telegram_account_to_json(a: &TelegramAccountConfig, state: &str) -> Accou
}
}
/// Stoat has none of IRC's concepts either. `has_password` says there is a sign-in behind the account.
pub fn stoat_account_to_json(a: &StoatAccountConfig, state: &str) -> Account {
Account {
id: a.account_id(),
service: "stoat".to_string(),
status: "online".to_string(),
status_text: String::new(),
auto_idle: false,
display_name: a
.display_name
.clone()
.filter(|s| !s.is_empty())
.or_else(|| Some(a.username.clone()).filter(|s| !s.is_empty()))
.unwrap_or_else(|| a.user_id.clone()),
state: state.to_string(),
autojoin: String::new(),
has_nickserv_password: false,
sasl_enabled: false,
sasl_username: String::new(),
allow_plaintext_sasl: false,
reconnect_for_sasl: false,
sasl_mechanism: String::new(),
has_sasl_certificate: false,
current_nick: String::new(),
highlight_keywords: Vec::new(),
ssl: true,
has_password: a.is_signed_in(),
avatar_url: None,
sneedchat_rooms: Vec::new(),
tor_mode: None,
tor_proxy: None,
use_tor: a.use_tor,
route_level: route_level(a.use_tor, a.strict_route),
has_key_backup: false,
rtc_focus_url: None,
sliding_sync: false,
dehydration: false,
web_session: false,
moderation: false,
}
}
/// Matrix has none of IRC's autojoin/SASL/NickServ concepts either - same
/// zero-value convention as discord_account_to_json/sneedchat_account_to_json.
/// `has_key_backup` comes from the caller (Runtime::list_accounts) rather
@@ -2253,59 +2097,3 @@ mod telegram_account_tests {
assert_eq!(unnamed.display_name, "+15551234567");
}
}
#[cfg(test)]
mod stoat_account_tests {
use super::*;
fn store(name: &str) -> (AccountStore, std::path::PathBuf) {
let path = std::env::temp_dir().join(format!("nobilis-stoat-accounts-{name}-{}.toml", std::process::id()));
let _ = std::fs::remove_file(&path);
(AccountStore::open(path.clone()).unwrap(), path)
}
fn account(user: &str) -> StoatAccountConfig {
StoatAccountConfig { instance: "https://api.stoat.chat".into(), user_id: user.into(), username: "someone#0001".into(), token: "secret-token".into(), session_id: "01SESSION".into(), ..Default::default() }
}
#[test]
fn a_stoat_account_survives_a_restart_and_is_found_by_the_id_built_from_stoats() {
let (s, path) = store("restart");
s.add_stoat(account("01USER")).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("[[stoat_account]]"), "{text}");
let reopened = AccountStore::open(path.clone()).unwrap();
let got = reopened.get_stoat("stoat:01USER").expect("found");
assert_eq!((got.instance.as_str(), got.token.as_str(), got.session_id.as_str()), ("https://api.stoat.chat", "secret-token", "01SESSION"));
std::fs::remove_file(path).ok();
}
#[test]
fn signing_in_again_keeps_what_the_person_set_and_removing_forgets() {
let (s, path) = store("again");
s.add_stoat(account("01A")).unwrap();
s.add_stoat(account("01B")).unwrap();
s.set_route_level("stoat:01A", "strict").unwrap();
s.set_display_name("stoat:01A", "Me on Stoat").unwrap();
s.add_stoat(StoatAccountConfig { token: "newer".into(), ..account("01A") }).unwrap();
let got = s.get_stoat("stoat:01A").unwrap();
assert_eq!((got.token.as_str(), got.display_name.as_deref(), got.strict_route), ("newer", Some("Me on Stoat"), true));
assert_eq!(s.route_level_of("stoat:01A").as_deref(), Some("strict"));
assert_eq!(s.all_stoat().len(), 2);
assert!(s.remove("stoat:01A").unwrap());
assert!(!s.remove("stoat:01A").unwrap());
assert!(s.get_stoat("stoat:01B").is_some());
assert!(AccountStore::open(path.clone()).unwrap().get_stoat("stoat:01A").is_none(), "the file forgot too");
std::fs::remove_file(path).ok();
}
#[test]
fn the_listing_says_what_the_account_is_called_and_that_there_is_a_sign_in_behind_it() {
let listed = stoat_account_to_json(&account("01C"), "connected");
assert_eq!((listed.service.as_str(), listed.id.as_str(), listed.display_name.as_str(), listed.has_password), ("stoat", "stoat:01C", "someone#0001", true));
let named = stoat_account_to_json(&StoatAccountConfig { display_name: Some("Me".into()), ..account("01C") }, "connected");
assert_eq!(named.display_name, "Me");
let signed_out = stoat_account_to_json(&StoatAccountConfig { token: String::new(), ..account("01C") }, "connected");
assert!(!signed_out.has_password);
}
}
-1
View File
@@ -16,6 +16,5 @@ pub mod kick;
pub mod matrix;
pub mod sneedchat;
pub mod steam;
pub mod stoat;
pub mod telegram;
pub mod twitch;
-255
View File
@@ -1,255 +0,0 @@
//! The REST client: one per account, with the account's token and route.
//!
//! Stoat limits each route's bucket to a number of calls in a fixed ten-second window, and says so in
//! `X-RateLimit-Remaining` and `X-RateLimit-Reset-After` (milliseconds). Those are kept to before a request is made,
//! so a refusal is the exception; a 429 that does come waits as long as it asks and is never taken as an answer. A 401
//! means the session is over: nothing more is sent with it.
use anyhow::{anyhow, Result};
use reqwest::Method;
use serde_json::Value;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::time::{Duration, Instant};
/// The official instance's API address.
pub const DEFAULT_INSTANCE: &str = "https://api.stoat.chat";
/// The longest a rate limit is waited out in one go before the request is given up.
const LONGEST_WAIT: Duration = Duration::from_secs(30);
/// How many times a request is tried again after being told to wait.
const RETRIES: usize = 2;
/// Why a request did not give an answer.
#[derive(Debug)]
pub enum ApiError {
/// The session has been refused: nothing more is sent with it.
Unauthorized,
/// Stoat said no, with its own name for why.
Refused { status: u16, kind: String, detail: Value },
/// The request never got an answer.
Network(String),
}
impl std::fmt::Display for ApiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ApiError::Unauthorized => write!(f, "Stoat has ended this session. Sign in again from Accounts"),
ApiError::Refused { status, kind, detail } => write!(f, "{}", words(*status, kind, detail)),
ApiError::Network(e) => write!(f, "could not reach Stoat: {e}"),
}
}
}
impl std::error::Error for ApiError {}
/// Stoat's error names, said so a person can act on them.
pub fn words(status: u16, kind: &str, detail: &Value) -> String {
let permission = detail["permission"].as_str().unwrap_or("that");
match kind {
"InvalidCredentials" => "That email and password were not accepted".to_string(),
"InvalidSession" => "Stoat has ended this session. Sign in again from Accounts".to_string(),
"LockedOut" => "Stoat has locked this account for a while after too many wrong passwords. Try again later".to_string(),
"UnverifiedAccount" => "This account's email has not been verified. Do that from Stoat's own app first".to_string(),
"DisabledAccount" => "This account is disabled".to_string(),
"InvalidToken" | "InvalidOneTimeCode" => "That code was not accepted".to_string(),
"CaptchaFailed" => "Stoat wants a captcha answered for this, which moho does not do. Do it from Stoat's own app".to_string(),
"MissingPermission" => format!("You do not have permission here ({permission})"),
"NotFound" | "UnknownChannel" | "UnknownMessage" | "UnknownServer" | "UnknownUser" => "Stoat says that is not there any more".to_string(),
"PayloadTooLarge" => "That is too large for Stoat".to_string(),
"TooManyAttachments" | "TooManyEmbeds" | "TooManyReplies" => "That has more than Stoat allows in one message".to_string(),
"Blocked" | "BlockedByOther" => "Stoat will not allow that between you and this person".to_string(),
"InSlowmode" => format!("This channel has slowmode on: wait {} more seconds before sending again", detail["retry_after"].as_u64().unwrap_or(1)),
"TooManyRequests" | "RateLimited" => "Stoat says too many requests were made. Wait a moment".to_string(),
"FailedValidation" => format!("Stoat refused that: {}", detail["error"].as_str().unwrap_or("it is not valid")),
other if !other.is_empty() => format!("Stoat refused that ({other})"),
_ => format!("Stoat answered {status}"),
}
}
/// Calls left in a route's window, and when it is replenished.
struct Bucket {
remaining: u32,
until: Instant,
}
pub struct Api {
http: reqwest::Client,
base: String,
token: Option<String>,
buckets: Mutex<HashMap<String, Bucket>>,
halted: AtomicBool,
}
/// The route a request belongs to, for pacing: the method and the path with its ids taken out.
pub fn route_key(method: &Method, path: &str) -> String {
let parts: Vec<&str> = path
.split('/')
.filter(|p| !p.is_empty())
.map(|p| if p.len() == 26 && p.chars().all(|c| c.is_ascii_alphanumeric()) { ":id" } else { p })
.collect();
format!("{method} /{}", parts.join("/"))
}
impl Api {
/// A client for an instance. `key` names the route (the account's id, or a sign-in's pending key).
pub fn new(key: &str, instance: &str, token: Option<String>) -> Self {
let router = crate::net::route::router();
let http = router.client_if("stoat", router.routed(key), |b| b.user_agent(concat!("moho/", env!("CARGO_PKG_VERSION"))).timeout(Duration::from_secs(30)));
Self { http, base: instance.trim_end_matches('/').to_string(), token, buckets: Mutex::new(HashMap::new()), halted: AtomicBool::new(false) }
}
/// The instance's address, as requests are made to it.
pub fn base(&self) -> &str {
&self.base
}
/// Whether the session was refused.
pub fn halted(&self) -> bool {
self.halted.load(Ordering::Relaxed)
}
pub async fn get(&self, path: &str, query: &[(&str, String)]) -> Result<Value, ApiError> {
self.request(Method::GET, path, query, None).await
}
pub async fn post(&self, path: &str, body: &Value) -> Result<Value, ApiError> {
self.request(Method::POST, path, &[], Some(body)).await
}
pub async fn put(&self, path: &str, body: Option<&Value>) -> Result<Value, ApiError> {
self.request(Method::PUT, path, &[], body).await
}
pub async fn patch(&self, path: &str, body: &Value) -> Result<Value, ApiError> {
self.request(Method::PATCH, path, &[], Some(body)).await
}
pub async fn delete(&self, path: &str) -> Result<Value, ApiError> {
self.request(Method::DELETE, path, &[], None).await
}
/// Puts a file on the instance's file server, and answers the id to name it by in a message.
pub async fn upload(&self, server: &str, tag: &str, name: &str, content_type: &str, bytes: Vec<u8>) -> Result<String, ApiError> {
if self.halted() {
return Err(ApiError::Unauthorized);
}
let part = reqwest::multipart::Part::bytes(bytes).file_name(name.to_string()).mime_str(content_type).map_err(|e| ApiError::Network(e.to_string()))?;
let form = reqwest::multipart::Form::new().part("file", part);
let mut request = self.http.post(format!("{}/{tag}", server.trim_end_matches('/'))).multipart(form);
if let Some(token) = &self.token {
request = request.header("X-Session-Token", token);
}
let response = request.send().await.map_err(|e| ApiError::Network(e.to_string()))?;
let status = response.status();
let text = response.text().await.map_err(|e| ApiError::Network(e.to_string()))?;
let json: Value = serde_json::from_str(&text).unwrap_or(Value::Null);
if status.as_u16() == 401 {
self.halted.store(true, Ordering::Relaxed);
return Err(ApiError::Unauthorized);
}
if !status.is_success() {
return Err(ApiError::Refused { status: status.as_u16(), kind: json["type"].as_str().unwrap_or("").to_string(), detail: json });
}
json["id"].as_str().map(str::to_string).ok_or_else(|| ApiError::Refused { status: status.as_u16(), kind: "NoFileId".to_string(), detail: json })
}
/// Waits for the route's window if it has run dry.
async fn pace(&self, key: &str) {
let wait = {
let buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
buckets.get(key).filter(|b| b.remaining == 0 && b.until > Instant::now()).map(|b| b.until - Instant::now())
};
if let Some(wait) = wait {
tokio::time::sleep(wait.min(LONGEST_WAIT)).await;
}
}
fn learn(&self, key: &str, headers: &reqwest::header::HeaderMap) {
let number = |name: &str| headers.get(name).and_then(|v| v.to_str().ok()).and_then(|v| v.parse::<u64>().ok());
if let (Some(remaining), Some(reset)) = (number("x-ratelimit-remaining"), number("x-ratelimit-reset-after")) {
self.buckets.lock().unwrap_or_else(|e| e.into_inner()).insert(key.to_string(), Bucket { remaining: remaining as u32, until: Instant::now() + Duration::from_millis(reset) });
}
}
pub async fn request(&self, method: Method, path: &str, query: &[(&str, String)], body: Option<&Value>) -> Result<Value, ApiError> {
let key = route_key(&method, path);
for attempt in 0..=RETRIES {
if self.halted() {
return Err(ApiError::Unauthorized);
}
self.pace(&key).await;
let mut request = self.http.request(method.clone(), format!("{}{path}", self.base)).query(query);
if let Some(token) = &self.token {
request = request.header("X-Session-Token", token);
}
if let Some(body) = body {
request = request.json(body);
}
let response = request.send().await.map_err(|e| ApiError::Network(e.to_string()))?;
self.learn(&key, response.headers());
let status = response.status();
let text = response.text().await.map_err(|e| ApiError::Network(e.to_string()))?;
let json: Value = if text.trim().is_empty() { Value::Null } else { serde_json::from_str(&text).unwrap_or(Value::Null) };
// A channel's slowmode is told in the same status, in seconds, and is not a rate limit to wait out and try again:
// it is the answer, and the account is not to ask again until it is over.
if status.as_u16() == 429 && json["type"].as_str() == Some("InSlowmode") {
return Err(ApiError::Refused { status: 429, kind: "InSlowmode".into(), detail: json });
}
if status.as_u16() == 429 {
let wait = Duration::from_millis(json["retry_after"].as_u64().unwrap_or(1000)).min(LONGEST_WAIT);
tracing::info!("stoat: told to wait {} ms on {key}", wait.as_millis());
if attempt == RETRIES {
return Err(ApiError::Refused { status: 429, kind: "TooManyRequests".into(), detail: json });
}
tokio::time::sleep(wait + Duration::from_millis(50)).await;
continue;
}
// The session is over only where one was sent: a wrong password on the login is a refusal, not that.
if status.as_u16() == 401 && self.token.is_some() {
self.halted.store(true, Ordering::Relaxed);
return Err(ApiError::Unauthorized);
}
if !status.is_success() {
let kind = json["type"].as_str().unwrap_or("").to_string();
return Err(ApiError::Refused { status: status.as_u16(), kind, detail: json });
}
return Ok(json);
}
Err(ApiError::Network("gave up waiting".into()))
}
}
/// What the instance says of itself at its root, and `Err` if what answered is not Stoat (or Revolt).
pub async fn instance_info(api: &Api) -> Result<Value> {
let root = api.get("/", &[]).await.map_err(|e| anyhow!("{e}"))?;
if root.get("stoat").is_none() && root.get("revolt").is_none() {
return Err(anyhow!("that address did not answer like a Stoat instance"));
}
Ok(root)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_route_is_named_without_its_ids() {
let id = "01HZZZZZZZZZZZZZZZZZZZZZZZ";
assert_eq!(route_key(&Method::POST, &format!("/channels/{id}/messages")), "POST /channels/:id/messages");
assert_eq!(route_key(&Method::GET, "/users/@me"), "GET /users/@me");
assert_eq!(route_key(&Method::GET, "/"), "GET /");
assert_eq!(route_key(&Method::DELETE, &format!("/channels/{id}/messages/{id}")), "DELETE /channels/:id/messages/:id");
}
#[test]
fn stoats_refusals_are_said_in_words() {
assert!(words(401, "InvalidCredentials", &Value::Null).contains("email and password"));
assert!(words(403, "MissingPermission", &serde_json::json!({ "permission": "SendMessage" })).contains("SendMessage"));
assert!(words(400, "CaptchaFailed", &Value::Null).contains("captcha"));
assert_eq!(words(418, "", &Value::Null), "Stoat answered 418");
assert!(words(400, "SomethingNew", &Value::Null).contains("SomethingNew"));
}
}
-235
View File
@@ -1,235 +0,0 @@
//! An account's connection: the events websocket, kept up and reconnected - without ever signing in afresh to get over
//! a hiccup, and stopping, not retrying, when Stoat has ended the session (the same token can never work again).
//!
//! The shape is Telegram's and Steam's: a wait that grows while a connection falls over at once and starts again after
//! one that held, a long wait when told to slow down, and a stop when the session is over.
//!
//! One connection, one `Authenticate` (the token goes in a message, never in the address), then `Ready`, which carries
//! everything the account can see, and the events after it. A `Ping` goes every twenty seconds, which Stoat asks of a
//! client (every ten to thirty); a connection that has heard nothing for ninety is let go and made again.
use super::api::{self, Api, ApiError};
use super::world::{Instance, World};
use super::{events, Handle};
use crate::accounts::StoatAccountConfig;
use crate::runtime::ConnState;
use crate::state::AppState;
use anyhow::{anyhow, Context, Result};
use futures::{SinkExt, StreamExt};
use serde_json::{json, Value};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio_tungstenite::tungstenite::Message as Ws;
const RECONNECT_INITIAL_DELAY: Duration = Duration::from_secs(10);
const RECONNECT_MAX_DELAY: Duration = Duration::from_secs(300);
const PING_EVERY: Duration = Duration::from_secs(20);
const SILENT_FOR: Duration = Duration::from_secs(90);
/// How a connection ended.
#[derive(Debug, PartialEq)]
pub enum Ending {
/// Stoat ended the session: sign in again.
SignInAgain(String),
/// The connection ended; try again after the usual wait.
Closed,
}
/// What Stoat's `Error` event means for the connection.
pub fn classify(error: &str) -> Ending {
match error {
"InvalidSession" | "NotAuthenticated" => Ending::SignInAgain("Stoat has ended this session".to_string()),
"OnboardingNotFinished" => Ending::SignInAgain("This account has not finished setting up: choose a username in Stoat's own app first".to_string()),
_ => Ending::Closed,
}
}
/// Starts (or restarts) an account's connection.
pub fn spawn(state: AppState, config: StoatAccountConfig) {
let account_id = config.account_id();
crate::net::route::router().mark(&account_id, config.use_tor);
state.runtime.reset_connection(&account_id);
let task = tokio::spawn({
let account_id = account_id.clone();
let state = state.clone();
async move {
run_with_retry(&state, &account_id).await;
state.runtime.remove_task_handle(&account_id);
}
});
state.runtime.insert_task_handle(&account_id, task.abort_handle());
}
async fn run_with_retry(state: &AppState, account_id: &str) {
let mut delay = RECONNECT_INITIAL_DELAY;
state.runtime.set_conn_state(state, account_id, ConnState::Connecting, None);
loop {
// The account as it is now: removed while this slept, it is not reconnected.
let Some(config) = state.accounts.get_stoat(account_id) else { return };
let detail = match run(state, &config, account_id).await {
Ok(Ending::SignInAgain(why)) => {
tracing::info!("stoat[{account_id}]: stopped: {why}");
state.runtime.set_conn_state(state, account_id, ConnState::AuthFailed, Some(&format!("{why}. Sign in to Stoat again from Accounts.")));
return;
}
Ok(Ending::Closed) => "connection ended".to_string(),
Err(e) => match e.downcast_ref::<ApiError>() {
Some(ApiError::Unauthorized) => {
state.runtime.set_conn_state(state, account_id, ConnState::AuthFailed, Some("Stoat has ended this session. Sign in to Stoat again from Accounts."));
return;
}
_ => {
tracing::warn!("stoat[{account_id}]: {e:#}");
format!("{e:#}")
}
},
};
state.runtime.set_conn_state(state, account_id, ConnState::Connecting, None);
let wait = state.runtime.next_retry_delay(account_id, &mut delay, RECONNECT_INITIAL_DELAY, RECONNECT_MAX_DELAY);
tracing::info!("stoat[{account_id}]: not connected ({detail}); trying again in {}s", wait.as_secs());
state.runtime.report_retry(state, account_id, &detail, wait);
tokio::time::sleep(wait).await;
}
}
/// The address of the events socket: the instance's own, asking for what is wanted of `Ready` and nothing else. The
/// path is the instance's own and no slash is added to it: a self-hosted instance serves its events at `/ws` exactly,
/// and `/ws/` is somewhere else. An address with no path at all (the official one's) is written with the `/` a request
/// line needs.
fn socket_address(instance: &Instance) -> Result<String> {
let mut url = url::Url::parse(instance.ws.trim_end_matches('/')).context("the instance's events address")?;
url.set_query(Some("version=1&format=json&ready=users&ready=servers&ready=channels&ready=members&ready=emojis&ready=voice_states&ready=channel_unreads"));
Ok(url.to_string())
}
/// One go at being connected.
async fn run(state: &AppState, config: &StoatAccountConfig, account_id: &str) -> Result<Ending> {
let instance_url = if config.instance.is_empty() { api::DEFAULT_INSTANCE } else { config.instance.as_str() };
let api = Arc::new(Api::new(account_id, instance_url, Some(config.token.clone())));
let root = api::instance_info(&api).await?;
let instance = Arc::new(Instance::from_root(&root));
if instance.ws.is_empty() {
return Err(anyhow!("the instance did not say where its events are"));
}
// Who the session is, which also finds out whether it still is one.
let me = api.get("/users/@me", &[]).await.map_err(anyhow::Error::from)?;
let own_id = me["_id"].as_str().ok_or_else(|| anyhow!("Stoat did not say who this is"))?.to_string();
let username = format!("{}#{}", me["username"].as_str().unwrap_or(""), me["discriminator"].as_str().unwrap_or(""));
let _ = state.accounts.mutate_stoat(account_id, |a| a.username = username);
let (mut sink, mut stream) = route_socket(account_id, &instance).await?.split();
let (socket, mut outgoing) = tokio::sync::mpsc::unbounded_channel::<String>();
let world = Arc::new(Mutex::new(World::default()));
let handle = Handle { account_id: account_id.to_string(), api: api.clone(), world: world.clone(), instance: instance.clone(), socket };
// The token goes in a message, so it is never in an address a log could hold.
sink.send(Ws::text(json!({ "type": "Authenticate", "token": config.token }).to_string())).await.context("authenticating")?;
let mut ping = tokio::time::interval(PING_EVERY);
let mut pings: u64 = 0;
let mut heard = Instant::now();
let ending = loop {
tokio::select! {
frame = stream.next() => {
let Some(frame) = frame else { break Ending::Closed };
let frame = frame.context("reading the events")?;
heard = Instant::now();
let text = match frame {
Ws::Text(t) => t.to_string(),
Ws::Close(_) => break Ending::Closed,
_ => continue,
};
let Ok(event) = serde_json::from_str::<Value>(&text) else { continue };
match event["type"].as_str() {
Some("Authenticated") | Some("Pong") => {}
Some("Ready") => {
// The account is in the world even when Stoat names only those it has a relation with.
if let Some((id, user)) = super::world::parse_user(&me) {
world.lock().unwrap_or_else(|e| e.into_inner()).users.insert(id, user);
}
events::ready(state, &handle, &own_id, &event);
state.runtime.set_stoat_handle(account_id, handle.clone());
state.runtime.set_conn_state(state, account_id, ConnState::Connected, None);
tracing::info!("stoat[{account_id}]: connected as {own_id}");
// What the account has muted lives in its settings, which are asked for once it is up.
let (state, handle) = (state.clone(), handle.clone());
tokio::spawn(async move { super::settings::load(&state, &handle).await });
}
Some("Error") => {
let error = event["error"].as_str().unwrap_or("");
tracing::info!("stoat[{account_id}]: the events said {error}");
match classify(error) {
Ending::Closed => {}
other => break other,
}
}
Some("Logout") => break Ending::SignInAgain("Stoat has ended this session".to_string()),
Some("Auth") => {
// A session deleted: this one, or one of the account's others.
let ours = event["session_id"].as_str().is_some_and(|s| s == config.session_id) || event["event_type"].as_str() == Some("DeleteAllSessions");
if ours && !config.session_id.is_empty() {
break Ending::SignInAgain("Stoat has ended this session".to_string());
}
}
_ => events::handle(state, &handle, &event),
}
}
line = outgoing.recv() => {
match line {
Some(line) => sink.send(Ws::text(line)).await.context("writing to the events")?,
None => break Ending::Closed,
}
}
_ = ping.tick() => {
if heard.elapsed() > SILENT_FOR {
break Ending::Closed;
}
pings += 1;
sink.send(Ws::text(json!({ "type": "Ping", "data": pings }).to_string())).await.context("pinging")?;
}
}
};
state.runtime.clear_stoat_handle(account_id);
let _ = sink.close().await;
Ok(ending)
}
async fn route_socket(account_id: &str, instance: &Instance) -> Result<tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<crate::net::route::TcpOrSocks>>> {
let (socket, _) = crate::net::route::websocket(account_id, socket_address(instance)?).await.context("connecting to the events")?;
Ok(socket)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_session_stoat_ended_stops_the_connection_and_anything_else_is_tried_again() {
assert!(matches!(classify("InvalidSession"), Ending::SignInAgain(_)));
assert!(matches!(classify("NotAuthenticated"), Ending::SignInAgain(_)));
assert!(matches!(classify("OnboardingNotFinished"), Ending::SignInAgain(w) if w.contains("username")));
assert_eq!(classify("InternalError"), Ending::Closed);
assert_eq!(classify("AlreadyAuthenticated"), Ending::Closed);
}
#[test]
fn the_socket_asks_for_what_ready_is_needed_for_and_has_no_token_in_it() {
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
let path_and_query = |ws: &str| -> String {
let address = socket_address(&Instance { ws: ws.into(), ..Default::default() }).unwrap();
// As the websocket library writes it in the request line.
address.as_str().into_client_request().unwrap().uri().path_and_query().unwrap().to_string()
};
// The official instance has no path, and a request line needs one.
let official = path_and_query("wss://events.example/");
assert!(official.starts_with("/?version=1&format=json"), "{official}");
assert!(path_and_query("wss://events.example").starts_with("/?version=1"));
// A self-hosted one serves its events at /ws, and /ws/ is somewhere else.
let hosted = path_and_query("ws://localhost:8880/ws");
assert!(hosted.starts_with("/ws?version=1&format=json"), "{hosted}");
for want in ["ready=users", "ready=servers", "ready=channels", "ready=members", "ready=emojis", "ready=voice_states", "ready=channel_unreads"] {
assert!(official.contains(want), "{official} lacks {want}");
}
assert!(!official.contains("token"), "the token goes in a message");
assert!(socket_address(&Instance::default()).is_err(), "an instance that did not say where its events are has no address");
}
}
-854
View File
@@ -1,854 +0,0 @@
//! What the events websocket says: the first big answer (`Ready`), which makes the rail and the conversations, and
//! everything after - messages, edits, deletions, reactions, channels and servers changing, somebody typing, a
//! conversation read on another device.
use super::media;
use super::messages;
use super::world::{self, Channel, Kind, World};
use super::Handle;
use crate::model::BufferGroup;
use crate::state::AppState;
use serde_json::{json, Value};
use std::collections::HashSet;
/// How long somebody's "typing" lasts without another: Stoat sends one when it starts and one when it stops, and a
/// client that never hears the stop must not show it for ever.
const TYPING_FOR_MS: u64 = 8000;
fn now() -> i64 {
chrono::Utc::now().timestamp()
}
/// The rail entry for a server.
pub fn rail_id(account_id: &str, server_id: &str) -> String {
format!("{account_id}|server:{server_id}")
}
/// A server on the rail, with its name and picture.
fn register_server(state: &AppState, handle: &Handle, server: &world::Server, position: i64) {
let icon_url = server.icon.as_deref().and_then(|f| media::file_url(&handle.instance, "icons", f)).map(|u| media::cached(&handle.account_id, media::AVATARS, &u, None, None));
state.runtime.upsert_buffer_group(
state,
BufferGroup { id: rail_id(&handle.account_id, &server.id), account_id: handle.account_id.clone(), service: "stoat".to_string(), kind: "guild".to_string(), name: server.name.clone(), icon_url, position, pending: false },
);
}
/// What a name is in the list: the title, and where it is filed. A name another channel already has is told apart by
/// the end of this one's id.
fn unique_name(state: &AppState, account_id: &str, wanted: &str, channel_id: &str) -> String {
let taken = state.runtime.list_buffers().into_iter().any(|b| b.account_id == account_id && b.name == wanted && b.remote_id.as_deref() != Some(channel_id));
if taken {
format!("{wanted} ({})", &channel_id[channel_id.len().saturating_sub(4)..])
} else {
wanted.to_string()
}
}
/// A channel's conversation: made if it is not there, renamed if it was called something else, filed under its server
/// and its category, and told whether the account may write in it.
pub fn register_channel(state: &AppState, handle: &Handle, channel: &Channel, order: i64, heading: Option<(&str, i64)>) {
let account_id = &handle.account_id;
let (title, read_only, avatar) = {
let world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
if world.permissions(channel, now()) & world::VIEW_CHANNEL == 0 {
return;
}
let avatar = match channel.kind {
Kind::Direct => channel.recipients.iter().find(|r| **r != world.own_id).and_then(|r| world.users.get(r)).and_then(|u| u.avatar.clone()).and_then(|f| media::file_url(&handle.instance, "avatars", &f)),
Kind::Group => channel.icon.as_deref().and_then(|f| media::file_url(&handle.instance, "icons", f)),
_ => None,
};
(world.title_of(channel), world.read_only(channel, now()), avatar)
};
// The older kind of voice channel has no text: it is joined, which is a later round.
if channel.kind == Kind::Voice {
return;
}
let kind = if matches!(channel.kind, Kind::Direct | Kind::Saved) { "dm" } else { "channel" };
let existing = state.runtime.list_buffers().into_iter().find(|b| b.account_id == *account_id && b.remote_id.as_deref() == Some(channel.id.as_str()));
let name = unique_name(state, account_id, &title, &channel.id);
let buffer = match existing {
Some(found) if found.name == name => found,
Some(found) => {
state.runtime.rename_buffer(state, account_id, &found.name, &name, kind);
state.runtime.get_buffer(&crate::model::buffer_id(account_id, &name)).unwrap_or(found)
}
None => state.runtime.ensure_buffer(state, account_id, &name, kind),
};
state.runtime.set_buffer_remote_id(state, &buffer.id, &channel.id);
match (&channel.server, heading) {
(Some(server), heading) => {
state.runtime.set_buffer_group(state, &buffer.id, &rail_id(account_id, server));
// Under the server's own heading, in the server's own order: the heading's place first, then the channel's.
match heading {
Some((title, place)) => state.runtime.set_buffer_category(state, &buffer.id, Some(title), place * 1000 + order),
None => state.runtime.set_buffer_category(state, &buffer.id, None, order),
}
}
(None, _) => {
let heading = if channel.kind == Kind::Group { "Groups" } else { "Direct Messages" };
state.runtime.set_buffer_category(state, &buffer.id, Some(heading), 0);
}
}
state.runtime.set_buffer_read_only(state, &buffer.id, read_only);
if let Some(topic) = &channel.description {
state.runtime.set_buffer_topic(state, &buffer.id, topic);
}
// A channel that can also be called into is marked, as a horn marks a Telegram channel.
state.runtime.set_buffer_name_icon(state, &buffer.id, channel.voice.then_some("volume_up"));
if let Some(url) = avatar {
state.runtime.set_buffer_avatar(state, &buffer.id, &media::cached(account_id, media::AVATARS, &url, None, None));
}
}
/// A server and its channels, made at once: one just joined.
pub fn register_server_entry(state: &AppState, handle: &Handle, server: &world::Server) {
register_server(state, handle, server, 0);
register_server_channels(state, handle, server);
}
/// A server's channels under its headings.
fn register_server_channels(state: &AppState, handle: &Handle, server: &world::Server) {
let channels: Vec<Channel> = {
let world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
server.channels.iter().filter_map(|id| world.channels.get(id).cloned()).collect()
};
for (order, channel) in channels.iter().enumerate() {
let heading = server.categories.iter().enumerate().find(|(_, c)| c.channels.contains(&channel.id)).map(|(place, c)| (c.title.as_str(), place as i64 + 1));
register_channel(state, handle, channel, order as i64, heading);
}
}
/// Keeps who is in each voice channel as an event says, and tells the window of the channels it touched. A call that
/// begins in a direct message or a group, with somebody else in it and this account not, is ringing; it stops when this
/// account joins (from anywhere) or when the last person leaves.
fn voice_changed(state: &AppState, handle: &Handle, event: &Value) {
let mut touched: Vec<String> = Vec::new();
let mut rings: Vec<(String, bool)> = Vec::new();
{
let mut world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
let own = world.own_id.clone();
let personal = |world: &World, channel: &str| world.channels.get(channel).is_some_and(|c| matches!(c.kind, Kind::Direct | Kind::Group));
match event["type"].as_str().unwrap_or("") {
"VoiceChannelJoin" => {
if let (Some(channel), Some(member)) = (event["id"].as_str(), world::parse_voice_member(&event["state"])) {
let calling = personal(&world, channel);
let list = world.voice.entry(channel.to_string()).or_default();
let first = list.is_empty();
let joiner = member.user.clone();
list.retain(|m| m.user != member.user);
list.push(member);
touched.push(channel.to_string());
if calling {
if joiner == own {
rings.push((channel.to_string(), false));
} else if first {
rings.push((channel.to_string(), true));
}
}
}
}
"VoiceChannelLeave" => {
if let (Some(channel), Some(user)) = (event["id"].as_str(), event["user"].as_str()) {
if let Some(list) = world.voice.get_mut(channel) {
list.retain(|m| m.user != user);
if list.is_empty() || user == own {
rings.push((channel.to_string(), false));
}
}
touched.push(channel.to_string());
}
}
"VoiceChannelMove" => {
if let (Some(from), Some(to), Some(member)) = (event["from"].as_str(), event["to"].as_str(), world::parse_voice_member(&event["state"])) {
if let Some(list) = world.voice.get_mut(from) {
list.retain(|m| m.user != member.user);
}
let list = world.voice.entry(to.to_string()).or_default();
list.retain(|m| m.user != member.user);
list.push(member);
touched.extend([from.to_string(), to.to_string()]);
}
}
_ => {
if let (Some(channel), Some(user)) = (event["channel_id"].as_str(), event["id"].as_str()) {
if let Some(member) = world.voice.get_mut(channel).and_then(|l| l.iter_mut().find(|m| m.user == user)) {
let data = &event["data"];
if let Some(v) = data["camera"].as_bool() {
member.camera = v;
}
if let Some(v) = data["screensharing"].as_bool() {
member.screen = v;
}
if let Some(v) = data["is_publishing"].as_bool() {
member.publishing = v;
}
touched.push(channel.to_string());
}
}
}
}
world.voice.retain(|_, l| !l.is_empty());
for (channel, ringing) in &rings {
if *ringing {
world.ringing.insert(channel.clone());
} else {
world.ringing.remove(channel);
}
}
}
for channel in touched {
tell_voice(state, handle, &channel);
}
for (channel, ringing) in rings {
tell_ring(state, handle, &channel, ringing);
}
}
/// Says a conversation started or stopped ringing.
fn tell_ring(state: &AppState, handle: &Handle, channel: &str, ringing: bool) {
if let Some(buffer) = messages::buffer_of(state, &handle.account_id, channel) {
state.events.emit("incomingCall", json!({ "accountId": handle.account_id, "bufferId": buffer.id, "channelId": channel, "ringing": ringing }));
}
}
/// The conversations ringing now, for a window that opens in the middle of it.
pub fn ringing(state: &AppState, handle: &Handle) -> Vec<Value> {
let channels: Vec<String> = handle.world.lock().unwrap_or_else(|e| e.into_inner()).ringing.iter().cloned().collect();
channels.into_iter().filter_map(|c| messages::buffer_of(state, &handle.account_id, &c).map(|b| json!({ "accountId": handle.account_id, "bufferId": b.id, "channelId": c, "ringing": true }))).collect()
}
/// Says who is in a channel's call, in the words the window draws.
pub fn tell_voice(state: &AppState, handle: &Handle, channel: &str) {
let Some(buffer) = messages::buffer_of(state, &handle.account_id, channel) else { return };
state.events.emit("stoatVoice", json!({ "accountId": handle.account_id, "bufferId": buffer.id, "members": voice_members(handle, channel) }));
}
/// The people in a channel's call.
pub fn voice_members(handle: &Handle, channel: &str) -> Vec<Value> {
let world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
world
.voice
.get(channel)
.into_iter()
.flatten()
.map(|m| {
let avatar = world.users.get(&m.user).and_then(|u| u.avatar.clone()).and_then(|f| media::file_url(&handle.instance, "avatars", &f)).map(|u| media::cached(&handle.account_id, media::AVATARS, &u, None, None));
json!({ "userId": m.user, "name": world.name_of(&m.user), "avatarUrl": avatar, "camera": m.camera, "screen": m.screen, "muted": !m.publishing })
})
.collect()
}
/// The custom emoji the account can use, as the picker's sections: one for each server that has some, and one for the
/// rest. Said again whenever an emoji is made or deleted.
pub fn register_emoji(state: &AppState, handle: &Handle) {
/// Each server's emoji (the ones with no server together), by server, and what each server is called and shown as.
type ByServer = std::collections::BTreeMap<Option<String>, Vec<(String, world::EmojiInfo)>>;
type Names = std::collections::HashMap<String, (String, Option<String>)>;
let account_id = &handle.account_id;
let (groups, names): (ByServer, Names) = {
let world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
let mut groups: ByServer = Default::default();
for (id, info) in &world.emojis {
groups.entry(info.server.clone()).or_default().push((id.clone(), info.clone()));
}
let names = world.servers.iter().map(|(id, s)| (id.clone(), (s.name.clone(), s.icon.clone()))).collect();
(groups, names)
};
state.runtime.clear_emoji_sources(account_id, "stoat");
for (server, mut list) in groups {
list.sort_by_key(|(_, e)| e.name.to_lowercase());
let (name, icon) = server.as_ref().and_then(|s| names.get(s).cloned()).unwrap_or_else(|| ("Your emoji".to_string(), None));
let buffers: Vec<String> = match &server {
Some(s) => state.runtime.list_buffers().into_iter().filter(|b| b.account_id == *account_id && b.group_id.as_deref() == Some(rail_id(account_id, s).as_str())).map(|b| b.id).collect(),
None => Vec::new(),
};
let entries = list
.into_iter()
.map(|(id, info)| crate::model::EmojiEntry {
url: media::file_url(&handle.instance, "emojis", &id).map(|u| media::cached(account_id, media::MEDIA, &u, None, Some(if info.animated { "image/gif" } else { "image/png" }))),
id,
name: info.name,
animated: info.animated,
locked: false,
})
.collect();
state.runtime.set_emoji_source(
account_id,
crate::model::EmojiSource {
id: format!("stoat:{}", server.as_deref().unwrap_or("detached")),
name,
service: "stoat".to_string(),
kind: "text".to_string(),
icon_url: icon.as_deref().and_then(|f| media::file_url(&handle.instance, "icons", f)).map(|u| media::cached(account_id, media::AVATARS, &u, None, None)),
buffers,
// Stoat lets an account use an emoji from another of its servers.
sendable_anywhere: true,
emoji: entries,
},
);
}
}
/// Everything `Ready` carries, made into the rail and the conversations.
pub fn register_all(state: &AppState, handle: &Handle, ready: &Value) {
let (servers, others, name) = {
let world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
let servers: Vec<world::Server> = {
let mut list: Vec<world::Server> = world.servers.values().cloned().collect();
list.sort_by_key(|s| s.name.to_lowercase());
list
};
let others: Vec<Channel> = world.channels.values().filter(|c| c.server.is_none()).cloned().collect();
(servers, others, world.name_of(&world.own_id))
};
state.runtime.set_own_identity(&handle.account_id, &name);
for (position, server) in servers.iter().enumerate() {
register_server(state, handle, server, position as i64);
register_server_channels(state, handle, server);
}
for channel in &others {
register_channel(state, handle, channel, 0, None);
}
register_emoji(state, handle);
let in_calls: Vec<String> = handle.world.lock().unwrap_or_else(|e| e.into_inner()).voice.keys().cloned().collect();
for channel in in_calls {
tell_voice(state, handle, &channel);
}
// Gone since the last time: a server left, a channel closed.
let present: HashSet<String> = handle.world.lock().unwrap_or_else(|e| e.into_inner()).channels.keys().cloned().collect();
for buffer in state.runtime.list_buffers() {
if buffer.account_id == handle.account_id && buffer.remote_id.as_ref().is_some_and(|r| !present.contains(r)) {
state.runtime.remove_buffer(state, &buffer.id);
}
}
let rails: HashSet<String> = servers.iter().map(|s| rail_id(&handle.account_id, &s.id)).collect();
for group in state.runtime.list_buffer_groups() {
if group.account_id == handle.account_id && group.service == "stoat" && group.kind == "guild" && !rails.contains(&group.id) {
state.runtime.remove_buffer_group(state, &group.id);
}
}
// What was unread, as far as Stoat says it: where this account last read to, and how many mentions since.
for unread in ready["channel_unreads"].as_array().into_iter().flatten() {
let (Some(channel), Some(last)) = (unread["_id"]["channel"].as_str(), unread["last_id"].as_str()) else { continue };
let newest = handle.world.lock().unwrap_or_else(|e| e.into_inner()).channels.get(channel).and_then(|c| c.last_message_id.clone());
let mentions = unread["mentions"].as_array().map(|m| m.len() as u32).unwrap_or(0);
if let (Some(buffer), Some(newest)) = (messages::buffer_of(state, &handle.account_id, channel), newest) {
// Stoat says where reading stopped and where the channel ends, not how many lie between.
let unread_here = u32::from(newest.as_str() > last);
state.runtime.set_buffer_unread(state, &buffer.id, unread_here.max(mentions), mentions);
}
}
tracing::info!("stoat[{}]: {} servers, {} conversations", handle.account_id, servers.len(), handle.world.lock().unwrap_or_else(|e| e.into_inner()).channels.len());
}
fn typing(state: &AppState, handle: &Handle, channel: &str, user: &str) {
let (own, name) = {
let world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
(world.own_id.clone(), world.name_of(user))
};
if user == own {
return;
}
if let Some(buffer) = messages::buffer_of(state, &handle.account_id, channel) {
state.events.emit("typing", json!({ "accountId": handle.account_id, "bufferId": buffer.id, "nicks": [name], "expiresInMs": TYPING_FOR_MS }));
}
}
/// The message an edit or an append touched, brought up to date.
fn edited(state: &AppState, handle: &Handle, event: &Value) {
let (Some(id), Some(channel)) = (event["id"].as_str(), event["channel"].as_str()) else { return };
let Some(buffer) = messages::buffer_of(state, &handle.account_id, channel) else { return };
let data = &event["data"];
if data.get("pinned").is_some() {
super::search::announce(state, handle, &buffer);
}
if let Some(content) = data["content"].as_str() {
let body = {
let world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
let server = world.channels.get(channel).and_then(|c| c.server.clone());
messages::words(&world, Some((&handle.instance, &handle.account_id)), server.as_deref(), content)
};
state.runtime.update_message_body_only(state, &buffer.id, id, &body);
}
}
fn reaction(state: &AppState, handle: &Handle, event: &Value, add: bool) {
let (Some(id), Some(channel), Some(user), Some(emoji)) = (event["id"].as_str(), event["channel_id"].as_str(), event["user_id"].as_str(), event["emoji_id"].as_str()) else { return };
let Some(buffer) = messages::buffer_of(state, &handle.account_id, channel) else { return };
let world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
let me = user == world.own_id;
let shown = messages::reaction_key(&world, Some((&handle.instance, &handle.account_id)), emoji);
drop(world);
state.runtime.update_reaction(state, &buffer.id, id, &shown, me, add);
}
/// One event.
pub fn handle(state: &AppState, handle: &Handle, event: &Value) {
let Some(kind) = event["type"].as_str() else { return };
let account_id = &handle.account_id;
match kind {
"Bulk" => {
for inner in event["v"].as_array().into_iter().flatten() {
self::handle(state, handle, inner);
}
}
"Message" => {
messages::record(state, handle, event);
}
"MessageUpdate" | "MessageAppend" => edited(state, handle, event),
"MessageDelete" => {
if let (Some(id), Some(channel)) = (event["id"].as_str(), event["channel"].as_str()) {
if let Some(buffer) = messages::buffer_of(state, account_id, channel) {
state.runtime.delete_message(state, &buffer.id, id);
}
}
}
"BulkMessageDelete" => {
if let (Some(ids), Some(channel)) = (event["ids"].as_array(), event["channel"].as_str()) {
if let Some(buffer) = messages::buffer_of(state, account_id, channel) {
for id in ids.iter().filter_map(Value::as_str) {
state.runtime.delete_message(state, &buffer.id, id);
}
}
}
}
"MessageReact" => reaction(state, handle, event, true),
"MessageUnreact" => reaction(state, handle, event, false),
"MessageRemoveReaction" => {
if let (Some(id), Some(channel)) = (event["id"].as_str(), event["channel_id"].as_str()) {
if let Some(buffer) = messages::buffer_of(state, account_id, channel) {
let emoji = event["emoji_id"].as_str().unwrap_or("");
state.runtime.remove_reaction_entirely(state, &buffer.id, id, emoji);
}
}
}
"ChannelCreate" => {
if let Some(channel) = world::parse_channel(event) {
handle.world.lock().unwrap_or_else(|e| e.into_inner()).channels.insert(channel.id.clone(), channel.clone());
if let Some(server) = channel.server.as_ref().and_then(|s| handle.world.lock().unwrap_or_else(|e| e.into_inner()).servers.get(s).cloned()) {
register_server_channels(state, handle, &server);
} else {
register_channel(state, handle, &channel, 0, None);
}
}
}
"ChannelUpdate" => {
let Some(id) = event["id"].as_str() else { return };
let updated = {
let mut world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
if let Some(channel) = world.channels.get_mut(id) {
let data = &event["data"];
if let Some(name) = data["name"].as_str() {
channel.name = name.to_string();
}
if data.get("description").is_some() {
channel.description = data["description"].as_str().filter(|d| !d.is_empty()).map(str::to_string);
}
if data.get("default_permissions").is_some() {
channel.default = (data["default_permissions"]["a"].as_u64().unwrap_or(0), data["default_permissions"]["d"].as_u64().unwrap_or(0));
}
if let Some(roles) = data["role_permissions"].as_object() {
channel.roles = roles.iter().map(|(k, p)| (k.clone(), (p["a"].as_u64().unwrap_or(0), p["d"].as_u64().unwrap_or(0)))).collect();
}
if let Some(p) = data["permissions"].as_u64() {
channel.group_permissions = Some(p);
}
}
world.channels.get(id).cloned()
};
if let Some(channel) = updated {
match channel.server.as_ref().and_then(|s| handle.world.lock().unwrap_or_else(|e| e.into_inner()).servers.get(s).cloned()) {
Some(server) => register_server_channels(state, handle, &server),
None => register_channel(state, handle, &channel, 0, None),
}
}
}
"ChannelDelete" => {
if let Some(id) = event["id"].as_str() {
handle.world.lock().unwrap_or_else(|e| e.into_inner()).channels.remove(id);
if let Some(buffer) = messages::buffer_of(state, account_id, id) {
state.runtime.remove_buffer(state, &buffer.id);
}
}
}
"ChannelStartTyping" => {
if let (Some(channel), Some(user)) = (event["id"].as_str(), event["user"].as_str()) {
typing(state, handle, channel, user);
}
}
"ChannelAck" => {
let own = handle.world.lock().unwrap_or_else(|e| e.into_inner()).own_id.clone();
if event["user"].as_str() == Some(own.as_str()) {
if let Some(buffer) = event["id"].as_str().and_then(|c| messages::buffer_of(state, account_id, c)) {
state.runtime.set_buffer_unread(state, &buffer.id, 0, 0);
state.events.emit("bufferRead", json!({ "accountId": account_id, "bufferId": buffer.id }));
}
}
}
"ServerCreate" => {
let mut world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
let server = world::parse_server(&event["server"]);
let channels: Vec<Channel> = event["channels"].as_array().into_iter().flatten().filter_map(world::parse_channel).collect();
let member = world::parse_member(&json!({ "_id": { "server": event["server"]["_id"], "user": world.own_id }, "roles": [] }));
for channel in channels {
world.channels.insert(channel.id.clone(), channel);
}
if let Some(server) = &server {
if let Some((s, _, m)) = member {
world.members.entry(s).or_insert(m);
}
world.servers.insert(server.id.clone(), server.clone());
}
drop(world);
if let Some(server) = server {
register_server(state, handle, &server, 0);
register_server_channels(state, handle, &server);
}
}
"ServerUpdate" => {
let Some(id) = event["id"].as_str() else { return };
let updated = {
let mut world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
if let Some(server) = world.servers.get_mut(id) {
let data = &event["data"];
if let Some(name) = data["name"].as_str() {
server.name = name.to_string();
}
if let Some(p) = data["default_permissions"].as_u64() {
server.default_permissions = p;
}
if let Some(parsed) = world::parse_server(&json!({ "_id": id, "categories": data["categories"], "channels": data["channels"] })) {
if data.get("categories").is_some() {
server.categories = parsed.categories;
}
if data.get("channels").is_some() {
server.channels = parsed.channels;
}
}
if data.get("icon").is_some() {
server.icon = data["icon"]["_id"].as_str().map(str::to_string);
}
}
world.servers.get(id).cloned()
};
if let Some(server) = updated {
register_server(state, handle, &server, 0);
register_server_channels(state, handle, &server);
}
}
"ServerDelete" => {
if let Some(id) = event["id"].as_str() {
let channels: Vec<String> = {
let mut world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
world.members.remove(id);
let gone = world.servers.remove(id).map(|s| s.channels).unwrap_or_default();
for c in &gone {
world.channels.remove(c);
}
gone
};
for channel in channels {
if let Some(buffer) = messages::buffer_of(state, account_id, &channel) {
state.runtime.remove_buffer(state, &buffer.id);
}
}
state.runtime.remove_buffer_group(state, &rail_id(account_id, id));
}
}
"ServerMemberUpdate" => {
let own = handle.world.lock().unwrap_or_else(|e| e.into_inner()).own_id.clone();
if event["id"]["user"].as_str() != Some(own.as_str()) {
return;
}
let Some(server_id) = event["id"]["server"].as_str() else { return };
{
let mut world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
let member = world.members.entry(server_id.to_string()).or_default();
let data = &event["data"];
if let Some(roles) = data["roles"].as_array() {
member.roles = roles.iter().filter_map(|r| r.as_str().map(str::to_string)).collect();
}
if data.get("timeout").is_some() {
member.timeout = data["timeout"].as_str().and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok()).map(|t| t.timestamp());
}
if let Some(nick) = data["nickname"].as_str() {
member.nickname = Some(nick.to_string());
}
if let Some(c) = event["clear"].as_array() {
if c.iter().any(|x| x.as_str() == Some("Timeout")) {
member.timeout = None;
}
}
}
// Looked up first, and the lock let go: making the channels takes it again.
let server = handle.world.lock().unwrap_or_else(|e| e.into_inner()).servers.get(server_id).cloned();
if let Some(server) = server {
register_server_channels(state, handle, &server);
}
}
"ServerRoleUpdate" => {
let (Some(server_id), Some(role_id)) = (event["id"].as_str(), event["role_id"].as_str()) else { return };
{
let mut world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
if let Some(server) = world.servers.get_mut(server_id) {
let role = server.roles.entry(role_id.to_string()).or_default();
let data = &event["data"];
if let Some(name) = data["name"].as_str() {
role.name = name.to_string();
}
if let Some(rank) = data["rank"].as_i64() {
role.rank = rank;
}
if data.get("permissions").is_some() {
role.allow = data["permissions"]["a"].as_u64().unwrap_or(0);
role.deny = data["permissions"]["d"].as_u64().unwrap_or(0);
}
if let Some(c) = data["colour"].as_str() {
role.colour = Some(c.to_string());
}
}
}
// Looked up first, and the lock let go: making the channels takes it again.
let server = handle.world.lock().unwrap_or_else(|e| e.into_inner()).servers.get(server_id).cloned();
if let Some(server) = server {
register_server_channels(state, handle, &server);
}
}
"ServerRoleDelete" => {
if let (Some(server_id), Some(role_id)) = (event["id"].as_str(), event["role_id"].as_str()) {
if let Some(server) = handle.world.lock().unwrap_or_else(|e| e.into_inner()).servers.get_mut(server_id) {
server.roles.remove(role_id);
}
}
}
"UserUpdate" => {
let Some(id) = event["id"].as_str() else { return };
let mut world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
if let Some(user) = world.users.get_mut(id) {
let data = &event["data"];
if let Some(name) = data["display_name"].as_str() {
user.name = name.to_string();
}
if let Some(online) = data["online"].as_bool() {
user.online = online;
}
if data.get("avatar").is_some() {
user.avatar = data["avatar"]["_id"].as_str().map(str::to_string);
}
if let Some(presence) = data["status"]["presence"].as_str() {
user.presence = presence.to_string();
}
if data["status"].get("text").is_some() {
user.status_text = data["status"]["text"].as_str().filter(|t| !t.is_empty()).map(str::to_string);
}
if let Some(clear) = event["clear"].as_array() {
if clear.iter().any(|c| c.as_str() == Some("StatusText")) {
user.status_text = None;
}
}
}
drop(world);
super::members::update_status(state, handle, id);
}
"ServerMemberJoin" | "ServerMemberLeave" => {
// Whoever's list is open for the server is read again.
let Some(server) = event["id"].as_str() else { return };
let channels: Vec<String> = handle.world.lock().unwrap_or_else(|e| e.into_inner()).servers.get(server).map(|s| s.channels.clone()).unwrap_or_default();
for channel in channels {
if let Some(buffer) = messages::buffer_of(state, account_id, &channel) {
if state.runtime.get_presence(&buffer.id).is_some() {
let (state, handle) = (state.clone(), handle.clone());
tokio::spawn(async move {
let _ = super::members::refresh(&state, &handle, &buffer).await;
});
}
}
}
}
"UserRelationship" => {
// The other person, as Stoat sees them (with what they are to this account in `relationship`); an older
// instance says it beside, in `status`, and names the person by id.
{
let mut world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
match world::parse_user(&event["user"]) {
Some((id, parsed)) => {
let status = event["status"].as_str().map(str::to_string).unwrap_or_else(|| parsed.relationship.clone());
let entry = world.users.entry(id).or_insert_with(|| parsed.clone());
entry.name = parsed.name;
entry.avatar = parsed.avatar;
entry.relationship = status;
}
None => {
if let (Some(id), Some(status)) = (event["user"].as_str(), event["status"].as_str()) {
if let Some(user) = world.users.get_mut(id) {
user.relationship = status.to_string();
}
}
}
}
}
// A conversation with them may now be one that can be written in, or no longer.
let other = event["user"]["_id"].as_str().or(event["user"].as_str()).unwrap_or("").to_string();
let direct: Vec<Channel> = handle.world.lock().unwrap_or_else(|e| e.into_inner()).channels.values().filter(|c| c.kind == Kind::Direct && c.recipients.contains(&other)).cloned().collect();
for channel in direct {
register_channel(state, handle, &channel, 0, None);
}
super::people::tell_requests(state, handle);
}
// Who is in a voice channel: told as they come, go, move, and mute or turn a camera on.
"VoiceChannelJoin" | "VoiceChannelLeave" | "VoiceChannelMove" | "UserVoiceStateUpdate" => voice_changed(state, handle, event),
"ChannelGroupJoin" | "ChannelGroupLeave" => {
let (Some(id), Some(user)) = (event["id"].as_str(), event["user"].as_str()) else { return };
let own = handle.world.lock().unwrap_or_else(|e| e.into_inner()).own_id.clone();
if kind == "ChannelGroupLeave" && user == own {
// This account was taken out of the group, or left it elsewhere.
handle.world.lock().unwrap_or_else(|e| e.into_inner()).channels.remove(id);
if let Some(buffer) = messages::buffer_of(state, account_id, id) {
state.runtime.remove_buffer(state, &buffer.id);
}
return;
}
if let Some(channel) = handle.world.lock().unwrap_or_else(|e| e.into_inner()).channels.get_mut(id) {
channel.recipients.retain(|r| r != user);
if kind == "ChannelGroupJoin" {
channel.recipients.push(user.to_string());
}
}
if let Some(buffer) = messages::buffer_of(state, account_id, id) {
if state.runtime.get_presence(&buffer.id).is_some() {
let (state, handle) = (state.clone(), handle.clone());
tokio::spawn(async move {
let _ = super::members::refresh(&state, &handle, &buffer).await;
});
}
}
}
"ServerRoleRanksUpdate" => {
if let (Some(server), Some(ranks)) = (event["id"].as_str(), event["ranks"].as_array()) {
if let Some(server) = handle.world.lock().unwrap_or_else(|e| e.into_inner()).servers.get_mut(server) {
for (place, id) in ranks.iter().filter_map(Value::as_str).enumerate() {
if let Some(role) = server.roles.get_mut(id) {
role.rank = place as i64;
}
}
}
}
}
"UserSettingsUpdate" => super::settings::changed(state, handle, &event["update"]),
"ChannelStopTyping" => {}
"UserPresence" => {
if let (Some(id), Some(online)) = (event["id"].as_str(), event["online"].as_bool()) {
if let Some(user) = handle.world.lock().unwrap_or_else(|e| e.into_inner()).users.get_mut(id) {
user.online = online;
}
super::members::update_status(state, handle, id);
}
}
"EmojiCreate" => {
if let Some((id, info)) = world::parse_emoji(event) {
handle.world.lock().unwrap_or_else(|e| e.into_inner()).emojis.insert(id, info);
register_emoji(state, handle);
}
}
"EmojiDelete" => {
if let Some(id) = event["id"].as_str() {
handle.world.lock().unwrap_or_else(|e| e.into_inner()).emojis.remove(id);
register_emoji(state, handle);
}
}
_ => {}
}
}
/// Makes the world from `Ready`, and the conversations from the world.
pub fn ready(state: &AppState, handle: &Handle, own_id: &str, event: &Value) {
{
let mut world: std::sync::MutexGuard<'_, World> = handle.world.lock().unwrap_or_else(|e| e.into_inner());
world.absorb_ready(own_id, event);
}
register_all(state, handle, event);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_relationship_event_names_the_other_person_and_what_they_are_to_this_account() {
let state = crate::backend::kick::testkit::simulated_daemon("stoat-relationship");
let h = crate::backend::stoat::tests::bare_handle("stoat:ME");
h.world.lock().unwrap().own_id = "ME".into();
handle(&state, &h, &json!({ "type": "UserRelationship", "id": "ME", "user": { "_id": "ERIN", "username": "erin", "relationship": "Incoming" } }));
assert_eq!(super::super::people::incoming(&h).iter().map(|r| r["name"].as_str().unwrap().to_string()).collect::<Vec<_>>(), vec!["erin"]);
handle(&state, &h, &json!({ "type": "UserRelationship", "id": "ME", "user": { "_id": "ERIN", "username": "erin", "relationship": "Friend" } }));
assert!(super::super::people::incoming(&h).is_empty(), "answered: no longer waiting");
}
/// A role changing or an own membership changing makes the server's channels again, which takes the world's lock: an
/// event handled while still holding it would never come back (found against a real instance, where it stopped the
/// whole connection).
#[test]
fn a_role_or_membership_change_does_not_wait_on_itself() {
let state = crate::backend::kick::testkit::simulated_daemon("stoat-deadlock");
let h = crate::backend::stoat::tests::bare_handle("stoat:ME");
{
let mut w = h.world.lock().unwrap();
w.own_id = "ME".into();
w.servers.insert("S".into(), world::parse_server(&json!({ "_id": "S", "owner": "ME", "name": "Hall", "channels": ["C"] })).unwrap());
w.channels.insert("C".into(), world::parse_channel(&json!({ "_id": "C", "channel_type": "TextChannel", "server": "S", "name": "general" })).unwrap());
}
let (done, heard) = std::sync::mpsc::channel();
std::thread::spawn(move || {
handle(&state, &h, &json!({ "type": "ServerRoleUpdate", "id": "S", "role_id": "R", "data": { "name": "Mods", "rank": 1 } }));
handle(&state, &h, &json!({ "type": "ServerMemberUpdate", "id": { "server": "S", "user": "ME" }, "data": { "roles": ["R"] } }));
handle(&state, &h, &json!({ "type": "ServerUpdate", "id": "S", "data": { "name": "Hall 2" } }));
handle(&state, &h, &json!({ "type": "ChannelUpdate", "id": "C", "data": { "name": "lobby" } }));
let _ = done.send(());
});
assert!(heard.recv_timeout(std::time::Duration::from_secs(10)).is_ok(), "an event took the world's lock twice");
}
#[test]
fn a_call_begun_in_a_direct_message_rings_until_this_account_joins_or_it_ends() {
let state = crate::backend::kick::testkit::simulated_daemon("stoat-ring");
let h = crate::backend::stoat::tests::bare_handle("stoat:ME");
{
let mut w = h.world.lock().unwrap();
w.own_id = "ME".into();
w.channels.insert("DM".into(), world::parse_channel(&json!({ "_id": "DM", "channel_type": "DirectMessage", "recipients": ["ME", "PAL"] })).unwrap());
w.channels.insert("VC".into(), world::parse_channel(&json!({ "_id": "VC", "channel_type": "TextChannel", "server": "S", "name": "v", "voice": {} })).unwrap());
}
handle(&state, &h, &json!({ "type": "VoiceChannelJoin", "id": "VC", "state": { "id": "PAL" } }));
assert!(h.world.lock().unwrap().ringing.is_empty(), "a server's voice channel never rings");
handle(&state, &h, &json!({ "type": "VoiceChannelJoin", "id": "DM", "state": { "id": "PAL" } }));
assert!(h.world.lock().unwrap().ringing.contains("DM"));
handle(&state, &h, &json!({ "type": "VoiceChannelJoin", "id": "DM", "state": { "id": "ME" } }));
assert!(h.world.lock().unwrap().ringing.is_empty(), "answered, from here or another app");
handle(&state, &h, &json!({ "type": "VoiceChannelLeave", "id": "DM", "user": "ME" }));
handle(&state, &h, &json!({ "type": "VoiceChannelLeave", "id": "DM", "user": "PAL" }));
handle(&state, &h, &json!({ "type": "VoiceChannelJoin", "id": "DM", "state": { "id": "PAL" } }));
handle(&state, &h, &json!({ "type": "VoiceChannelLeave", "id": "DM", "user": "PAL" }));
assert!(h.world.lock().unwrap().ringing.is_empty(), "the caller gave up");
}
#[test]
fn who_is_in_a_call_follows_the_events_and_leaves_with_the_last_one() {
let state = crate::backend::kick::testkit::simulated_daemon("stoat-voice");
let h = crate::backend::stoat::tests::bare_handle("stoat:ME");
h.world.lock().unwrap().absorb_ready("ME", &json!({ "voice_states": [{ "id": "C", "participants": [{ "id": "U1", "is_publishing": true, "camera": false, "screensharing": false }] }] }));
assert_eq!(h.world.lock().unwrap().voice["C"].len(), 1, "who was already in it when the account connected");
handle(&state, &h, &json!({ "type": "VoiceChannelJoin", "id": "C", "state": { "id": "U2", "is_publishing": true } }));
handle(&state, &h, &json!({ "type": "UserVoiceStateUpdate", "id": "U2", "channel_id": "C", "data": { "camera": true, "is_publishing": false } }));
{
let w = h.world.lock().unwrap();
let two = w.voice["C"].iter().find(|m| m.user == "U2").unwrap();
assert!(two.camera && !two.publishing, "a camera on and a microphone off");
}
handle(&state, &h, &json!({ "type": "VoiceChannelMove", "user": "U2", "from": "C", "to": "D", "state": { "id": "U2" } }));
assert!(h.world.lock().unwrap().voice["D"].iter().any(|m| m.user == "U2") && h.world.lock().unwrap().voice["C"].iter().all(|m| m.user != "U2"));
handle(&state, &h, &json!({ "type": "VoiceChannelLeave", "id": "C", "user": "U1" }));
assert!(!h.world.lock().unwrap().voice.contains_key("C"), "an empty call is not kept");
assert_eq!(voice_members(&h, "D").len(), 1);
}
}
-121
View File
@@ -1,121 +0,0 @@
//! The limits an instance states, kept to before a request is made rather than learned from a refusal.
//!
//! Each is a question with a plain answer from what the account already holds - how many servers it is in, how many
//! requests it has outstanding, how many different reactions a message has - so none costs a request, and the
//! answer is said in words that name the number. Stoat still checks, and its own refusal still passes through.
use super::world::{Instance, UserLimits, World};
use super::Handle;
/// Whether one more server may be joined.
pub fn may_join(limits: &UserLimits, servers_held: usize) -> Result<(), String> {
if servers_held >= limits.servers {
return Err(format!("This account is in {servers_held} servers, which is the most Stoat lets it join ({}). Leave one first", limits.servers));
}
Ok(())
}
/// Whether one more friend request may be sent: those still waiting for an answer count.
pub fn may_ask(limits: &UserLimits, world: &World) -> Result<(), String> {
let waiting = world.users.values().filter(|u| u.relationship == "Outgoing").count();
if waiting >= limits.outgoing_friend_requests {
return Err(format!("This account has {waiting} friend requests waiting for an answer, which is the most Stoat allows at once ({}). Wait for some to be answered", limits.outgoing_friend_requests));
}
Ok(())
}
/// Whether a reaction may be put on a message that already carries these (a new kind counts against the limit; one
/// that is already there does not).
pub fn may_react(instance: &Instance, already: &[String], emoji: &str) -> Result<(), String> {
if !already.iter().any(|e| e == emoji) && already.len() >= instance.message_reactions {
return Err(format!("A message may carry at most {} different reactions on this instance", instance.message_reactions));
}
Ok(())
}
/// Whether a file may be a picture of one kind.
pub fn may_use_picture(kind: &str, bytes: u64, limits: &UserLimits) -> Result<(), String> {
let most = match kind {
"avatar" => limits.avatar_bytes,
"icon" => limits.icon_bytes,
"banner" => limits.banner_bytes,
_ => limits.emoji_bytes,
};
if bytes > most {
return Err(format!("That picture is {} KB and Stoat takes {kind}s of at most {} KB", bytes / 1000, most / 1000));
}
Ok(())
}
/// What a call is held to for this account, said once on joining: nothing where it is as good as anybody's.
pub fn call_note(handle: &Handle) -> Option<String> {
let (mine, best) = (handle.limits(), &handle.instance.standing);
if !handle.is_new(chrono::Utc::now().timestamp_millis()) || mine.voice_quality >= best.voice_quality && mine.video == best.video {
return None;
}
let mut parts = Vec::new();
if mine.voice_quality < best.voice_quality {
parts.push(format!("sound is limited to {} kHz", mine.voice_quality / 1000));
}
if !mine.video && best.video {
parts.push("video is off".to_string());
}
Some(format!("While this account is new, {} (for the first {} hours).", parts.join(" and "), handle.instance.new_user_hours))
}
#[cfg(test)]
mod tests {
use super::*;
use super::super::world::User;
fn limits() -> UserLimits {
UserLimits { servers: 2, outgoing_friend_requests: 1, avatar_bytes: 4_000_000, ..Default::default() }
}
#[test]
fn a_server_is_joined_only_below_the_limit() {
assert!(may_join(&limits(), 1).is_ok());
assert!(may_join(&limits(), 2).unwrap_err().contains("(2)"));
}
#[test]
fn requests_waiting_count_against_asking() {
let mut w = World::default();
assert!(may_ask(&limits(), &w).is_ok());
w.users.insert("U".into(), User { relationship: "Incoming".into(), ..Default::default() });
assert!(may_ask(&limits(), &w).is_ok(), "one asked of us is not one of ours");
w.users.insert("V".into(), User { relationship: "Outgoing".into(), ..Default::default() });
assert!(may_ask(&limits(), &w).unwrap_err().contains("most Stoat allows"));
}
#[test]
fn a_reaction_already_there_is_not_a_new_kind() {
let instance = Instance { message_reactions: 2, ..Default::default() };
let held = vec!["a".to_string(), "b".to_string()];
assert!(may_react(&instance, &held, "a").is_ok());
assert!(may_react(&instance, &held, "c").unwrap_err().contains("at most 2"));
assert!(may_react(&instance, &held[..1], "c").is_ok());
}
#[test]
fn a_picture_is_held_to_its_kind_s_size() {
assert!(may_use_picture("avatar", 3_999_999, &limits()).is_ok());
assert!(may_use_picture("avatar", 5_000_000, &limits()).unwrap_err().contains("at most 4000 KB"));
}
#[test]
fn a_call_says_what_a_new_account_is_held_to_and_an_old_one_says_nothing() {
let h = crate::backend::stoat::tests::bare_handle("stoat:ME");
let instance = Instance { new_user_hours: 72, standing: UserLimits { voice_quality: 48_000, video: true, ..Default::default() }, fresh: UserLimits { voice_quality: 16_000, video: true, ..Default::default() }, ..Default::default() };
let h = Handle { instance: std::sync::Arc::new(instance), ..h };
h.world.lock().unwrap().own_id = format!("{:0>10}{}", "", "0".repeat(16));
assert_eq!(call_note(&h), None, "an account made long ago");
// An id made now.
let now = chrono::Utc::now().timestamp_millis() as u64;
let alphabet = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
let head: String = (0..10).rev().map(|i| alphabet[((now >> (5 * i)) & 31) as usize] as char).collect();
h.world.lock().unwrap().own_id = format!("{head}{}", "0".repeat(16));
let note = call_note(&h).unwrap();
assert!(note.contains("16 kHz") && note.contains("72 hours"), "{note}");
}
}
-245
View File
@@ -1,245 +0,0 @@
//! Adding an account: the sign-in a person takes part in.
//!
//! The person types the instance (the official one unless they run their own), their email and password; or pastes a
//! session token. If the account has a second factor Stoat asks for it next: the six-digit code of an authenticator
//! app, or a recovery code. Neither the password nor the code is kept: what the account keeps is the session token
//! Stoat issued, which is as private as a Discord token and handled the same way.
//!
//! It is a conversation that can take minutes, so it runs as its own task and talks to the window through events:
//!
//! - `stoatLoginStatus` - what it is doing, for a line of text
//! - `stoatLoginNeeds` - what Stoat is asking for: `step` is `mfa`, with the `methods` it accepts, and `error` says why
//! the last answer was refused
//! - `stoatLoginResult` - done, with the account, or the reason it failed
//!
//! and the window answers with `submitStoatCode`. An account is never made here, and a captcha or an unverified email
//! is said and left to Stoat's own app: answering a captcha for a person is what gets an account flagged.
use super::api::{self, Api, ApiError};
use crate::accounts::StoatAccountConfig;
use crate::state::AppState;
use anyhow::{anyhow, bail, Result};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Duration;
use tokio::sync::mpsc;
/// How long a sign-in may wait for a person.
const PATIENCE: Duration = Duration::from_secs(5 * 60);
/// What the window can hand to a sign-in that is waiting.
enum Answer {
Code(String),
}
fn waiting() -> &'static Mutex<HashMap<String, mpsc::UnboundedSender<Answer>>> {
static WAITING: std::sync::OnceLock<Mutex<HashMap<String, mpsc::UnboundedSender<Answer>>>> = std::sync::OnceLock::new();
WAITING.get_or_init(Default::default)
}
/// Gives a sign-in the second factor Stoat asked for.
pub fn submit_code(login_id: &str, code: &str) -> Result<()> {
if code.trim().is_empty() {
bail!("type the code first");
}
let sender = waiting().lock().unwrap().get(login_id).cloned().ok_or_else(|| anyhow!("that sign-in is not waiting for anything any more"))?;
sender.send(Answer::Code(code.trim().to_string())).map_err(|_| anyhow!("that sign-in has ended"))
}
/// Stops a sign-in that is waiting, because the form was closed.
pub fn cancel(login_id: &str) {
waiting().lock().unwrap().remove(login_id);
}
/// How a second factor is said to Stoat: an authenticator's code is digits, and a recovery code is not.
pub fn mfa_response(code: &str) -> Value {
let digits: String = code.chars().filter(|c| c.is_ascii_digit()).collect();
let spaced = code.chars().all(|c| c.is_ascii_digit() || c == ' ' || c == '-');
if spaced && digits.len() == 6 {
json!({ "totp_code": digits })
} else {
json!({ "recovery_code": code.trim() })
}
}
/// The address of an instance's API as it was typed: nothing means the official one, and the address of the official
/// web app (which is not the API) is turned into the API's.
pub fn normalize_instance(typed: &str) -> Option<String> {
let text = typed.trim().trim_end_matches('/');
if text.is_empty() {
return Some(api::DEFAULT_INSTANCE.to_string());
}
let with_scheme = if text.contains("://") { text.to_string() } else { format!("https://{text}") };
let url = url::Url::parse(&with_scheme).ok()?;
if !matches!(url.scheme(), "http" | "https") {
return None;
}
let host = url.host_str()?;
if matches!(host, "stoat.chat" | "app.stoat.chat" | "revolt.chat" | "app.revolt.chat") {
return Some(api::DEFAULT_INSTANCE.to_string());
}
Some(with_scheme)
}
/// How the person is signing in.
enum Method {
Password { email: String, password: String },
Token(String),
}
/// Starts a sign-in by email and password. Answers at once; the rest is events.
pub fn start(state: AppState, login_id: String, instance: String, email: String, password: String) {
begin(state, login_id, instance, Method::Password { email, password });
}
/// Starts a sign-in with a session token the person already has.
pub fn start_token(state: AppState, login_id: String, instance: String, token: String) {
begin(state, login_id, instance, Method::Token(token));
}
fn begin(state: AppState, login_id: String, instance: String, method: Method) {
let (tx, rx) = mpsc::unbounded_channel();
waiting().lock().unwrap().insert(login_id.clone(), tx);
tokio::spawn(async move {
let outcome = flow(&state, &login_id, &instance, method, rx).await;
waiting().lock().unwrap().remove(&login_id);
match outcome {
Ok(Some(saved)) => {
let account = crate::accounts::stoat_account_to_json(&saved, "connecting");
super::connect::spawn(state.clone(), saved);
state.events.emit("stoatLoginResult", json!({ "loginId": login_id, "success": true, "account": account }));
}
// The form was closed; nobody is waiting for an answer.
Ok(None) => {}
Err(e) => {
let error = format!("{e:#}");
tracing::info!("stoat: sign-in {login_id}: {error}");
state.events.emit("stoatLoginResult", json!({ "loginId": login_id, "success": false, "error": error }));
}
}
});
}
fn status(state: &AppState, login_id: &str, text: &str) {
state.events.emit("stoatLoginStatus", json!({ "loginId": login_id, "text": text }));
}
fn needs(state: &AppState, login_id: &str, methods: &Value, error: Option<&str>) {
state.events.emit("stoatLoginNeeds", json!({ "loginId": login_id, "step": "mfa", "methods": methods, "error": error }));
}
async fn answer(rx: &mut mpsc::UnboundedReceiver<Answer>) -> Option<Answer> {
tokio::time::timeout(PATIENCE, rx.recv()).await.ok().flatten()
}
async fn flow(state: &AppState, login_id: &str, instance: &str, method: Method, mut rx: mpsc::UnboundedReceiver<Answer>) -> Result<Option<StoatAccountConfig>> {
let instance = normalize_instance(instance).ok_or_else(|| anyhow!("that does not look like the address of a Stoat instance"))?;
status(state, login_id, "Reaching the instance");
let key = crate::net::route::pending_key("stoat");
let anonymous = Api::new(&key, &instance, None);
api::instance_info(&anonymous).await?;
let (token, session_id) = match &method {
Method::Token(token) => (token.trim().to_string(), String::new()),
Method::Password { email, password } => {
if email.trim().is_empty() || password.is_empty() {
bail!("an email and a password are needed");
}
status(state, login_id, "Signing in");
let first = json!({ "email": email.trim(), "password": password, "friendly_name": super::session_name() });
match signed_in(&anonymous, state, login_id, first, &mut rx).await? {
Some(session) => session,
None => return Ok(None),
}
}
};
// Who it is, which is also the proof the token is good.
let session = Api::new(&key, &instance, Some(token.clone()));
let me = session.get("/users/@me", &[]).await.map_err(|e| match e {
ApiError::Unauthorized => anyhow!("Stoat did not accept that token"),
other => anyhow!("{other}"),
})?;
let user_id = me["_id"].as_str().ok_or_else(|| anyhow!("Stoat did not say who this is"))?.to_string();
let config = StoatAccountConfig {
instance,
user_id,
username: format!("{}#{}", me["username"].as_str().unwrap_or(""), me["discriminator"].as_str().unwrap_or("")),
token,
session_id,
use_tor: crate::net::route::router().wanted(&key),
..Default::default()
};
Ok(Some(state.accounts.add_stoat(config)?))
}
/// The login request, and the second factor if Stoat wants one. Answers the session token and its id.
async fn signed_in(api: &Api, state: &AppState, login_id: &str, first: Value, rx: &mut mpsc::UnboundedReceiver<Answer>) -> Result<Option<(String, String)>> {
let mut reply = api.post("/auth/session/login", &first).await.map_err(|e| anyhow!("{e}"))?;
loop {
match reply["result"].as_str() {
Some("Success") => {
let token = reply["token"].as_str().ok_or_else(|| anyhow!("Stoat did not give a session"))?.to_string();
return Ok(Some((token, reply["_id"].as_str().unwrap_or("").to_string())));
}
Some("Disabled") => bail!("This account is disabled"),
Some("MFA") => {
let ticket = reply["ticket"].as_str().ok_or_else(|| anyhow!("Stoat did not give a ticket"))?.to_string();
let methods = reply["allowed_methods"].clone();
needs(state, login_id, &methods, None);
loop {
let Some(Answer::Code(code)) = answer(rx).await else { return Ok(None) };
let body = json!({ "mfa_ticket": ticket, "mfa_response": mfa_response(&code), "friendly_name": super::session_name() });
match api.post("/auth/session/login", &body).await {
Ok(next) => {
reply = next;
break;
}
Err(ApiError::Refused { kind, .. }) if matches!(kind.as_str(), "InvalidToken" | "InvalidOneTimeCode" | "InvalidCredentials") => {
needs(state, login_id, &methods, Some("That code is not right. Check it and try again"));
}
Err(e) => bail!("{e}"),
}
}
}
_ => bail!("Stoat answered something moho does not know how to follow"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_address_of_an_instance_is_made_from_what_was_typed() {
assert_eq!(normalize_instance("").as_deref(), Some("https://api.stoat.chat"));
assert_eq!(normalize_instance(" ").as_deref(), Some("https://api.stoat.chat"));
assert_eq!(normalize_instance("https://stoat.chat").as_deref(), Some("https://api.stoat.chat"), "the web app is not the API");
assert_eq!(normalize_instance("app.revolt.chat/").as_deref(), Some("https://api.stoat.chat"));
assert_eq!(normalize_instance("chat.example.org/api/").as_deref(), Some("https://chat.example.org/api"));
assert_eq!(normalize_instance("http://localhost:8000").as_deref(), Some("http://localhost:8000"));
assert_eq!(normalize_instance("ftp://example.org"), None);
assert_eq!(normalize_instance("not a host"), None);
}
#[test]
fn a_six_digit_code_is_an_authenticators_and_anything_else_is_a_recovery_code() {
assert_eq!(mfa_response("123456"), json!({ "totp_code": "123456" }));
assert_eq!(mfa_response("123 456"), json!({ "totp_code": "123456" }));
assert_eq!(mfa_response("abcd-1234-efgh"), json!({ "recovery_code": "abcd-1234-efgh" }));
assert_eq!(mfa_response("12345"), json!({ "recovery_code": "12345" }));
}
#[test]
fn answers_for_a_sign_in_that_is_not_waiting_are_refused_in_words() {
assert!(submit_code("stoat-none", "123456").unwrap_err().to_string().contains("not waiting"));
assert!(submit_code("stoat-none", " ").unwrap_err().to_string().contains("type the code"));
let (tx, mut rx) = mpsc::unbounded_channel();
waiting().lock().unwrap().insert("stoat-t".into(), tx);
submit_code("stoat-t", " 123456 ").unwrap();
assert!(matches!(rx.try_recv(), Ok(Answer::Code(c)) if c == "123456"));
cancel("stoat-t");
assert!(submit_code("stoat-t", "123456").is_err());
}
}
-446
View File
@@ -1,446 +0,0 @@
//! Looking after a server: its name and picture, its channels and the headings they sit under, its roles and what
//! each may do, who may do what in one channel, its webhooks, and the record of what its moderators did.
//!
//! The window asks for all of it at once when the settings are opened (`overview`) and then makes one change at a time;
//! after each it asks again, so what it shows is what Stoat holds and not what it hoped. What the account may do is
//! worked out from its own permissions, to offer only that; Stoat checks again and says why in words if it refuses.
//!
//! Permissions cross the boundary by name (`ViewChannel`, `SendMessage`), not as the bit sets Stoat keeps: the window
//! is JavaScript, whose whole numbers stop being exact at 2^53 and whose bit operations stop at 32 bits.
use super::messages;
use super::world::{self, Server};
use super::{media, Handle};
use crate::model::Buffer;
use anyhow::{anyhow, bail, Result};
use serde_json::{json, Value};
/// Every permission: its bit, its name, what the window calls it, the heading it sits under, and whether it means
/// something on a channel (a server's roles and defaults carry all of them; a channel carries only these).
pub const PERMISSIONS: &[(&str, u32, &str, &str, bool)] = &[
("ManageChannel", 0, "Manage channels", "Server", true),
("ManageServer", 1, "Manage the server", "Server", false),
("ManagePermissions", 2, "Manage permissions", "Server", true),
("ManageRole", 3, "Manage roles", "Server", false),
("ManageCustomisation", 4, "Manage emoji", "Server", false),
("ViewAuditLogs", 40, "View the audit log", "Server", false),
("KickMembers", 6, "Kick members", "Members", false),
("BanMembers", 7, "Ban members", "Members", false),
("TimeoutMembers", 8, "Time members out", "Members", false),
("AssignRoles", 9, "Give roles", "Members", false),
("ChangeNickname", 10, "Change own nickname", "Members", false),
("ManageNicknames", 11, "Change others' nicknames", "Members", false),
("ChangeAvatar", 12, "Change own server picture", "Members", false),
("RemoveAvatars", 13, "Remove others' server pictures", "Members", false),
("ViewChannel", 20, "See the channel", "Text", true),
("ReadMessageHistory", 21, "Read earlier messages", "Text", true),
("SendMessage", 22, "Send messages", "Text", true),
("ManageMessages", 23, "Delete others' messages and pin", "Text", true),
("ManageWebhooks", 24, "Manage webhooks", "Text", true),
("InviteOthers", 25, "Make invites", "Text", true),
("SendEmbeds", 26, "Send links that unfurl", "Text", true),
("UploadFiles", 27, "Attach files", "Text", true),
("Masquerade", 28, "Send under another name and picture", "Text", true),
("React", 29, "React", "Text", true),
("BypassSlowmode", 39, "Ignore slowmode", "Text", true),
("MentionEveryone", 37, "Mention everyone", "Text", true),
("MentionRoles", 38, "Mention roles", "Text", true),
("Connect", 30, "Join calls", "Voice", true),
("Speak", 31, "Speak", "Voice", true),
("Video", 32, "Use a camera or share a screen", "Voice", true),
("Listen", 36, "Hear a call", "Voice", true),
("MuteMembers", 33, "Mute others in a call", "Voice", true),
("DeafenMembers", 34, "Deafen others in a call", "Voice", true),
("MoveMembers", 35, "Move others between calls", "Voice", true),
];
/// The names of the permissions a set of bits holds.
pub fn names_of(bits: u64) -> Vec<&'static str> {
PERMISSIONS.iter().filter(|(_, bit, ..)| bits & (1u64 << bit) != 0).map(|(name, ..)| *name).collect()
}
/// The bits the names stand for; a name nobody knows is refused, not dropped.
pub fn bits_of(names: &[String]) -> Result<u64> {
let mut bits = 0u64;
for name in names {
let (_, bit, ..) = PERMISSIONS.iter().find(|(n, ..)| n == name).ok_or_else(|| anyhow!("{name} is not a permission Stoat has"))?;
bits |= 1u64 << bit;
}
Ok(bits)
}
fn names_in(v: &Value) -> Vec<String> {
v.as_array().into_iter().flatten().filter_map(|x| x.as_str().map(str::to_string)).collect()
}
fn server_of(handle: &Handle, buffer: &Buffer) -> Result<Server> {
let channel = buffer.remote_id.as_deref().ok_or_else(|| anyhow!("this is not a Stoat conversation"))?;
let world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
world.channels.get(channel).and_then(|c| c.server.as_ref()).and_then(|s| world.servers.get(s)).cloned().ok_or_else(|| anyhow!("this conversation is not in a server"))
}
fn fail(e: super::api::ApiError) -> anyhow::Error {
anyhow!("{e}")
}
/// What the account may do to the server, for the window to offer only that.
fn powers(handle: &Handle, server: &Server) -> Value {
let bits = handle.world.lock().unwrap_or_else(|e| e.into_inner()).server_permissions(server);
let has = |name: &str| bits & PERMISSIONS.iter().find(|(n, ..)| *n == name).map(|(_, b, ..)| 1u64 << b).unwrap_or(0) != 0;
json!({
"owner": server.owner == handle.world.lock().unwrap_or_else(|e| e.into_inner()).own_id,
"server": has("ManageServer"),
"channels": has("ManageChannel"),
"roles": has("ManageRole"),
"permissions": has("ManagePermissions"),
"webhooks": has("ManageWebhooks"),
"audit": has("ViewAuditLogs"),
})
}
/// Everything the settings window shows about the server a conversation is in.
pub async fn overview(handle: &Handle, buffer: &Buffer) -> Result<Value> {
let server = server_of(handle, buffer)?;
let fresh = handle.api.get(&format!("/servers/{}", server.id), &[("include_channels", "true".to_string())]).await.map_err(fail)?;
let categories = if fresh["categories"].is_array() { fresh["categories"].clone() } else { json!([]) };
let category_of = |channel: &str| categories.as_array().into_iter().flatten().find(|c| c["channels"].as_array().is_some_and(|l| l.iter().any(|x| x.as_str() == Some(channel)))).and_then(|c| c["id"].as_str().map(str::to_string));
let channels: Vec<Value> = fresh["channels"]
.as_array()
.into_iter()
.flatten()
.filter(|c| c.is_object())
.map(|c| {
json!({
"id": c["_id"], "name": c["name"], "description": c["description"].as_str().unwrap_or(""),
"voice": c.get("voice").is_some_and(|v| v.is_object()) || c["channel_type"] == "VoiceChannel",
"nsfw": c["nsfw"].as_bool().unwrap_or(false), "slowmode": c["slowmode"].as_u64().unwrap_or(0),
"category": category_of(c["_id"].as_str().unwrap_or("")),
"roles": c["role_permissions"].as_object().map(|o| o.iter().map(|(role, p)| json!({ "roleId": role, "allow": names_of(p["a"].as_u64().unwrap_or(0)), "deny": names_of(p["d"].as_u64().unwrap_or(0)) })).collect::<Vec<_>>()).unwrap_or_default(),
"default": { "allow": names_of(c["default_permissions"]["a"].as_u64().unwrap_or(0)), "deny": names_of(c["default_permissions"]["d"].as_u64().unwrap_or(0)) },
})
})
.collect();
let mut roles: Vec<Value> = fresh["roles"]
.as_object()
.into_iter()
.flatten()
.map(|(id, r)| json!({ "id": id, "name": r["name"], "colour": r["colour"], "hoist": r["hoist"].as_bool().unwrap_or(false), "rank": r["rank"].as_i64().unwrap_or(0), "allow": names_of(r["permissions"]["a"].as_u64().unwrap_or(0)), "deny": names_of(r["permissions"]["d"].as_u64().unwrap_or(0)) }))
.collect();
roles.sort_by_key(|r| r["rank"].as_i64().unwrap_or(0));
let icon = fresh["icon"]["_id"].as_str().and_then(|f| media::file_url(&handle.instance, "icons", f)).map(|u| media::cached(&handle.account_id, media::AVATARS, &u, None, None));
Ok(json!({
"serverId": server.id,
"name": fresh["name"], "description": fresh["description"].as_str().unwrap_or(""), "iconUrl": icon,
"can": powers(handle, &server),
"categories": categories,
"channels": channels,
"roles": roles,
"defaultPermissions": names_of(fresh["default_permissions"].as_u64().unwrap_or(0)),
"permissions": PERMISSIONS.iter().map(|(name, _, label, group, on_channel)| json!({ "key": name, "label": label, "group": group, "channel": on_channel })).collect::<Vec<_>>(),
"limits": { "channels": handle.instance.server_channels, "roles": handle.instance.server_roles },
}))
}
/// Changes the server's name or description, or its picture (`icon` a file; empty takes it off).
pub async fn edit_server(handle: &Handle, buffer: &Buffer, name: Option<&str>, description: Option<&str>, icon: Option<&str>) -> Result<()> {
let server = server_of(handle, buffer)?;
let mut body = json!({});
let mut remove: Vec<&str> = Vec::new();
if let Some(name) = name.map(str::trim).filter(|n| !n.is_empty()) {
body["name"] = json!(name);
}
match description.map(str::trim) {
Some("") => remove.push("Description"),
Some(d) => body["description"] = json!(d),
None => {}
}
match icon {
Some("") => remove.push("Icon"),
Some(path) => {
let size = std::fs::metadata(path).map_err(|e| anyhow!("{path}: {e}"))?.len();
super::limits::may_use_picture("icon", size, handle.limits()).map_err(|e| anyhow!(e))?;
let host = handle.instance.autumn.clone().ok_or_else(|| anyhow!("this instance has no file server"))?;
let file = super::send::sent_name(path, false);
let bytes = tokio::fs::read(path).await.map_err(|e| anyhow!("reading {path}: {e}"))?;
body["icon"] = json!(handle.api.upload(&host, "icons", &file, super::send::content_type_of(&file), bytes).await.map_err(fail)?);
}
None => {}
}
if !remove.is_empty() {
body["remove"] = json!(remove);
}
handle.api.patch(&format!("/servers/{}", server.id), &body).await.map(|_| ()).map_err(fail)
}
/// Makes a channel, text or voice, at the end of the server's list.
pub async fn create_channel(handle: &Handle, buffer: &Buffer, name: &str, voice: bool) -> Result<()> {
let server = server_of(handle, buffer)?;
if server.channels.len() >= handle.instance.server_channels {
bail!("A server may have at most {} channels on this instance, and this one has {}", handle.instance.server_channels, server.channels.len());
}
let name = name.trim();
if name.is_empty() {
bail!("A channel needs a name");
}
handle.api.post(&format!("/servers/{}/channels", server.id), &json!({ "type": if voice { "Voice" } else { "Text" }, "name": name })).await.map(|_| ()).map_err(fail)
}
/// Changes a channel's name, description, age-restriction or slowmode (seconds; none takes it off).
pub async fn edit_channel(handle: &Handle, channel: &str, name: Option<&str>, description: Option<&str>, nsfw: Option<bool>, slowmode: Option<u64>) -> Result<()> {
let mut body = json!({});
let mut remove: Vec<&str> = Vec::new();
if let Some(name) = name.map(str::trim).filter(|n| !n.is_empty()) {
body["name"] = json!(name);
}
match description.map(str::trim) {
Some("") => remove.push("Description"),
Some(d) => body["description"] = json!(d),
None => {}
}
if let Some(nsfw) = nsfw {
body["nsfw"] = json!(nsfw);
}
match slowmode {
Some(0) => remove.push("Slowmode"),
Some(s) => body["slowmode"] = json!(s.min(6 * 3600)),
None => {}
}
if !remove.is_empty() {
body["remove"] = json!(remove);
}
handle.api.patch(&format!("/channels/{channel}"), &body).await.map(|_| ()).map_err(fail)
}
pub async fn delete_channel(handle: &Handle, channel: &str) -> Result<()> {
handle.api.delete(&format!("/channels/{channel}")).await.map(|_| ()).map_err(fail)
}
/// Sets the headings and which channels sit under each, whole.
pub async fn set_categories(handle: &Handle, buffer: &Buffer, categories: &Value) -> Result<()> {
let server = server_of(handle, buffer)?;
let list: Vec<Value> = categories
.as_array()
.ok_or_else(|| anyhow!("the headings must be a list"))?
.iter()
.map(|c| json!({ "id": c["id"], "title": c["title"].as_str().unwrap_or("").chars().take(32).collect::<String>(), "channels": c["channels"] }))
.collect();
if list.iter().any(|c| c["id"].as_str().is_none_or(str::is_empty) || c["title"].as_str().is_none_or(|t| t.trim().is_empty())) {
bail!("Every heading needs a name");
}
handle.api.patch(&format!("/servers/{}", server.id), &json!({ "categories": list })).await.map(|_| ()).map_err(fail)
}
pub async fn create_role(handle: &Handle, buffer: &Buffer, name: &str) -> Result<()> {
let server = server_of(handle, buffer)?;
if server.roles.len() >= handle.instance.server_roles {
bail!("A server may have at most {} roles on this instance, and this one has {}", handle.instance.server_roles, server.roles.len());
}
handle.api.post(&format!("/servers/{}/roles", server.id), &json!({ "name": name.trim() })).await.map(|_| ()).map_err(fail)
}
/// Changes a role's name, colour (empty takes it off) or whether it is listed apart.
pub async fn edit_role(handle: &Handle, buffer: &Buffer, role: &str, name: Option<&str>, colour: Option<&str>, hoist: Option<bool>) -> Result<()> {
let server = server_of(handle, buffer)?;
let mut body = json!({});
if let Some(name) = name.map(str::trim).filter(|n| !n.is_empty()) {
body["name"] = json!(name);
}
match colour.map(str::trim) {
Some("") => body["remove"] = json!(["Colour"]),
Some(c) => body["colour"] = json!(c),
None => {}
}
if let Some(h) = hoist {
body["hoist"] = json!(h);
}
handle.api.patch(&format!("/servers/{}/roles/{role}", server.id), &body).await.map(|_| ()).map_err(fail)
}
pub async fn delete_role(handle: &Handle, buffer: &Buffer, role: &str) -> Result<()> {
let server = server_of(handle, buffer)?;
handle.api.delete(&format!("/servers/{}/roles/{role}", server.id)).await.map(|_| ()).map_err(fail)
}
/// Puts the roles in this order, the most powerful first.
pub async fn reorder_roles(handle: &Handle, buffer: &Buffer, order: &[String]) -> Result<()> {
let server = server_of(handle, buffer)?;
handle.api.patch(&format!("/servers/{}/roles/ranks", server.id), &json!({ "ranks": order })).await.map(|_| ()).map_err(fail)
}
/// What a role allows and denies server-wide; with no role, what everybody is allowed.
pub async fn set_role_permissions(handle: &Handle, buffer: &Buffer, role: Option<&str>, allow: &[String], deny: &[String]) -> Result<()> {
let server = server_of(handle, buffer)?;
match role {
Some(role) => handle.api.put(&format!("/servers/{}/permissions/{role}", server.id), Some(&json!({ "permissions": { "allow": bits_of(allow)?, "deny": bits_of(deny)? } }))).await,
None => handle.api.put(&format!("/servers/{}/permissions/default", server.id), Some(&json!({ "permissions": bits_of(allow)? }))).await,
}
.map(|_| ())
.map_err(fail)
}
/// What a role (or everybody) is allowed and denied in one channel.
pub async fn set_channel_permissions(handle: &Handle, channel: &str, role: Option<&str>, allow: &[String], deny: &[String]) -> Result<()> {
let permissions = json!({ "allow": bits_of(allow)?, "deny": bits_of(deny)? });
match role {
Some(role) => handle.api.put(&format!("/channels/{channel}/permissions/{role}"), Some(&json!({ "permissions": permissions }))).await,
None => handle.api.put(&format!("/channels/{channel}/permissions/default"), Some(&json!({ "permissions": permissions }))).await,
}
.map(|_| ())
.map_err(fail)
}
/// What a recorded action says, in words.
pub fn describe(world: &world::World, entry: &Value) -> String {
let who = world.name_of(entry["user"].as_str().unwrap_or(""));
let target = entry["target"].as_str().map(|t| world.name_of(t));
let action = &entry["action"];
let channel = |id: &Value| id.as_str().and_then(|c| world.channels.get(c)).map(|c| format!("#{}", c.name)).unwrap_or_else(|| "a channel".to_string());
let name_of = |id: &Value| world.name_of(id.as_str().unwrap_or(""));
let text = match action["type"].as_str().unwrap_or("") {
"MessageDelete" => format!("deleted a message by {} in {}", name_of(&action["author"]), channel(&action["channel"])),
"MessageBulkDelete" => format!("deleted {} messages in {}", action["count"].as_u64().unwrap_or(0), channel(&action["channel"])),
"MessagePin" => format!("pinned a message by {} in {}", name_of(&action["author"]), channel(&action["channel"])),
"MessageUnpin" => format!("unpinned a message by {} in {}", name_of(&action["author"]), channel(&action["channel"])),
"BanCreate" => format!("banned {}", name_of(&action["user"])),
"BanDelete" => format!("let {} back in", name_of(&action["user"])),
"ChannelCreate" => format!("made the channel {}", action["name"].as_str().unwrap_or("")),
"ChannelEdit" => format!("changed {}", channel(&action["channel"])),
"ChannelRolePermissionsEdit" => format!("changed who may do what in {}", channel(&action["channel"])),
"ChannelDelete" => format!("deleted the channel {}", action["name"].as_str().unwrap_or("")),
"MemberEdit" => format!("changed {}", name_of(&action["user"])),
"MemberKick" => format!("kicked {}", name_of(&action["user"])),
"ServerEdit" => "changed the server".to_string(),
"RoleEdit" => "changed a role".to_string(),
"RoleCreate" => format!("made the role {}", action["name"].as_str().unwrap_or("")),
"RoleDelete" => format!("deleted the role {}", action["name"].as_str().unwrap_or("")),
"RolesReorder" => "put the roles in a new order".to_string(),
"InviteCreate" => format!("made an invite to {}", channel(&action["channel"])),
"InviteDelete" => format!("deleted an invite to {}", channel(&action["channel"])),
"WebhookCreate" => format!("made the webhook {} in {}", action["name"].as_str().unwrap_or(""), channel(&action["channel"])),
"WebhookDelete" => format!("deleted the webhook {} in {}", action["name"].as_str().unwrap_or(""), channel(&action["channel"])),
"EmojiCreate" => format!("added the emoji {}", action["name"].as_str().unwrap_or("")),
"EmojiUpdate" => "changed an emoji".to_string(),
"EmojiDelete" => format!("removed the emoji {}", action["name"].as_str().unwrap_or("")),
other => other.to_string(),
};
let reason = entry["reason"].as_str().filter(|r| !r.is_empty()).map(|r| format!(" ({r})")).unwrap_or_default();
match (&target, action["type"].as_str()) {
(Some(t), Some("MemberEdit" | "MemberKick" | "BanCreate" | "BanDelete")) if !text.contains(t.as_str()) => format!("{who} {text} - {t}{reason}"),
_ => format!("{who} {text}{reason}"),
}
}
/// What the server's moderators did, newest first: each as a line and when.
pub async fn audit_log(handle: &Handle, buffer: &Buffer, before: Option<&str>) -> Result<Value> {
let server = server_of(handle, buffer)?;
let mut query = vec![("limit", "50".to_string())];
if let Some(b) = before.filter(|b| b.len() == 26) {
query.push(("before", b.to_string()));
}
let answer = handle.api.get(&format!("/servers/{}/audit_logs", server.id), &query).await.map_err(fail)?;
{
let mut world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
for u in answer["users"].as_array().into_iter().flatten() {
if let Some((id, user)) = world::parse_user(u) {
world.users.entry(id).or_insert(user);
}
}
}
let world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
let entries: Vec<Value> = answer["audit_logs"]
.as_array()
.into_iter()
.flatten()
.map(|e| json!({ "id": e["_id"], "text": describe(&world, e), "ts": e["_id"].as_str().and_then(messages::ulid_ms).map(|ms| ms / 1000).unwrap_or(0) }))
.collect();
Ok(json!({ "entries": entries }))
}
/// The webhooks of a channel, each with the address it takes posts at.
pub async fn webhooks(handle: &Handle, channel: &str) -> Result<Value> {
let answer = handle.api.get(&format!("/channels/{channel}/webhooks"), &[]).await.map_err(fail)?;
let base = handle.api.base();
let list: Vec<Value> = answer
.as_array()
.into_iter()
.flatten()
.map(|w| json!({ "id": w["id"], "name": w["name"], "token": w["token"], "url": w["token"].as_str().map(|t| format!("{base}/webhooks/{}/{t}", w["id"].as_str().unwrap_or(""))) }))
.collect();
Ok(json!({ "webhooks": list }))
}
pub async fn create_webhook(handle: &Handle, channel: &str, name: &str) -> Result<()> {
handle.api.post(&format!("/channels/{channel}/webhooks"), &json!({ "name": name.trim() })).await.map(|_| ()).map_err(fail)
}
pub async fn delete_webhook(handle: &Handle, id: &str, token: Option<&str>) -> Result<()> {
let path = match token.filter(|t| !t.is_empty()) {
Some(t) => format!("/webhooks/{id}/{t}"),
None => format!("/webhooks/{id}"),
};
handle.api.delete(&path).await.map(|_| ()).map_err(fail)
}
/// Runs one action of the settings window; answers what it shows afterwards.
pub async fn run(handle: &Handle, buffer: &Buffer, action: &str, p: &Value) -> Result<Value> {
let text = |k: &str| p[k].as_str();
let id = |k: &str| text(k).filter(|v| !v.is_empty()).ok_or_else(|| anyhow!("{action} needs \"{k}\""));
match action {
"overview" => return overview(handle, buffer).await,
"auditLog" => return audit_log(handle, buffer, text("before")).await,
"webhooks" => return webhooks(handle, id("channelId")?).await,
"editServer" => edit_server(handle, buffer, text("name"), text("description"), text("icon")).await?,
"createChannel" => create_channel(handle, buffer, text("name").unwrap_or(""), p["voice"].as_bool().unwrap_or(false)).await?,
"editChannel" => edit_channel(handle, id("channelId")?, text("name"), text("description"), p["nsfw"].as_bool(), p["slowmode"].as_u64()).await?,
"deleteChannel" => delete_channel(handle, id("channelId")?).await?,
"setCategories" => set_categories(handle, buffer, &p["categories"]).await?,
"createRole" => create_role(handle, buffer, text("name").unwrap_or("")).await?,
"editRole" => edit_role(handle, buffer, id("roleId")?, text("name"), text("colour"), p["hoist"].as_bool()).await?,
"deleteRole" => delete_role(handle, buffer, id("roleId")?).await?,
"reorderRoles" => reorder_roles(handle, buffer, &names_in(&p["order"])).await?,
"setRolePermissions" => set_role_permissions(handle, buffer, text("roleId").filter(|r| !r.is_empty()), &names_in(&p["allow"]), &names_in(&p["deny"])).await?,
"setChannelPermissions" => set_channel_permissions(handle, id("channelId")?, text("roleId").filter(|r| !r.is_empty()), &names_in(&p["allow"]), &names_in(&p["deny"])).await?,
"createWebhook" => create_webhook(handle, id("channelId")?, text("name").unwrap_or("")).await?,
"deleteWebhook" => delete_webhook(handle, id("webhookId")?, text("token")).await?,
other => bail!("Stoat has no way to {other}"),
}
Ok(json!({ "ok": true }))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn permissions_cross_as_names_and_back_without_loss_at_the_top_bits() {
let bits = (1u64 << 40) | (1u64 << 22) | 1;
assert_eq!(names_of(bits), vec!["ManageChannel", "ViewAuditLogs", "SendMessage"]);
assert_eq!(bits_of(&["SendMessage".into(), "ViewAuditLogs".into(), "ManageChannel".into()]).unwrap(), bits);
assert!(bits_of(&["Fly".into()]).unwrap_err().to_string().contains("Fly"));
assert_eq!(bits_of(&[]).unwrap(), 0);
}
#[test]
fn no_two_permissions_share_a_bit_or_a_name() {
let mut bits: Vec<u32> = PERMISSIONS.iter().map(|p| p.1).collect();
bits.sort();
bits.dedup();
assert_eq!(bits.len(), PERMISSIONS.len());
let mut names: Vec<&str> = PERMISSIONS.iter().map(|p| p.0).collect();
names.sort();
names.dedup();
assert_eq!(names.len(), PERMISSIONS.len());
}
#[test]
fn what_a_moderator_did_is_said_by_name() {
let mut w = world::World::default();
w.users.insert("U1".into(), world::User { name: "Mod".into(), ..Default::default() });
w.users.insert("U2".into(), world::User { name: "Troll".into(), ..Default::default() });
let said = |v: Value| describe(&w, &v);
assert_eq!(said(json!({ "user": "U1", "action": { "type": "BanCreate", "user": "U2" }, "reason": "spam" })), "Mod banned Troll (spam)");
assert_eq!(said(json!({ "user": "U1", "target": "U2", "action": { "type": "MemberKick", "user": "U2" } })), "Mod kicked Troll");
assert_eq!(said(json!({ "user": "U1", "action": { "type": "RoleCreate", "role": "R", "name": "Helpers" } })), "Mod made the role Helpers");
assert_eq!(said(json!({ "user": "U1", "action": { "type": "SomethingNew" } })), "Mod SomethingNew");
}
}
-122
View File
@@ -1,122 +0,0 @@
//! Avatars, server icons, pictures and files: named in the cache when a message or a list mentions them, and fetched
//! when the window looks.
//!
//! Stoat keeps files on a server of its own (Autumn; the address is in the instance's root) and serves them to
//! anybody with the address, so nothing here needs the token. A file is named in the cache by its address, and the
//! address is recorded against the name: when the window finds the file missing - not fetched yet, expired, cleared -
//! it asks for it again, and the address is what says where from.
use super::world::Instance;
use anyhow::{anyhow, bail, Context, Result};
use serde_json::json;
use std::path::{Path, PathBuf};
pub const MEDIA: &str = "stoat-media";
pub const AVATARS: &str = "stoat-avatars";
/// The address of a file on the instance's file server: `tag` is `avatars`, `icons`, `banners`, `attachments`,
/// `emojis` or `backgrounds`.
pub fn file_url(instance: &Instance, tag: &str, id: &str) -> Option<String> {
let base = instance.autumn.as_deref()?;
(!id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')).then(|| format!("{base}/{tag}/{id}"))
}
/// A stable short name for an address (FNV-1a), so the same file is the same cache entry on every run.
fn hash(text: &str) -> String {
let mut h: u64 = 0xcbf29ce484222325;
for b in text.bytes() {
h ^= u64::from(b);
h = h.wrapping_mul(0x100000001b3);
}
format!("{h:016x}")
}
fn extension_of(name: Option<&str>, content_type: Option<&str>) -> String {
if let Some(ext) = name.and_then(|n| n.rsplit_once('.')).map(|(_, e)| e.to_ascii_lowercase()).filter(|e| !e.is_empty() && e.len() <= 5 && e.chars().all(|c| c.is_ascii_alphanumeric())) {
return ext;
}
match content_type.unwrap_or("") {
"image/jpeg" => "jpg",
"image/png" => "png",
"image/webp" => "webp",
"image/gif" => "gif",
"video/mp4" => "mp4",
"video/webm" => "webm",
"audio/mpeg" => "mp3",
"audio/ogg" => "ogg",
_ => "png",
}
.to_string()
}
/// Where a file of an address is (or will be) in the cache, as the `file://` address the window loads. The address it
/// comes from is recorded, so it can be fetched when it is not there.
pub fn cached(account_id: &str, dir: &str, url: &str, name: Option<&str>, content_type: Option<&str>) -> String {
let path = crate::media_cache::dir(dir).join(format!("{}.{}", hash(url), extension_of(name, content_type)));
crate::media_cache::record(&path, json!({ "url": url, "account": account_id }));
format!("file://{}", path.display())
}
/// Fetches an address into a cache file, whole and renamed into place.
async fn fetch(account_id: &str, url: &str, path: &Path) -> Result<()> {
// Only ever an address the instance gave: the path is the cache's own and the address was recorded by `cached`.
if !url.starts_with("https://") && !url.starts_with("http://") {
bail!("not an address");
}
let router = crate::net::route::router();
let client = router.client_if("stoat", router.routed(account_id), |b| b.user_agent(concat!("moho/", env!("CARGO_PKG_VERSION"))).timeout(std::time::Duration::from_secs(60)));
let response = client.get(url).send().await.with_context(|| format!("fetching {url}"))?;
if !response.status().is_success() {
bail!("{} answered {}", url, response.status());
}
let bytes = response.bytes().await.context("reading the file")?;
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let part = path.with_extension("part");
tokio::fs::write(&part, &bytes).await?;
tokio::fs::rename(&part, path).await.with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
/// Fetches a cached file again, from the address recorded for it.
pub async fn restore_cached(path: &Path) -> Result<PathBuf> {
let source = crate::media_cache::source(path).ok_or_else(|| anyhow!("nothing says where this file came from"))?;
let url = source["url"].as_str().ok_or_else(|| anyhow!("no address was recorded for this file"))?;
fetch(source["account"].as_str().unwrap_or(""), url, path).await?;
Ok(path.to_path_buf())
}
#[cfg(test)]
mod tests {
use super::*;
fn instance() -> Instance {
Instance { autumn: Some("https://cdn.example".into()), ..Default::default() }
}
#[test]
fn a_file_is_on_the_file_server_by_its_tag_and_id() {
assert_eq!(file_url(&instance(), "avatars", "01ABC"), Some("https://cdn.example/avatars/01ABC".to_string()));
assert_eq!(file_url(&instance(), "avatars", "../x"), None, "an id is letters, digits, and the dash and underscore the file server uses");
assert_eq!(file_url(&instance(), "attachments", "6YhKheJ5c9Ir9J8LXS8KbaGL3JnY2ox3a9vCWpPMo-").as_deref(), Some("https://cdn.example/attachments/6YhKheJ5c9Ir9J8LXS8KbaGL3JnY2ox3a9vCWpPMo-"), "ids from the file server may end in a dash");
assert_eq!(file_url(&instance(), "attachments", "a_b/../c"), None);
assert_eq!(file_url(&Instance::default(), "avatars", "01ABC"), None, "an instance with no file server has none");
}
#[test]
fn the_same_address_is_the_same_name_and_a_different_one_is_not() {
assert_eq!(hash("https://cdn.example/avatars/1"), hash("https://cdn.example/avatars/1"));
assert_ne!(hash("https://cdn.example/avatars/1"), hash("https://cdn.example/avatars/2"));
assert_eq!(hash("").len(), 16);
}
#[test]
fn an_extension_comes_from_the_name_or_the_kind_of_file() {
assert_eq!(extension_of(Some("cat.JPG"), None), "jpg");
assert_eq!(extension_of(None, Some("image/webp")), "webp");
assert_eq!(extension_of(Some("noext"), Some("video/mp4")), "mp4");
assert_eq!(extension_of(None, None), "png");
assert_eq!(extension_of(Some("a.way-too-long-extension"), None), "png");
}
}
-158
View File
@@ -1,158 +0,0 @@
//! Who is here: the list beside a conversation, and who is online.
//!
//! A server's members come in one answer (with the people they are), a group's are in its channel, and a direct
//! message is the two of them. Stoat tells a client who is online only for the servers it has asked about - the
//! account says it is looking at a server with `Subscribe` - and then sends each change as it happens, which is kept
//! here against whoever is listed.
//!
//! Each entry is what the window's member list reads: the name shown (the server nickname, else their display name),
//! their id, whether they are away, their status word, their picture and the roles they hold, highest rank first.
use super::media;
use super::world::{Kind, Server, User, World};
use super::Handle;
use crate::model::Buffer;
use crate::state::AppState;
use anyhow::{anyhow, Result};
use serde_json::{json, Value};
/// A status as the window's words have it, from Stoat's presence: not online at all is offline, whatever the
/// presence word says.
pub fn status_of(user: &User) -> &'static str {
if !user.online {
return "offline";
}
match user.presence.as_str() {
"Idle" => "idle",
"Busy" => "dnd",
"Invisible" => "offline",
_ => "online",
}
}
/// One person as a member-list entry.
pub fn entry(world: &World, handle: &Handle, user_id: &str, member: Option<&Value>, server: Option<&Server>) -> Value {
let user = world.users.get(user_id).cloned().unwrap_or_default();
let status = status_of(&user);
let nick = member.and_then(|m| m["nickname"].as_str()).filter(|n| !n.is_empty()).map(str::to_string).unwrap_or_else(|| world.name_of(user_id));
let avatar = member
.and_then(|m| m["avatar"]["_id"].as_str())
.map(str::to_string)
.or(user.avatar.clone())
.and_then(|f| media::file_url(&handle.instance, "avatars", &f))
.map(|u| media::cached(&handle.account_id, media::AVATARS, &u, None, None));
let roles: Vec<String> = match (member, server) {
(Some(m), Some(s)) => {
let mut held: Vec<(&String, i64)> = m["roles"].as_array().into_iter().flatten().filter_map(|r| r.as_str()).filter_map(|r| s.roles.get(r).map(|role| (&role.name, role.rank))).collect();
held.sort_by_key(|(_, rank)| *rank);
held.into_iter().map(|(name, _)| name.clone()).collect()
}
_ => Vec::new(),
};
json!({
"nick": nick,
"userId": user_id,
"prefix": "",
"away": status == "offline",
"status": status,
"activity": user.status_text.as_ref().map(|t| json!({ "kind": "custom", "text": t })).unwrap_or(Value::Null),
"bot": user.bot,
"relationship": user.relationship,
"avatarUrl": avatar,
"roles": roles,
})
}
/// Tells Stoat the account is looking at a server, so it sends who comes online and goes offline there.
pub fn subscribe(handle: &Handle, server: &str) {
let _ = handle.socket.send(json!({ "type": "Subscribe", "server_id": server }).to_string());
}
/// Lists the people of a conversation and tells the window.
pub async fn refresh(state: &AppState, handle: &Handle, buffer: &Buffer) -> Result<usize> {
let channel_id = buffer.remote_id.as_deref().ok_or_else(|| anyhow!("this is not a Stoat conversation"))?;
let channel = handle.world.lock().unwrap_or_else(|e| e.into_inner()).channels.get(channel_id).cloned().ok_or_else(|| anyhow!("Stoat has no such channel"))?;
let list: Vec<Value> = match (&channel.kind, &channel.server) {
(Kind::Text | Kind::Voice, Some(server_id)) => {
subscribe(handle, server_id);
let answer = handle.api.get(&format!("/servers/{server_id}/members"), &[("exclude_offline", "false".to_string())]).await.map_err(|e| anyhow!("{e}"))?;
let mut world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
for u in answer["users"].as_array().into_iter().flatten() {
if let Some((id, user)) = super::world::parse_user(u) {
world.users.insert(id, user);
}
}
let server = world.servers.get(server_id).cloned();
answer["members"]
.as_array()
.into_iter()
.flatten()
.filter_map(|m| m["_id"]["user"].as_str().map(|id| entry(&world, handle, id, Some(m), server.as_ref())))
.collect()
}
(Kind::Direct | Kind::Group, _) => {
let world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
channel.recipients.iter().map(|id| entry(&world, handle, id, None, None)).collect()
}
_ => Vec::new(),
};
let total = list.len();
let list = Value::Array(list);
state.runtime.set_presence(&buffer.id, list.clone());
state.runtime.set_presence_total(&buffer.id, total);
state.events.emit("presenceChange", json!({ "bufferId": buffer.id, "members": list, "total": total }));
Ok(total)
}
/// Somebody's status changed: wherever they are listed, so they are now.
pub fn update_status(state: &AppState, handle: &Handle, user_id: &str) {
let user = handle.world.lock().unwrap_or_else(|e| e.into_inner()).users.get(user_id).cloned();
let Some(user) = user else { return };
let status = status_of(&user);
for buffer in state.runtime.list_buffers() {
if buffer.account_id != handle.account_id {
continue;
}
let Some(existing) = state.runtime.get_presence(&buffer.id) else { continue };
let Some(members) = existing.as_array() else { continue };
if !members.iter().any(|m| m["userId"].as_str() == Some(user_id)) {
continue;
}
let updated: Vec<Value> = members
.iter()
.map(|m| {
if m["userId"].as_str() != Some(user_id) {
return m.clone();
}
let mut m = m.clone();
m["status"] = json!(status);
m["away"] = json!(status == "offline");
m["activity"] = user.status_text.as_ref().map(|t| json!({ "kind": "custom", "text": t })).unwrap_or(Value::Null);
m
})
.collect();
let list = json!(updated);
state.runtime.set_presence(&buffer.id, list.clone());
state.events.emit("presenceChange", json!({ "bufferId": buffer.id, "members": list }));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn user(online: bool, presence: &str) -> User {
User { online, presence: presence.to_string(), ..Default::default() }
}
#[test]
fn a_status_is_offline_unless_the_person_is_online_and_then_the_word_they_chose() {
assert_eq!(status_of(&user(false, "Online")), "offline");
assert_eq!(status_of(&user(true, "Online")), "online");
assert_eq!(status_of(&user(true, "Idle")), "idle");
assert_eq!(status_of(&user(true, "Busy")), "dnd");
assert_eq!(status_of(&user(true, "Focus")), "online");
assert_eq!(status_of(&user(true, "Invisible")), "offline", "somebody invisible is not shown as here");
assert_eq!(status_of(&user(true, "")), "online");
}
}
-491
View File
@@ -1,491 +0,0 @@
//! A message as moho's, and a channel's history.
//!
//! Stoat's markdown is close to Discord's, and so is a message: an id that is a ULID (its first ten characters are
//! the time), an author, content, attachments (files on the file server), embeds, replies, reactions, and - for a
//! bridge or a webhook - a masquerade, the name and picture the message wants to be shown with. The words that name
//! somebody (`<@id>`), a channel (`<#id>`) or an emoji (`:id:`) are turned into what they say here, since the window
//! has no list to look them up in.
use super::media;
use super::world::{Channel, Instance, World};
use super::Handle;
use crate::model::{Attachment, Embed, Reaction, ReplyPreview};
use crate::state::AppState;
use anyhow::{anyhow, Result};
use regex::Regex;
use serde_json::{json, Value};
use std::sync::OnceLock;
/// How many messages a page of history asks for.
pub const PAGE: usize = 50;
const CROCKFORD: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
/// When a message was sent, from its id: a ULID's first ten characters are the time in milliseconds.
pub fn ulid_ms(id: &str) -> Option<i64> {
let head = id.get(..10)?;
let mut n: u64 = 0;
for c in head.bytes() {
let v = CROCKFORD.iter().position(|x| *x == c.to_ascii_uppercase())? as u64;
n = n.checked_mul(32)?.checked_add(v)?;
}
i64::try_from(n).ok()
}
fn regexes() -> &'static (Regex, Regex, Regex, Regex, Regex, Regex) {
static R: OnceLock<(Regex, Regex, Regex, Regex, Regex, Regex)> = OnceLock::new();
R.get_or_init(|| {
(
Regex::new(r"<@([0-9A-Za-z]{26})>").expect("a fixed pattern parses"),
Regex::new(r"<#([0-9A-Za-z]{26})>").expect("a fixed pattern parses"),
Regex::new(r"<%([0-9A-Za-z]{26})>").expect("a fixed pattern parses"),
Regex::new(r":([0-9A-Za-z]{26}):").expect("a fixed pattern parses"),
Regex::new(r"!!([^\n]+?)!!").expect("a fixed pattern parses"),
Regex::new(r"<t:(\d{1,12})(?::[a-zA-Z])?>").expect("a fixed pattern parses"),
)
})
}
/// A message's words as this client shows them: who and what is named said by name, spoilers in Discord's marks (the
/// window draws those), timestamps as a date.
pub fn words(world: &World, files: Option<(&Instance, &str)>, server: Option<&str>, content: &str) -> String {
let (user, channel, role, emoji, spoiler, time) = regexes();
let out = user.replace_all(content, |c: &regex::Captures| format!("@{}", world.name_of(&c[1])));
let out = channel.replace_all(&out, |c: &regex::Captures| world.channels.get(&c[1]).map(|ch| format!("#{}", ch.name)).unwrap_or_else(|| "#channel".to_string()));
let out = role.replace_all(&out, |c: &regex::Captures| {
let name = server.and_then(|s| world.servers.get(s)).and_then(|s| s.roles.get(&c[1])).map(|r| r.name.clone());
format!("@{}", name.unwrap_or_else(|| "role".to_string()))
});
// A custom emoji is drawn: its picture is named in the cache and fetched when the window looks, and the line says so
// in the form the window reads (`[stoat:emoji:name:file]`). Without a file server it is its name.
let out = emoji.replace_all(&out, |c: &regex::Captures| match (world.emojis.get(&c[1]), files) {
(Some(info), Some((instance, account))) => match media::file_url(instance, "emojis", &c[1]) {
Some(url) => format!("[stoat:emoji:{}:{}]", token_name(&info.name), media::cached(account, media::MEDIA, &url, None, Some(if info.animated { "image/gif" } else { "image/png" }))),
None => format!(":{}:", info.name),
},
(Some(info), None) => format!(":{}:", info.name),
(None, _) => c[0].to_string(),
});
let out = spoiler.replace_all(&out, "||$1||");
let out = time.replace_all(&out, |c: &regex::Captures| {
c[1].parse::<i64>().ok().and_then(|t| chrono::DateTime::from_timestamp(t, 0)).map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string()).unwrap_or_else(|| c[0].to_string())
});
out.into_owned()
}
/// What is written in the box turned into what Stoat reads: an emoji named between colons becomes the id it is known by.
/// A name no custom emoji has is left alone, and so is an id.
pub fn outgoing(world: &World, text: &str) -> String {
static NAMED: OnceLock<Regex> = OnceLock::new();
let named = NAMED.get_or_init(|| Regex::new(r":([A-Za-z0-9_]{2,32}):").expect("a fixed pattern parses"));
named
.replace_all(text, |c: &regex::Captures| {
let word = &c[1];
if world.emojis.contains_key(word) {
return c[0].to_string();
}
let mut found: Vec<&String> = world.emojis.iter().filter(|(_, e)| e.name == word).map(|(id, _)| id).collect();
found.sort();
match found.first() {
Some(id) => format!(":{id}:"),
None => c[0].to_string(),
}
})
.into_owned()
}
/// A name as the emoji token carries it: the letters, digits and underscores a name is made of.
fn token_name(name: &str) -> String {
name.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '_').take(32).collect()
}
/// What a system message says: somebody joining, leaving, being removed, a channel renamed.
pub fn system_text(world: &World, system: &Value) -> Option<String> {
let name = |key: &str| world.name_of(system[key].as_str().unwrap_or(""));
Some(match system["type"].as_str()? {
"text" => system["content"].as_str()?.to_string(),
"user_added" => format!("{} was added by {}", name("id"), name("by")),
"user_remove" => format!("{} was removed by {}", name("id"), name("by")),
"user_joined" => format!("{} joined", name("id")),
"user_left" => format!("{} left", name("id")),
"user_kicked" => format!("{} was kicked", name("id")),
"user_banned" => format!("{} was banned", name("id")),
"channel_renamed" => format!("{} renamed the channel to {}", name("by"), system["name"].as_str().unwrap_or("")),
"channel_description_changed" => format!("{} changed the channel description", name("by")),
"channel_icon_changed" => format!("{} changed the channel icon", name("by")),
"channel_ownership_changed" => format!("{} gave the channel to {}", name("from"), name("to")),
"message_pinned" => format!("{} pinned a message", name("by")),
"message_unpinned" => format!("{} unpinned a message", name("by")),
"call_started" => format!("{} started a call", name("by")),
other => other.replace('_', " "),
})
}
fn colour(text: &str) -> Option<i64> {
let hex = text.strip_prefix('#')?;
(hex.len() == 6).then(|| i64::from_str_radix(hex, 16).ok()).flatten()
}
/// The address a picture from another site is fetched at: through the instance's link proxy where it has one, so the
/// site does not learn who is looking.
fn proxied(instance: &Instance, url: &str) -> Option<String> {
let proxy = instance.january.as_deref()?;
let mut out = url::Url::parse(&format!("{proxy}/proxy")).ok()?;
out.query_pairs_mut().append_pair("url", url);
Some(out.to_string())
}
fn attachment_of(account_id: &str, instance: &Instance, file: &Value) -> Option<Attachment> {
let id = file["_id"].as_str()?;
let url = media::file_url(instance, file["tag"].as_str().unwrap_or("attachments"), id)?;
let content_type = file["content_type"].as_str();
let filename = file["filename"].as_str();
let metadata = &file["metadata"];
let kind = match metadata["type"].as_str() {
Some("Image") => "image",
Some("Video") => "video",
Some("Audio") => "audio",
_ => "file",
};
Some(Attachment {
kind: kind.to_string(),
mimetype: content_type.map(str::to_string),
filename: filename.map(str::to_string),
size: file["size"].as_u64(),
width: metadata["width"].as_u64().map(|w| w as u32),
height: metadata["height"].as_u64().map(|h| h as u32),
path: Some(media::cached(account_id, media::MEDIA, &url, filename, content_type)),
url: Some(url),
..Default::default()
})
}
fn embed_of(account_id: &str, instance: &Instance, embed: &Value) -> Option<Embed> {
let picture = |v: &Value| v["url"].as_str().and_then(|u| proxied(instance, u)).map(|u| media::cached(account_id, media::MEDIA, &u, None, None));
match embed["type"].as_str()? {
"Website" => Some(Embed {
title: embed["title"].as_str().map(str::to_string),
description: embed["description"].as_str().map(str::to_string),
url: embed["url"].as_str().or(embed["original_url"].as_str()).map(str::to_string),
provider: embed["site_name"].as_str().map(str::to_string),
color: embed["colour"].as_str().and_then(colour),
image_url: picture(&embed["image"]),
..Default::default()
}),
"Text" => Some(Embed {
title: embed["title"].as_str().map(str::to_string),
description: embed["description"].as_str().map(str::to_string),
url: embed["url"].as_str().map(str::to_string),
color: embed["colour"].as_str().and_then(colour),
..Default::default()
}),
"Image" => embed["url"].as_str().and_then(|u| proxied(instance, u)).map(|u| Embed {
url: embed["url"].as_str().map(str::to_string),
image_url: Some(media::cached(account_id, media::MEDIA, &u, None, None)),
..Default::default()
}),
_ => None,
}
}
/// The reactions on a message as moho's: `{ emoji: [user ids] }`.
/// How a reaction is held and shown: a custom emoji by its picture (the form a message draws one in) where there is a
/// file server, else by its name between colons; anything else as it is.
pub fn reaction_key(world: &World, files: Option<(&Instance, &str)>, emoji: &str) -> String {
if !(emoji.len() == 26 && emoji.chars().all(|c| c.is_ascii_alphanumeric())) {
return emoji.to_string();
}
let Some(info) = world.emojis.get(emoji) else { return ":emoji:".to_string() };
match files.and_then(|(instance, account)| media::file_url(instance, "emojis", emoji).map(|url| (account, url))) {
Some((account, url)) => format!("[stoat:emoji:{}:{}]", token_name(&info.name), media::cached(account, media::MEDIA, &url, None, Some(if info.animated { "image/gif" } else { "image/png" }))),
None => format!(":{}:", info.name),
}
}
/// The name in a reaction as it is held: `party` from `:party:` or from the picture's form.
pub fn reaction_name(held: &str) -> String {
let inner = held.strip_prefix("[stoat:emoji:").and_then(|s| s.split(':').next()).unwrap_or(held);
inner.trim_matches(':').to_string()
}
pub fn reactions_of(world: &World, files: Option<(&Instance, &str)>, reactions: &Value) -> Vec<Reaction> {
let mut out: Vec<Reaction> = reactions
.as_object()
.map(|o| {
o.iter()
.filter_map(|(emoji, who)| {
let who = who.as_array()?;
// A custom emoji is named by its id, which means nothing to a reader.
let shown = reaction_key(world, files, emoji);
Some(Reaction { emoji: shown, count: who.len() as i64, me: who.iter().any(|u| u.as_str() == Some(world.own_id.as_str())), animated: false })
})
.collect()
})
.unwrap_or_default();
out.sort_by(|a, b| b.count.cmp(&a.count).then(a.emoji.cmp(&b.emoji)));
out
}
/// The marks beside a sender's name: that it is a bot, a webhook, or somebody speaking under another name and picture
/// (a bridge, a bot relaying) - which would otherwise pass for the person it says it is.
pub fn badges_of(world: &World, message: &Value) -> Vec<crate::backend::kick::api::Badge> {
use crate::backend::kick::api::Badge;
let author = message["author"].as_str().unwrap_or("");
if message["webhook"].is_object() {
return vec![Badge { kind: "webhook".into(), text: "Webhook".into(), ..Default::default() }];
}
let mut out = Vec::new();
if message["masquerade"]["name"].as_str().is_some_and(|n| !n.is_empty()) {
// The first three letters of the words are what is drawn beside the name.
out.push(Badge { kind: "via".into(), text: format!("Via {}: it is sent under another name", world.name_of(author)), ..Default::default() });
}
if world.users.get(author).is_some_and(|u| u.bot) {
out.push(Badge { kind: "app".into(), text: "Bot".into(), ..Default::default() });
}
out
}
/// The buffer a channel's conversation is, if the account has one for it.
pub fn buffer_of(state: &AppState, account_id: &str, channel_id: &str) -> Option<crate::model::Buffer> {
state.runtime.list_buffers().into_iter().find(|b| b.account_id == account_id && b.remote_id.as_deref() == Some(channel_id))
}
/// Keeps a message's author, which the message often brings with it.
fn absorb_author(world: &mut World, message: &Value) {
if let Some((id, user)) = super::world::parse_user(&message["user"]) {
world.users.entry(id).and_modify(|u| u.name = user.name.clone()).or_insert(user);
}
}
/// Records a message, as heard live or read from history. Answers whether it was new.
pub fn record(state: &AppState, handle: &Handle, message: &Value) -> bool {
let (Some(id), Some(channel_id)) = (message["_id"].as_str(), message["channel"].as_str()) else { return false };
let account_id = &handle.account_id;
let Some(buffer) = buffer_of(state, account_id, channel_id) else {
tracing::debug!("stoat[{account_id}]: a message in a channel with no conversation was not shown");
return false;
};
let ts = ulid_ms(id).map(|ms| ms / 1000).unwrap_or_else(|| chrono::Utc::now().timestamp());
let mut world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
absorb_author(&mut world, message);
let channel: Option<Channel> = world.channels.get(channel_id).cloned();
let server = channel.as_ref().and_then(|c| c.server.clone());
let author = message["author"].as_str().unwrap_or("");
let own = world.own_id.clone();
// A system message is the room speaking, not a person.
if let Some(system) = message.get("system").filter(|s| s.is_object()) {
let text = system_text(&world, system).unwrap_or_default();
drop(world);
return state.runtime.record_message_at(state, account_id, &buffer.name, &buffer.kind, "Stoat", &text, false, "system", None, Some(id.to_string()), false, None, Vec::new(), Vec::new(), None, Some(ts), None, None);
}
let masquerade = &message["masquerade"];
let from = masquerade["name"]
.as_str()
.filter(|n| !n.is_empty())
.map(str::to_string)
.or_else(|| message["webhook"]["name"].as_str().filter(|n| !n.is_empty()).map(str::to_string))
.or_else(|| message["member"]["nickname"].as_str().filter(|n| !n.is_empty()).map(str::to_string))
.unwrap_or_else(|| world.name_of(author));
let avatar = masquerade["avatar"]
.as_str()
.map(|u| media::cached(account_id, media::AVATARS, u, None, None))
.or_else(|| message["webhook"]["avatar"].as_str().and_then(|f| media::file_url(&handle.instance, "avatars", f)).map(|u| media::cached(account_id, media::AVATARS, &u, None, None)))
.or_else(|| world.users.get(author).and_then(|u| u.avatar.clone()).and_then(|f| media::file_url(&handle.instance, "avatars", &f)).map(|u| media::cached(account_id, media::AVATARS, &u, None, None)));
let content = message["content"].as_str().unwrap_or("");
let body = words(&world, Some((&handle.instance, account_id)), server.as_deref(), content);
let highlight = message["mentions"].as_array().is_some_and(|m| m.iter().any(|u| u.as_str() == Some(own.as_str())))
|| server.as_ref().and_then(|s| world.members.get(s)).zip(message["role_mentions"].as_array()).is_some_and(|(m, roles)| roles.iter().any(|r| r.as_str().is_some_and(|r| m.roles.iter().any(|mine| mine == r))));
let attachments: Vec<Attachment> = message["attachments"].as_array().into_iter().flatten().filter_map(|f| attachment_of(account_id, &handle.instance, f)).collect();
let embeds: Vec<Embed> = message["embeds"].as_array().into_iter().flatten().filter_map(|e| embed_of(account_id, &handle.instance, e)).collect();
let reactions = reactions_of(&world, Some((&handle.instance, account_id)), &message["reactions"]);
let badges = badges_of(&world, message);
let reply_id = message["replies"].as_array().and_then(|r| r.first()).and_then(|r| r.as_str()).map(str::to_string);
drop(world);
let reply = reply_id.as_ref().map(|reply_id| {
let stored = state.store.get_message(&buffer.id, reply_id).ok().flatten();
ReplyPreview {
id: reply_id.clone(),
from: stored.as_ref().map(|m| m.from.clone()).unwrap_or_default(),
body: stored.map(|m| if m.body.trim().is_empty() { "Attachment".to_string() } else { m.body }).unwrap_or_default(),
thread: false,
forwarded: false,
thread_root: None,
chat: None,
}
});
let needs_fill = reply.as_ref().is_some_and(|r| r.body.is_empty());
let style = (!badges.is_empty()).then_some(crate::model::SenderStyle { color: None, badges });
let recorded = state.runtime.record_message_at(state, account_id, &buffer.name, &buffer.kind, &from, &body, false, "chat", reply, Some(id.to_string()), highlight, avatar, embeds, attachments, Some(author.to_string()), Some(ts), None, style);
if recorded && !reactions.is_empty() && state.store.set_reactions(&buffer.id, id, &reactions).unwrap_or(false) {
state.events.emit("reactionsChanged", json!({ "bufferId": buffer.id, "id": id, "reactions": reactions }));
}
if needs_fill {
if let Some(reply_id) = reply_id {
fill_reply_later(state, handle, &buffer.id, channel_id, id, &reply_id);
}
}
recorded
}
/// Asks Stoat what a reply answered, and fills the quote in when it comes.
fn fill_reply_later(state: &AppState, handle: &Handle, buffer_id: &str, channel_id: &str, msg_id: &str, reply_id: &str) {
let (state, handle) = (state.clone(), handle.clone());
let (buffer_id, channel_id, msg_id, reply_id) = (buffer_id.to_string(), channel_id.to_string(), msg_id.to_string(), reply_id.to_string());
tokio::spawn(async move {
let Ok(original) = handle.api.get(&format!("/channels/{channel_id}/messages/{reply_id}"), &[]).await else { return };
let world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
let from = original["masquerade"]["name"].as_str().map(str::to_string).unwrap_or_else(|| world.name_of(original["author"].as_str().unwrap_or("")));
let server = world.channels.get(&channel_id).and_then(|c| c.server.clone());
let body = words(&world, Some((&handle.instance, &handle.account_id)), server.as_deref(), original["content"].as_str().unwrap_or(""));
drop(world);
let body = if body.trim().is_empty() { "Attachment".to_string() } else { body };
state.runtime.fill_reply_preview(&state, &buffer_id, &msg_id, &from, &body);
});
}
/// Reads a page of a channel's history and records it, oldest first so a reply finds what it answers already kept.
/// `before` is the id of the oldest message already held. Answers how many came.
pub async fn history(state: &AppState, handle: &Handle, buffer: &crate::model::Buffer, before: Option<&str>) -> Result<usize> {
let channel_id = buffer.remote_id.as_deref().ok_or_else(|| anyhow!("this is not a Stoat conversation"))?;
let mut query = vec![("limit", PAGE.to_string()), ("include_users", "true".to_string())];
if let Some(before) = before.filter(|b| b.len() == 26) {
query.push(("before", before.to_string()));
}
let answer = handle.api.get(&format!("/channels/{channel_id}/messages"), &query).await.map_err(|e| anyhow!("{e}"))?;
let (messages, users) = match &answer {
Value::Array(list) => (list.clone(), Vec::new()),
other => (other["messages"].as_array().cloned().unwrap_or_default(), other["users"].as_array().cloned().unwrap_or_default()),
};
{
let mut world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
for u in &users {
if let Some((id, user)) = super::world::parse_user(u) {
world.users.entry(id).or_insert(user);
}
}
}
let mut ordered = messages;
ordered.sort_by(|a, b| a["_id"].as_str().cmp(&b["_id"].as_str()));
let count = ordered.len();
for message in &ordered {
record(state, handle, message);
}
Ok(count)
}
#[cfg(test)]
mod tests {
use super::*;
const ME: &str = "01ME000000000000000000000A";
fn world() -> World {
let mut w = World::default();
w.absorb_ready(
ME,
&json!({
"users": [{ "_id": ME, "username": "me", "display_name": "Me" }, { "_id": "01PAL00000000000000000000A", "username": "pal", "display_name": "Pal" }],
"servers": [{ "_id": "S1", "owner": ME, "name": "Chasm", "channels": ["C1"], "roles": { "01ROLE0000000000000000000A": { "name": "Mods", "rank": 1, "permissions": { "a": 0, "d": 0 } } } }],
"channels": [{ "_id": "01CHAN0000000000000000000A", "channel_type": "TextChannel", "server": "S1", "name": "general" }],
"emojis": [{ "_id": "01EMOJ000000000000000000AA", "name": "party" }]
}),
);
w
}
#[test]
fn a_ulid_says_when_it_was_made() {
// The example in the ULID specification, made on 2016-07-30 at 23:54:10.259 UTC.
assert_eq!(ulid_ms("01ARZ3NDEKTSV4RRFFQ69G5FAV"), Some(1_469_922_850_259));
assert_eq!(ulid_ms("not an id"), None);
assert_eq!(ulid_ms("01ARZ3"), None);
assert!(ulid_ms("01ARZ3NDEKTSV4RRFFQ69G5FAV") < ulid_ms("01ARZ3NDEMTSV4RRFFQ69G5FAV"), "a later id is a later time");
}
#[test]
fn who_and_what_is_named_is_said_by_name() {
let w = world();
assert_eq!(words(&w, None, Some("S1"), "hi <@01PAL00000000000000000000A>, see <#01CHAN0000000000000000000A>"), "hi @Pal, see #general");
assert_eq!(words(&w, None, Some("S1"), "calling <%01ROLE0000000000000000000A>"), "calling @Mods");
assert_eq!(words(&w, None, None, "yay :01EMOJ000000000000000000AA: and :01NONE000000000000000000AA:"), "yay :party: and :01NONE000000000000000000AA:");
assert_eq!(words(&w, None, None, "nobody <@01GONE0000000000000000000>"), "nobody <@01GONE0000000000000000000>", "an id that is not 26 characters is not a mention");
}
#[test]
fn an_emoji_named_in_the_box_goes_out_as_its_id_and_what_is_not_one_is_left_alone() {
let w = world();
assert_eq!(outgoing(&w, "yay :party: :nobody: 12:30:45 and :01EMOJ000000000000000000AA:"), "yay :01EMOJ000000000000000000AA: :nobody: 12:30:45 and :01EMOJ000000000000000000AA:");
assert_eq!(outgoing(&w, "no colons"), "no colons");
}
#[test]
fn an_emoji_in_a_message_is_a_picture_named_in_the_cache_where_there_is_a_file_server() {
let w = world();
let instance = Instance { autumn: Some("https://cdn.example".into()), ..Default::default() };
let said = words(&w, Some((&instance, "stoat:me")), None, "yay :01EMOJ000000000000000000AA:");
assert!(said.starts_with("yay [stoat:emoji:party:file://") && said.contains("/stoat-media/") && said.ends_with(".png]"), "{said}");
assert_eq!(words(&w, Some((&Instance::default(), "stoat:me")), None, "yay :01EMOJ000000000000000000AA:"), "yay :party:", "without a file server it is its name");
}
#[test]
fn spoilers_and_times_are_written_as_the_window_reads_them() {
let w = world();
assert_eq!(words(&w, None, None, "the end is !!a twist!! really"), "the end is ||a twist|| really");
assert_eq!(words(&w, None, None, "at <t:1469918176:R> sharp"), "at 2016-07-30 22:36 UTC sharp");
}
#[test]
fn what_happened_in_a_room_is_said_in_words() {
let w = world();
let say = |v: Value| system_text(&w, &v);
assert_eq!(say(json!({ "type": "user_joined", "id": "01PAL00000000000000000000A" })).as_deref(), Some("Pal joined"));
assert_eq!(say(json!({ "type": "user_kicked", "id": ME })).as_deref(), Some("Me was kicked"));
assert_eq!(say(json!({ "type": "channel_renamed", "by": ME, "name": "lobby" })).as_deref(), Some("Me renamed the channel to lobby"));
assert_eq!(say(json!({ "type": "text", "content": "Welcome" })).as_deref(), Some("Welcome"));
assert_eq!(say(json!({ "type": "something_new" })).as_deref(), Some("something new"));
assert_eq!(say(json!({})), None);
}
#[test]
fn reactions_are_counted_and_the_accounts_own_is_marked() {
let w = world();
let got = reactions_of(&w, None, &json!({ "🔥": ["01PAL00000000000000000000A"], "👍": [ME, "01PAL00000000000000000000A"], "01EMOJ000000000000000000AA": [ME] }));
assert_eq!(got.iter().map(|r| (r.emoji.as_str(), r.count, r.me)).collect::<Vec<_>>(), vec![("👍", 2, true), (":party:", 1, true), ("🔥", 1, false)]);
assert!(reactions_of(&w, None, &Value::Null).is_empty());
let with_files = Instance { autumn: Some("https://cdn.example".into()), ..Default::default() };
let drawn = reactions_of(&w, Some((&with_files, "stoat:me")), &json!({ "01EMOJ000000000000000000AA": [ME] }));
assert!(drawn[0].emoji.starts_with("[stoat:emoji:party:file://") && drawn[0].emoji.ends_with(".png]"), "{}", drawn[0].emoji);
assert_eq!(reaction_name(&drawn[0].emoji), "party");
assert_eq!(reaction_name(":party:"), "party");
assert_eq!(reaction_name("👍"), "👍");
}
#[test]
fn a_sender_who_is_a_bot_a_webhook_or_a_bridge_is_marked() {
let mut w = world();
w.users.insert("01BOT00000000000000000000A".into(), super::super::world::User { name: "Relay".into(), bot: true, ..Default::default() });
let kinds = |m: Value| badges_of(&w, &m).into_iter().map(|b| b.kind).collect::<Vec<_>>();
assert_eq!(kinds(json!({ "author": ME })), Vec::<String>::new());
assert_eq!(kinds(json!({ "author": "01BOT00000000000000000000A" })), vec!["app"]);
assert_eq!(kinds(json!({ "author": "01BOT00000000000000000000A", "masquerade": { "name": "Ada" } })), vec!["via", "app"]);
assert_eq!(kinds(json!({ "author": ME, "webhook": { "name": "CI" } })), vec!["webhook"]);
assert!(badges_of(&w, &json!({ "author": "01BOT00000000000000000000A", "masquerade": { "name": "Ada" } }))[0].text.starts_with("Via Relay"));
}
#[test]
fn a_colour_is_read_as_a_number() {
assert_eq!(colour("#ff8800"), Some(0xff8800));
assert_eq!(colour("red"), None);
assert_eq!(colour("#fff"), None);
}
#[test]
fn a_picture_from_another_site_is_asked_for_through_the_proxy() {
let i = Instance { january: Some("https://proxy.example".into()), ..Default::default() };
assert_eq!(proxied(&i, "https://site.example/a b.png").as_deref(), Some("https://proxy.example/proxy?url=https%3A%2F%2Fsite.example%2Fa+b.png"));
assert_eq!(proxied(&Instance::default(), "https://site.example/a.png"), None, "with no proxy it is not fetched at all");
}
}
-92
View File
@@ -1,92 +0,0 @@
//! Stoat (formerly Revolt): a chat service shaped like Discord - servers with categories, channels and roles, direct
//! messages and groups, replies, reactions, custom emoji, embeds - over a REST API and an events websocket, both open
//! and documented (https://developers.stoat.chat).
//!
//! The plan, and what has been checked, is Gitea issue #32. In short: the official instance is `api.stoat.chat`, but
//! anybody can host one, so an account names its instance (as a Matrix account names its homeserver); the session
//! token is what the account is.
//!
//! # How this folder is arranged
//!
//! - `api` - the REST client: the token, the instance's rate limits kept to as its headers say, and what an error means
//! - `login` - adding an account: email and password, a second factor if there is one, or a pasted token
//! - `world` - what the account can see: users, servers, channels, roles, and what each lets it do
//! - `connect` - an account's connection: the events websocket, kept up and reconnected
//! - `events` - what the websocket says as it happens
//! - `messages` - a message as moho's, and a channel's history
//! - `send` - saying things: send, edit, delete, typing, read
//! - `limits` - the instance's stated limits, kept to before a request is made
//! - `manage` - looking after a server: channels, roles, permissions, webhooks, the audit log
//! - `members` - who is in a conversation, and who is online
//! - `profile` - the card for a person
//! - `own` - the account's own profile and presence
//! - `settings` - what the account has muted, kept on the account
//! - `search` - searching a channel, its pins, and reading around a message
//! - `media` - avatars, icons, pictures and files, fetched from the file server when looked at
//!
//! # What moho tells Stoat about itself
//!
//! The user agent is `moho/<version>` and the session is named `moho (<os>)`, which is what Stoat's list of sessions
//! shows. One connection is kept per account, and a session Stoat has ended stops the account instead of signing in
//! again to get over it.
pub mod api;
pub mod connect;
pub mod events;
pub mod limits;
pub mod login;
pub mod manage;
pub mod media;
pub mod members;
pub mod messages;
pub mod moderation;
pub mod own;
pub mod people;
pub mod profile;
pub mod search;
pub mod settings;
pub mod send;
pub mod voice;
pub mod world;
#[cfg(test)]
mod tests;
pub use connect::spawn;
use std::sync::{Arc, Mutex};
/// What the rest of the daemon needs to act on a connected account.
#[derive(Clone)]
pub struct Handle {
pub account_id: String,
pub api: Arc<api::Api>,
pub world: Arc<Mutex<world::World>>,
/// What the instance said about itself when the connection was made: where its files and previews are, and its limits.
pub instance: Arc<world::Instance>,
/// Lines for the websocket (typing notices), written by the connection that owns it.
pub socket: tokio::sync::mpsc::UnboundedSender<String>,
}
/// The name Stoat shows for this app's sessions.
pub fn session_name() -> String {
format!("moho ({})", std::env::consts::OS)
}
impl Handle {
/// Whether the account is still held to the tighter limits of a new one: its id says when it was made.
pub fn is_new(&self, now_ms: i64) -> bool {
let own = self.world.lock().unwrap_or_else(|e| e.into_inner()).own_id.clone();
let made = messages::ulid_ms(&own).unwrap_or(0);
made > 0 && now_ms - made < (self.instance.new_user_hours as i64) * 3_600_000
}
/// What this account may do now, by its standing.
pub fn limits(&self) -> &world::UserLimits {
if self.is_new(chrono::Utc::now().timestamp_millis()) {
&self.instance.fresh
} else {
&self.instance.standing
}
}
}
-105
View File
@@ -1,105 +0,0 @@
//! What this account may do to a server's members, and doing it: removing, barring, silencing, and giving or taking
//! roles.
//!
//! What the member menu offers is read off this account's own permissions in the conversation (they are advisory: Stoat
//! re-checks and says why in words, as it does for any client). Roles are held in order - a lower rank number is more
//! powerful - and an account may only hand out roles below its own best, which is Stoat's rule.
use super::world::{ASSIGN_ROLES, BAN_MEMBERS, KICK_MEMBERS, MANAGE_MESSAGES, TIMEOUT_MEMBERS};
use super::Handle;
use crate::model::Buffer;
use anyhow::{anyhow, bail, Result};
use serde_json::{json, Value};
/// The server a conversation belongs to.
fn server_of(handle: &Handle, buffer: &Buffer) -> Result<String> {
let channel = buffer.remote_id.as_deref().ok_or_else(|| anyhow!("this is not a Stoat conversation"))?;
handle.world.lock().unwrap_or_else(|e| e.into_inner()).channels.get(channel).and_then(|c| c.server.clone()).ok_or_else(|| anyhow!("this conversation is not in a server"))
}
/// What the menu may offer: the moderating this account's permissions allow, and the roles it may hand out.
pub fn powers(handle: &Handle, buffer: &Buffer) -> Value {
let world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
let Some(channel) = buffer.remote_id.as_deref().and_then(|c| world.channels.get(c)) else { return json!({}) };
let Some(server) = channel.server.as_ref().and_then(|s| world.servers.get(s)) else { return json!({}) };
let permissions = world.permissions(channel, chrono::Utc::now().timestamp());
let mine = world.own_rank(server);
let mut roles: Vec<(&String, &super::world::Role)> = server.roles.iter().filter(|(_, r)| mine.is_some_and(|m| r.rank > m)).collect();
roles.sort_by_key(|(_, r)| r.rank);
json!({
"canKick": permissions & KICK_MEMBERS != 0,
"canBan": permissions & BAN_MEMBERS != 0,
"canMute": permissions & TIMEOUT_MEMBERS != 0,
"canAssignRoles": permissions & ASSIGN_ROLES != 0,
"canRedactOthers": permissions & MANAGE_MESSAGES != 0,
"roles": if permissions & ASSIGN_ROLES != 0 { roles.into_iter().map(|(id, r)| json!({ "id": id, "name": r.name, "colour": r.colour })).collect::<Vec<_>>() } else { Vec::new() },
})
}
/// When a timeout of this many minutes ends, as Stoat reads it.
pub fn timeout_until(now: i64, minutes: i64) -> String {
chrono::DateTime::from_timestamp(now + minutes.max(1) * 60, 0).map(|t| t.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()).unwrap_or_default()
}
/// Removes, bars or silences a member (`kick`, `ban`, `mute`), or lets a silenced one speak again (`unmute`).
pub async fn moderate(handle: &Handle, buffer: &Buffer, user: &str, action: &str, minutes: Option<i64>, reason: Option<&str>) -> Result<()> {
let server = server_of(handle, buffer)?;
let member = format!("/servers/{server}/members/{user}");
match action {
"kick" => handle.api.delete(&member).await.map(|_| ()),
"ban" => handle.api.put(&format!("/servers/{server}/bans/{user}"), Some(&json!({ "reason": reason }))).await.map(|_| ()),
"unban" => handle.api.delete(&format!("/servers/{server}/bans/{user}")).await.map(|_| ()),
"mute" => handle.api.patch(&member, &json!({ "timeout": timeout_until(chrono::Utc::now().timestamp(), minutes.unwrap_or(10)) })).await.map(|_| ()),
"unmute" => handle.api.patch(&member, &json!({ "remove": ["Timeout"] })).await.map(|_| ()),
other => bail!("Stoat has no way to {other} a member"),
}
.map_err(|e| anyhow!("{e}"))
}
/// The roles somebody holds after one is given or taken: the others they held stay, and a role is never held twice.
pub fn roles_after(held: &[String], role: &str, give: bool) -> Vec<String> {
let mut out: Vec<String> = held.iter().filter(|r| r.as_str() != role).cloned().collect();
if give {
out.push(role.to_string());
}
out
}
/// Gives a member a role, or takes it away.
pub async fn set_role(handle: &Handle, buffer: &Buffer, user: &str, role: &str, give: bool) -> Result<()> {
let server = server_of(handle, buffer)?;
let member = handle.api.get(&format!("/servers/{server}/members/{user}"), &[]).await.map_err(|e| anyhow!("{e}"))?;
let held: Vec<String> = member["roles"].as_array().into_iter().flatten().filter_map(|r| r.as_str().map(str::to_string)).collect();
let roles = roles_after(&held, role, give);
handle.api.patch(&format!("/servers/{server}/members/{user}"), &json!({ "roles": roles })).await.map_err(|e| anyhow!("{e}"))?;
Ok(())
}
/// Makes an invite to a channel's server, and answers the link to give out.
pub async fn invite(handle: &Handle, buffer: &Buffer, web: &str) -> Result<String> {
let channel = buffer.remote_id.as_deref().ok_or_else(|| anyhow!("this is not a Stoat conversation"))?;
let made = handle.api.post(&format!("/channels/{channel}/invites"), &json!({})).await.map_err(|e| anyhow!("{e}"))?;
let code = made["_id"].as_str().ok_or_else(|| anyhow!("Stoat did not give an invite"))?;
Ok(format!("{}/invite/{code}", web.trim_end_matches('/')))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_timeout_ends_the_stated_minutes_from_now_in_the_form_stoat_reads() {
assert_eq!(timeout_until(0, 10), "1970-01-01T00:10:00.000Z");
assert_eq!(timeout_until(1_000_000_000, 60), "2001-09-09T02:46:40.000Z");
assert_eq!(timeout_until(0, 0), "1970-01-01T00:01:00.000Z", "never less than a minute");
}
#[test]
fn a_role_is_added_once_and_taken_away_without_touching_the_rest() {
let held = vec!["A".to_string(), "B".to_string()];
assert_eq!(roles_after(&held, "C", true), vec!["A", "B", "C"]);
assert_eq!(roles_after(&held, "A", true), vec!["B", "A"], "not held twice");
assert_eq!(roles_after(&held, "A", false), vec!["B"]);
assert_eq!(roles_after(&held, "Z", false), vec!["A", "B"]);
}
}
-145
View File
@@ -1,145 +0,0 @@
//! The account's own profile: what others see of it, and changing that - display name, pronouns, the line under the
//! name, the picture, and how present it is.
use super::media;
use super::Handle;
use anyhow::{anyhow, Result};
use serde_json::{json, Value};
/// A text field as Stoat has it: some words, or nothing.
pub fn text(v: &Value) -> Option<String> {
match v {
Value::String(s) if !s.is_empty() => Some(s.clone()),
// A list of them is written as one line.
Value::Array(list) => {
let joined: Vec<&str> = list.iter().filter_map(Value::as_str).collect();
(!joined.is_empty()).then(|| joined.join(", "))
}
_ => None,
}
}
/// Stoat's name for each of moho's four statuses. Its "Focus" is the one that only wants to hear when mentioned, which
/// is nearest to moho's do-not-disturb; "Busy" asks for no notifications at all, which is what that means here.
pub fn presence_of(status: &str) -> &'static str {
match status {
"idle" => "Idle",
"dnd" => "Busy",
"invisible" => "Invisible",
_ => "Online",
}
}
/// How the account looks to others, with what may be changed.
pub async fn get(handle: &Handle) -> Result<Value> {
let own = handle.world.lock().unwrap_or_else(|e| e.into_inner()).own_id.clone();
let profile_path = format!("/users/{own}/profile");
let (me, bio) = tokio::join!(handle.api.get("/users/@me", &[]), handle.api.get(&profile_path, &[]));
let me = me.map_err(|e| anyhow!("{e}"))?;
let avatar = me["avatar"]["_id"].as_str().and_then(|f| media::file_url(&handle.instance, "avatars", f)).map(|u| media::cached(&handle.account_id, media::AVATARS, &u, None, None));
Ok(json!({
"accountId": handle.account_id,
"userId": own,
"handle": format!("{}#{}", me["username"].as_str().unwrap_or(""), me["discriminator"].as_str().unwrap_or("")),
"displayName": me["display_name"].as_str().unwrap_or(""),
"pronouns": text(&me["pronouns"]).unwrap_or_default(),
"bio": bio.ok().and_then(|b| text(&b["content"])).unwrap_or_default(),
"statusText": me["status"]["text"].as_str().unwrap_or(""),
"avatarUrl": avatar,
}))
}
/// What a change of the profile asks of Stoat: the fields to set, and the ones to take away (a field emptied is removed,
/// since Stoat would take an empty string as a value).
pub fn change(display_name: Option<&str>, pronouns: Option<&str>, bio: Option<&str>, status_text: Option<&str>) -> Value {
let mut body = json!({});
let mut remove: Vec<&str> = Vec::new();
let mut field = |value: Option<&str>, set: &mut dyn FnMut(&str), gone: &'static str| {
if let Some(v) = value.map(str::trim) {
if v.is_empty() {
remove.push(gone);
} else {
set(v);
}
}
};
field(display_name, &mut |v| body["display_name"] = json!(v), "DisplayName");
field(pronouns, &mut |v| body["pronouns"] = json!(v.chars().take(24).collect::<String>()), "Pronouns");
field(bio, &mut |v| body["profile"] = json!({ "content": v }), "ProfileContent");
field(status_text, &mut |v| body["status"] = json!({ "text": v }), "StatusText");
if !remove.is_empty() {
body["remove"] = json!(remove);
}
body
}
/// Changes the profile. Only what is given is touched.
pub async fn set(handle: &Handle, display_name: Option<&str>, pronouns: Option<&str>, bio: Option<&str>, status_text: Option<&str>) -> Result<()> {
let body = change(display_name, pronouns, bio, status_text);
if body.as_object().is_some_and(|o| o.is_empty()) {
return Ok(());
}
handle.api.patch("/users/@me", &body).await.map_err(|e| anyhow!("{e}"))?;
Ok(())
}
/// Puts a picture from a file on the account.
pub async fn set_avatar(handle: &Handle, path: &str) -> Result<String> {
let server = handle.instance.autumn.clone().ok_or_else(|| anyhow!("this instance has no file server"))?;
let name = super::send::sent_name(path, false);
let size = std::fs::metadata(path).map_err(|e| anyhow!("{path}: {e}"))?.len();
super::limits::may_use_picture("avatar", size, handle.limits()).map_err(|e| anyhow!(e))?;
let bytes = tokio::fs::read(path).await.map_err(|e| anyhow!("reading {path}: {e}"))?;
let id = handle.api.upload(&server, "avatars", &name, super::send::content_type_of(&name), bytes).await.map_err(|e| anyhow!("{e}"))?;
handle.api.patch("/users/@me", &json!({ "avatar": id })).await.map_err(|e| anyhow!("{e}"))?;
Ok(media::file_url(&handle.instance, "avatars", &id).map(|u| media::cached(&handle.account_id, media::AVATARS, &u, None, None)).unwrap_or_default())
}
/// Takes the picture off.
pub async fn clear_avatar(handle: &Handle) -> Result<()> {
handle.api.patch("/users/@me", &json!({ "remove": ["Avatar"] })).await.map(|_| ()).map_err(|e| anyhow!("{e}"))
}
/// Says how present the account is, and what it says beside that where it has been given something to say.
pub async fn apply_status(handle: &Handle, status: &str, text: Option<&str>) -> bool {
let mut body = change(None, None, None, text);
body["status"] = {
let mut s = body["status"].take();
if !s.is_object() {
s = json!({});
}
s["presence"] = json!(presence_of(status));
s
};
handle.api.patch("/users/@me", &body).await.is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_field_given_is_set_and_one_emptied_is_removed_and_one_not_given_is_left() {
let body = change(Some(" Ada "), Some("she, her"), Some(""), None);
assert_eq!(body["display_name"], "Ada");
assert_eq!(body["pronouns"], json!("she, her"), "one line, and at most 24 characters");
assert_eq!(change(None, Some(&"x".repeat(40)), None, None)["pronouns"], json!("x".repeat(24)));
assert_eq!(body["remove"], json!(["ProfileContent"]));
assert!(body.get("status").is_none() && body.get("profile").is_none());
assert_eq!(change(None, None, None, None), json!({}));
assert_eq!(change(None, None, None, Some("out"))["status"], json!({ "text": "out" }));
assert_eq!(change(None, None, None, Some(" "))["remove"], json!(["StatusText"]));
}
#[test]
fn moho_s_statuses_are_stoat_s() {
assert_eq!((presence_of("online"), presence_of("idle"), presence_of("dnd"), presence_of("invisible")), ("Online", "Idle", "Busy", "Invisible"));
}
#[test]
fn pronouns_may_come_as_one_string_or_a_list() {
assert_eq!(text(&json!(["he", "him"])).as_deref(), Some("he, him"));
assert_eq!(text(&json!("they/them")).as_deref(), Some("they/them"));
assert_eq!(text(&json!([])), None);
}
}
-179
View File
@@ -1,179 +0,0 @@
//! The account's own people: opening a conversation with somebody, friends and their requests, blocking, and joining
//! and leaving servers.
//!
//! A friendship is made by one side asking and the other agreeing (`PUT /users/{id}/friend` does either), taken back
//! or refused by `DELETE`. What Stoat sends as the relationship changes (`UserRelationship`) is what the invitations
//! tile lists: everybody whose relationship to this account is `Incoming`.
use super::events;
use super::media;
use super::messages;
use super::world::{parse_channel, parse_server, Kind};
use super::Handle;
use crate::state::AppState;
use anyhow::{anyhow, bail, Result};
use serde_json::{json, Value};
/// The code in what somebody pasted: a bare code, or a link such as `https://stoat.chat/invite/Code`.
pub fn invite_code(typed: &str) -> Option<String> {
let text = typed.trim().trim_end_matches('/');
let code = match url::Url::parse(text) {
Ok(url) if matches!(url.scheme(), "http" | "https") => {
let parts: Vec<&str> = url.path_segments()?.filter(|p| !p.is_empty()).collect();
match parts.as_slice() {
[.., "invite", code] => (*code).to_string(),
[code] if url.host_str().is_some_and(|h| h.ends_with("rvlt.gg") || h.ends_with("stoat.gg")) => (*code).to_string(),
_ => return None,
}
}
_ => text.to_string(),
};
(!code.is_empty() && code.len() <= 32 && code.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')).then_some(code)
}
/// Opens the conversation with somebody (or the account's own notes, for itself), and answers its buffer id.
pub async fn open_dm(state: &AppState, handle: &Handle, user: &str) -> Result<String> {
let channel = handle.api.get(&format!("/users/{user}/dm"), &[]).await.map_err(|e| anyhow!("{e}"))?;
let parsed = parse_channel(&channel).ok_or_else(|| anyhow!("Stoat did not say where that conversation is"))?;
// Who they are, for the name it is called by.
let known = handle.world.lock().unwrap_or_else(|e| e.into_inner()).users.contains_key(user);
if !known {
if let Ok(found) = handle.api.get(&format!("/users/{user}"), &[]).await {
if let Some((id, u)) = super::world::parse_user(&found) {
handle.world.lock().unwrap_or_else(|e| e.into_inner()).users.insert(id, u);
}
}
}
handle.world.lock().unwrap_or_else(|e| e.into_inner()).channels.insert(parsed.id.clone(), parsed.clone());
events::register_channel(state, handle, &parsed, 0, None);
messages::buffer_of(state, &handle.account_id, &parsed.id).map(|b| b.id).ok_or_else(|| anyhow!("the conversation did not come up"))
}
/// Joins a server by its invite and makes its conversations.
pub async fn join_invite(state: &AppState, handle: &Handle, typed: &str) -> Result<()> {
let code = invite_code(typed).ok_or_else(|| anyhow!("\"{}\" is not a Stoat invite link or code", typed.trim()))?;
let held = handle.world.lock().unwrap_or_else(|e| e.into_inner()).servers.len();
super::limits::may_join(handle.limits(), held).map_err(|e| anyhow!(e))?;
let joined = handle.api.post(&format!("/invites/{code}"), &json!({})).await.map_err(|e| anyhow!("{e}"))?;
match joined["type"].as_str() {
Some("Server") => {
let server = parse_server(&joined["server"]).ok_or_else(|| anyhow!("Stoat did not describe that server"))?;
{
let mut world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
for c in joined["channels"].as_array().into_iter().flatten() {
if let Some(channel) = parse_channel(c) {
world.channels.insert(channel.id.clone(), channel);
}
}
world.members.entry(server.id.clone()).or_insert_with(|| super::world::Member { roles: Vec::new(), timeout: None, nickname: None, can_publish: true, can_receive: true });
world.servers.insert(server.id.clone(), server.clone());
}
events::register_server_entry(state, handle, &server);
Ok(())
}
Some("Group") => {
if let Some(channel) = parse_channel(&joined["channel"]) {
handle.world.lock().unwrap_or_else(|e| e.into_inner()).channels.insert(channel.id.clone(), channel.clone());
events::register_channel(state, handle, &channel, 0, None);
}
Ok(())
}
_ => bail!("Stoat answered something moho does not know how to follow"),
}
}
/// Leaves a server the account does not own.
pub async fn leave_server(state: &AppState, handle: &Handle, server: &str) -> Result<()> {
{
let world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
if world.servers.get(server).is_some_and(|s| s.owner == world.own_id) {
bail!("You own this server, and leaving it would delete it for everybody. Delete it from Stoat's own app if you mean to");
}
}
handle.api.delete(&format!("/servers/{server}")).await.map_err(|e| anyhow!("{e}"))?;
// The events say it too; this is so the window need not wait for them.
events::handle(state, handle, &json!({ "type": "ServerDelete", "id": server }));
Ok(())
}
/// Everybody who has asked to be this account's friend, as the invitations tile lists them.
pub fn incoming(handle: &Handle) -> Vec<Value> {
let world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
let mut list: Vec<(&String, &super::world::User)> = world.users.iter().filter(|(_, u)| u.relationship == "Incoming").collect();
list.sort_by_key(|(_, u)| u.name.to_lowercase());
list.into_iter()
.map(|(id, u)| {
let avatar = u.avatar.as_deref().and_then(|f| media::file_url(&handle.instance, "avatars", f)).map(|url| media::cached(&handle.account_id, media::AVATARS, &url, None, None));
json!({ "id": id, "name": u.name, "avatarUrl": avatar })
})
.collect()
}
/// Says the list of requests, so the tile is right.
pub fn tell_requests(state: &AppState, handle: &Handle) {
state.events.emit("stoatFriendRequests", json!({ "accountId": handle.account_id, "incoming": incoming(handle) }));
}
/// Asks somebody to be a friend by their `name#1234`.
pub async fn add_friend(handle: &Handle, username: &str) -> Result<()> {
let name = username.trim().trim_start_matches('@');
if !name.contains('#') || name.len() < 3 {
bail!("Type their name with the number after it, like name#1234");
}
super::limits::may_ask(handle.limits(), &handle.world.lock().unwrap_or_else(|e| e.into_inner())).map_err(|e| anyhow!(e))?;
handle.api.post("/users/friend", &json!({ "username": name })).await.map_err(|e| anyhow!("{e}"))?;
Ok(())
}
/// Accepts or turns down somebody's request, or takes a friend back.
pub async fn answer_friend(state: &AppState, handle: &Handle, user: &str, accept: bool) -> Result<()> {
let path = format!("/users/{user}/friend");
let answer = if accept { handle.api.put(&path, None).await } else { handle.api.delete(&path).await };
let updated = answer.map_err(|e| anyhow!("{e}"))?;
// What Stoat says the relationship is now (the person, as it sees them), kept as the event would have.
if let Some((id, parsed)) = super::world::parse_user(&updated) {
let mut world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
world.users.entry(id).and_modify(|u| u.relationship = parsed.relationship.clone()).or_insert(parsed);
} else if !accept {
if let Some(u) = handle.world.lock().unwrap_or_else(|e| e.into_inner()).users.get_mut(user) {
u.relationship = "None".to_string();
}
}
tell_requests(state, handle);
Ok(())
}
/// Blocks somebody, or lets them through again.
pub async fn block(state: &AppState, handle: &Handle, user: &str, blocked: bool) -> Result<()> {
let path = format!("/users/{user}/block");
let answer = if blocked { handle.api.put(&path, None).await } else { handle.api.delete(&path).await };
let updated = answer.map_err(|e| anyhow!("{e}"))?;
if let Some((id, parsed)) = super::world::parse_user(&updated) {
handle.world.lock().unwrap_or_else(|e| e.into_inner()).users.entry(id).and_modify(|u| u.relationship = parsed.relationship.clone()).or_insert(parsed);
}
// A conversation with them may now be one it cannot write in, or can again.
let direct: Vec<_> = handle.world.lock().unwrap_or_else(|e| e.into_inner()).channels.values().filter(|c| c.kind == Kind::Direct && c.recipients.iter().any(|r| r == user)).cloned().collect();
for channel in direct {
events::register_channel(state, handle, &channel, 0, None);
}
tell_requests(state, handle);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_invite_is_read_from_a_link_or_a_bare_code() {
assert_eq!(invite_code("gJhyqXTt").as_deref(), Some("gJhyqXTt"));
assert_eq!(invite_code("https://stoat.chat/invite/gJhyqXTt").as_deref(), Some("gJhyqXTt"));
assert_eq!(invite_code("https://app.stoat.chat/invite/gJhyqXTt/").as_deref(), Some("gJhyqXTt"));
assert_eq!(invite_code("http://localhost:8880/invite/abc-123_x").as_deref(), Some("abc-123_x"));
assert_eq!(invite_code("https://stoat.gg/Abc123").as_deref(), Some("Abc123"));
assert_eq!(invite_code("https://example.com/somewhere"), None);
assert_eq!(invite_code(""), None);
assert_eq!(invite_code("not a code!"), None);
assert_eq!(invite_code("../../etc/passwd"), None);
}
}
-130
View File
@@ -1,130 +0,0 @@
//! The card for a person: their name, handle, picture, what they wrote about themselves, the badges they hold, when
//! they made the account and joined the server, and who and what they have in common with this account.
use super::members::status_of;
use super::world::{Kind, User};
use super::{media, messages};
use crate::profile;
use crate::state::AppState;
use serde_json::{json, Value};
/// What Stoat's badge bits stand for.
pub fn badge_names(flags: u64) -> Vec<&'static str> {
[
(1, "Stoat developer"),
(2, "Translator"),
(4, "Supporter"),
(8, "Responsible disclosure"),
(16, "Founder"),
(32, "Platform moderator"),
(64, "Active supporter"),
(128, "Paw"),
(256, "Early adopter"),
]
.iter()
.filter(|(bit, _)| flags & bit != 0)
.map(|(_, name)| *name)
.collect()
}
/// The person a card is for: the other one in a direct message, or the member the window named by id.
fn target_of(state: &AppState, buffer_id: &str, user_id: &str, own: &str) -> Option<String> {
let buffer = state.runtime.get_buffer(buffer_id)?;
if buffer.kind == "dm" {
let handle = state.runtime.stoat_handle(&buffer.account_id)?;
let world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
let channel = world.channels.get(buffer.remote_id.as_deref()?)?;
if channel.kind == Kind::Direct {
return channel.recipients.iter().find(|r| *r != own).cloned();
}
}
(user_id.len() == 26).then(|| user_id.to_string())
}
pub async fn build(state: &AppState, account_id: &str, buffer_id: &str, who: &str, user_id: &str) -> Value {
let mut card = profile::pending("stoat", account_id, who);
card["pending"] = json!(false);
let Some(handle) = state.runtime.stoat_handle(account_id) else { return card };
let own = handle.world.lock().unwrap_or_else(|e| e.into_inner()).own_id.clone();
let Some(target) = target_of(state, buffer_id, user_id, &own) else { return card };
card["id"] = json!(target);
if let Some(ms) = messages::ulid_ms(&target) {
card["createdTs"] = json!(ms / 1000);
}
let (path, profile_path, mutual_path) = (format!("/users/{target}"), format!("/users/{target}/profile"), format!("/users/{target}/mutual"));
let (full, bio, mutual) = tokio::join!(handle.api.get(&path, &[]), handle.api.get(&profile_path, &[]), handle.api.get(&mutual_path, &[]));
if let Ok(user) = &full {
if let Some((id, parsed)) = super::world::parse_user(user) {
let handle_text = format!("{}#{}", user["username"].as_str().unwrap_or(""), user["discriminator"].as_str().unwrap_or(""));
card["name"] = json!(parsed.name);
card["handle"] = json!(handle_text);
let avatar = parsed.avatar.as_deref().and_then(|f| media::file_url(&handle.instance, "avatars", f)).map(|u| media::cached(account_id, media::AVATARS, &u, None, None));
profile::set(&mut card, "avatarUrl", avatar.map(|u| json!(u)));
let known: User = {
let mut world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
world.users.entry(id).and_modify(|u| u.online = parsed.online).or_insert(parsed.clone());
parsed
};
card["status"] = json!(status_of(&known));
profile::set(&mut card, "activity", known.status_text.as_ref().map(|t| json!({ "kind": "custom", "text": t })));
if known.bot {
profile::note(&mut card, "Account", "A bot");
}
}
if let Some(pronouns) = super::own::text(&user["pronouns"]) {
profile::note(&mut card, "Pronouns", pronouns);
}
let badges = badge_names(user["badges"].as_u64().unwrap_or(0));
if !badges.is_empty() {
profile::note(&mut card, "Badges", badges.join(", "));
}
if let Some(text) = user["status"]["text"].as_str() {
profile::note(&mut card, "Status", text);
}
}
if let Ok(bio) = &bio {
if let Some(about) = bio["content"].as_str() {
profile::note(&mut card, "About", about);
}
}
if let Ok(mutual) = &mutual {
let count = |key: &str| mutual[key].as_array().map(|l| l.len()).unwrap_or(0);
if count("servers") > 0 {
profile::note(&mut card, "Servers in common", count("servers").to_string());
}
if count("users") > 0 {
profile::note(&mut card, "Friends in common", count("users").to_string());
}
}
// When they joined the server this was asked in.
let server = state.runtime.get_buffer(buffer_id).and_then(|b| b.remote_id).and_then(|c| handle.world.lock().unwrap_or_else(|e| e.into_inner()).channels.get(&c).and_then(|ch| ch.server.clone()));
if let Some(server) = server {
if let Ok(member) = handle.api.get(&format!("/servers/{server}/members/{target}"), &[]).await {
if let Some(t) = member["joined_at"].as_str().and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok()) {
card["joinedTs"] = json!(t.timestamp());
}
let world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
if let Some(s) = world.servers.get(&server) {
let mut held: Vec<(i64, String)> = member["roles"].as_array().into_iter().flatten().filter_map(|r| r.as_str()).filter_map(|r| s.roles.get(r).map(|role| (role.rank, role.name.clone()))).collect();
held.sort();
if !held.is_empty() {
card["roles"] = json!(held.into_iter().map(|(_, n)| n).collect::<Vec<_>>());
}
}
}
}
card
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn badges_are_named_by_their_bits() {
assert_eq!(badge_names(0), Vec::<&str>::new());
assert_eq!(badge_names(1 | 16), vec!["Stoat developer", "Founder"]);
assert_eq!(badge_names(256), vec!["Early adopter"]);
assert_eq!(badge_names(1 << 20), Vec::<&str>::new(), "a bit nobody has named is not a badge");
}
}
-161
View File
@@ -1,161 +0,0 @@
//! Looking things up in a channel: searching its history, its pinned messages, and reading the part of it around a
//! message that is older than anything held.
//!
//! Stoat searches one channel at a time (there is no search across an account), with the database's own text index, so
//! the query is words and not a filter language. A hit is answered as a message and nothing is recorded; pressing one
//! reads the messages around it (`context`), the way a jump to anything old does.
use super::messages;
use super::Handle;
use crate::model::Buffer;
use crate::state::AppState;
use anyhow::{anyhow, Result};
use serde_json::{json, Value};
/// How many pinned messages are read: a channel with more has more than anybody is going to scroll.
const PINS: usize = 50;
fn channel_of(buffer: &Buffer) -> Result<&str> {
buffer.remote_id.as_deref().ok_or_else(|| anyhow!("this is not a Stoat conversation"))
}
/// The messages in an answer from Stoat (a bare list, or an object that also carries the people), with the people taken
/// into the world so a name can be said.
fn unpack(handle: &Handle, answer: &Value) -> Vec<Value> {
let (list, users) = match answer {
Value::Array(list) => (list.clone(), Vec::new()),
other => (other["messages"].as_array().cloned().unwrap_or_default(), other["users"].as_array().cloned().unwrap_or_default()),
};
let mut world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
for u in &users {
if let Some((id, user)) = super::world::parse_user(u) {
world.users.entry(id).or_insert(user);
}
}
list
}
/// One message as the window reads a row of results or pins.
pub fn row(handle: &Handle, buffer_id: &str, message: &Value) -> Option<Value> {
let id = message["_id"].as_str()?;
let world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
let author = message["author"].as_str().unwrap_or("");
let server = world.channels.get(message["channel"].as_str().unwrap_or("")).and_then(|c| c.server.clone());
let from = message["masquerade"]["name"]
.as_str()
.filter(|n| !n.is_empty())
.map(str::to_string)
.or_else(|| message["member"]["nickname"].as_str().filter(|n| !n.is_empty()).map(str::to_string))
.unwrap_or_else(|| world.name_of(author));
let body = match message.get("system").filter(|s| s.is_object()) {
Some(system) => messages::system_text(&world, system).unwrap_or_default(),
None => messages::words(&world, Some((&handle.instance, &handle.account_id)), server.as_deref(), message["content"].as_str().unwrap_or("")),
};
let own = author == world.own_id;
let body = if body.trim().is_empty() { "(no text)".to_string() } else { body };
Some(json!({
"id": id,
"bufferId": buffer_id,
"from": from,
"body": body,
"ts": messages::ulid_ms(id).map(|ms| ms / 1000).unwrap_or(0),
"kind": if message.get("system").is_some_and(|s| s.is_object()) { "system" } else { "chat" },
"isAction": false,
"isHighlight": false,
"edited": message["edited"].is_string(),
"isOwn": own,
}))
}
/// The matches for some words in one channel, newest first.
pub async fn run(handle: &Handle, buffer: &Buffer, query: &str, limit: usize) -> Result<Value> {
let channel = channel_of(buffer)?;
let answer = handle.api.post(&format!("/channels/{channel}/search"), &json!({ "query": query, "limit": limit.clamp(1, 100), "include_users": true })).await.map_err(|e| anyhow!("{e}"))?;
let results: Vec<Value> = unpack(handle, &answer).iter().filter_map(|m| row(handle, &buffer.id, m)).collect();
let count = results.len();
Ok(json!({ "results": results, "roomNames": { buffer.id.clone(): buffer.name.clone() }, "count": count }))
}
/// What a channel has pinned, newest first.
pub async fn pins(handle: &Handle, buffer: &Buffer) -> Result<Value> {
let channel = channel_of(buffer)?;
let answer = handle.api.post(&format!("/channels/{channel}/search"), &json!({ "pinned": true, "limit": PINS, "include_users": true })).await.map_err(|e| anyhow!("{e}"))?;
let pinned: Vec<Value> = unpack(handle, &answer).iter().filter_map(|m| row(handle, &buffer.id, m)).collect();
Ok(json!({ "bufferId": buffer.id, "pinned": pinned }))
}
/// Tells every window which messages a channel has pinned, after a pin was made or taken off. Not said when the list
/// is merely asked for: a window that is told the pins asks again if they differ from what it holds.
pub fn announce(state: &AppState, handle: &Handle, buffer: &Buffer) {
let (state, handle, buffer) = (state.clone(), handle.clone(), buffer.clone());
tokio::spawn(async move {
if let Ok(answer) = pins(&handle, &buffer).await {
let ids: Vec<String> = answer["pinned"].as_array().into_iter().flatten().filter_map(|m| m["id"].as_str().map(str::to_string)).collect();
state.events.emit("pinnedMessages", json!({ "bufferId": buffer.id, "pinned": ids }));
}
});
}
/// Pins a message, or takes the pin off.
pub async fn set_pinned(handle: &Handle, buffer: &Buffer, message: &str, pinned: bool) -> Result<()> {
let channel = channel_of(buffer)?;
let path = format!("/channels/{channel}/messages/{message}/pin");
if pinned {
handle.api.post(&path, &json!({})).await
} else {
handle.api.delete(&path).await
}
.map(|_| ())
.map_err(|e| anyhow!("{e}"))
}
/// Reads the part of a channel around one message and keeps it; answers when that message was sent.
pub async fn context(state: &AppState, handle: &Handle, buffer: &Buffer, message: &str) -> Result<i64> {
let channel = channel_of(buffer)?;
let answer = handle.api.get(&format!("/channels/{channel}/messages"), &[("nearby", message.to_string()), ("limit", messages::PAGE.to_string()), ("include_users", "true".to_string())]).await.map_err(|e| anyhow!("{e}"))?;
let mut list = unpack(handle, &answer);
list.sort_by(|a, b| a["_id"].as_str().cmp(&b["_id"].as_str()));
for m in &list {
messages::record(state, handle, m);
}
messages::ulid_ms(message).map(|ms| ms / 1000).ok_or_else(|| anyhow!("that is not a Stoat message"))
}
/// Reads forward from a message, towards the present; answers how many were new.
pub async fn newer(state: &AppState, handle: &Handle, buffer: &Buffer, message: &str) -> Result<usize> {
let channel = channel_of(buffer)?;
let answer = handle.api.get(&format!("/channels/{channel}/messages"), &[("after", message.to_string()), ("sort", "Oldest".to_string()), ("limit", messages::PAGE.to_string()), ("include_users", "true".to_string())]).await.map_err(|e| anyhow!("{e}"))?;
let mut list = unpack(handle, &answer);
list.sort_by(|a, b| a["_id"].as_str().cmp(&b["_id"].as_str()));
Ok(list.iter().filter(|m| messages::record(state, handle, m)).count())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_hit_is_a_row_with_its_author_named_and_its_time_from_its_id() {
let h = crate::backend::stoat::tests::bare_handle("stoat:ME");
{
let mut w = h.world.lock().unwrap();
w.own_id = "01ME000000000000000000000A".into();
w.users.insert("01PAL00000000000000000000A".into(), super::super::world::User { name: "Pal".into(), ..Default::default() });
}
let buffer = "stoat:ME|general";
let row = row(&h, buffer, &json!({ "_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV", "channel": "C", "author": "01PAL00000000000000000000A", "content": "hello there" })).unwrap();
assert_eq!((row["from"].as_str(), row["body"].as_str(), row["ts"].as_i64(), row["isOwn"].as_bool()), (Some("Pal"), Some("hello there"), Some(1_469_922_850), Some(false)));
let blank = super::row(&h, buffer, &json!({ "_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV", "author": "01ME000000000000000000000A", "content": "" })).unwrap();
assert_eq!((blank["body"].as_str(), blank["isOwn"].as_bool()), (Some("(no text)"), Some(true)));
assert!(super::row(&h, buffer, &json!({ "content": "no id" })).is_none());
}
#[test]
fn an_answer_may_be_a_bare_list_or_an_object_that_carries_the_people() {
let h = crate::backend::stoat::tests::bare_handle("stoat:ME");
assert_eq!(unpack(&h, &json!([{ "_id": "A" }])).len(), 1);
let list = unpack(&h, &json!({ "messages": [{ "_id": "A" }, { "_id": "B" }], "users": [{ "_id": "01NEW00000000000000000000A", "username": "newbie" }] }));
assert_eq!(list.len(), 2);
assert_eq!(h.world.lock().unwrap().name_of("01NEW00000000000000000000A"), "newbie");
}
}
-278
View File
@@ -1,278 +0,0 @@
//! Saying things: send, edit, delete, typing, and telling Stoat a conversation was read.
use super::messages;
use super::world::{Kind, SEND_MESSAGE};
use super::Handle;
use crate::model::Buffer;
use crate::state::AppState;
use serde_json::json;
fn handle_for(state: &AppState, buffer: &Buffer) -> Result<(Handle, String), String> {
let handle = state.runtime.stoat_handle(&buffer.account_id).ok_or_else(|| "that Stoat account is not connected".to_string())?;
let channel = buffer.remote_id.clone().ok_or_else(|| "this is not a Stoat conversation".to_string())?;
Ok((handle, channel))
}
/// How long a message may be for this account: a new one is held to a lower limit for its first hours, which the
/// instance says, and the account's own id says how old the account is.
pub fn message_limit(handle: &Handle, now_ms: i64) -> usize {
let own = handle.world.lock().unwrap_or_else(|e| e.into_inner()).own_id.clone();
let made = messages::ulid_ms(&own).unwrap_or(0);
let new = made > 0 && now_ms - made < (handle.instance.new_user_hours as i64) * 3_600_000;
if new {
handle.instance.new_message_length
} else {
handle.instance.message_length
}
}
/// What a message to send must be before it is sent: something to say, and no more than the limit.
pub fn check(body: &str, limit: usize) -> Result<(), String> {
if body.trim().is_empty() {
return Err("There is nothing to send".to_string());
}
let length = body.chars().count();
if length > limit {
return Err(format!("That message is too long: Stoat takes at most {limit} characters here, and this has {length}"));
}
Ok(())
}
/// Refuses a send while a slowmode Stoat told of is still running.
fn slowed(handle: &Handle, channel: &str) -> Result<(), String> {
let until = handle.world.lock().unwrap_or_else(|e| e.into_inner()).slow_until.get(channel).copied().unwrap_or(0);
let left = until - chrono::Utc::now().timestamp();
if left > 0 {
return Err(format!("This channel has slowmode on: wait {left} more seconds before sending again"));
}
Ok(())
}
/// What a refused send says, and, if it was a slowmode, when the next one may be made.
fn noted_slowmode(handle: &Handle, channel: &str, e: super::api::ApiError) -> String {
if let super::api::ApiError::Refused { kind, detail, .. } = &e {
if kind == "InSlowmode" {
let wait = detail["retry_after"].as_i64().unwrap_or(1).max(1);
handle.world.lock().unwrap_or_else(|e| e.into_inner()).slow_until.insert(channel.to_string(), chrono::Utc::now().timestamp() + wait);
}
}
words(e)
}
fn words(e: super::api::ApiError) -> String {
e.to_string()
}
/// What a file is called when it is sent: a spoiler is a name Stoat reads as one.
pub fn sent_name(path: &str, spoiler: bool) -> String {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "file".to_string());
if spoiler && !name.starts_with("SPOILER_") {
format!("SPOILER_{name}")
} else {
name
}
}
/// What a file's kind of content is, from its name, for the file server.
pub fn content_type_of(name: &str) -> &'static str {
match name.rsplit('.').next().unwrap_or("").to_ascii_lowercase().as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"mp4" => "video/mp4",
"webm" => "video/webm",
"mp3" => "audio/mpeg",
"ogg" | "opus" => "audio/ogg",
"wav" => "audio/wav",
"txt" => "text/plain",
"pdf" => "application/pdf",
_ => "application/octet-stream",
}
}
/// How many files a message may carry and how large each may be, for this account now.
pub fn file_limits(handle: &Handle, now_ms: i64) -> (usize, u64) {
let own = handle.world.lock().unwrap_or_else(|e| e.into_inner()).own_id.clone();
let made = messages::ulid_ms(&own).unwrap_or(0);
let new = made > 0 && now_ms - made < (handle.instance.new_user_hours as i64) * 3_600_000;
if new {
(handle.instance.new_message_attachments, handle.instance.new_attachment_bytes)
} else {
(handle.instance.message_attachments, handle.instance.attachment_bytes)
}
}
/// Sends files with a message: each is put on the file server and named in the message.
pub async fn send_files(state: &AppState, buffer: &Buffer, caption: &str, files: &[(String, bool)], reply_to: Option<&str>, progress: Option<&crate::upload::Progress>) -> Result<(), String> {
if files.is_empty() {
return send(state, buffer, caption, reply_to).await;
}
let (handle, channel) = handle_for(state, buffer)?;
let (most, biggest) = file_limits(&handle, chrono::Utc::now().timestamp_millis());
if files.len() > most {
return Err(format!("Stoat takes at most {most} files in a message here, and this has {}", files.len()));
}
if !caption.trim().is_empty() {
check(caption, message_limit(&handle, chrono::Utc::now().timestamp_millis()))?;
}
let server = handle.instance.autumn.clone().ok_or("this instance has no file server")?;
let total: u64 = files.iter().filter_map(|(p, _)| std::fs::metadata(p).ok().map(|m| m.len())).sum();
if let Some(p) = progress {
p.at(crate::upload::Phase::Preparing, total as usize, "Stoat");
}
let mut ids = Vec::new();
let mut done = 0u64;
for (path, spoiler) in files {
let size = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?.len();
if size > biggest {
return Err(format!("{} is {} MB and Stoat takes at most {} MB here", sent_name(path, false), size / 1_000_000, biggest / 1_000_000));
}
let bytes = tokio::fs::read(path).await.map_err(|e| format!("reading {path}: {e}"))?;
let name = sent_name(path, *spoiler);
if let Some(p) = progress {
p.sent(done, total, "Stoat");
}
let id = handle.api.upload(&server, "attachments", &name, content_type_of(&name), bytes).await.map_err(words)?;
done += size;
ids.push(id);
}
if let Some(p) = progress {
p.at(crate::upload::Phase::Waiting, total as usize, "Stoat");
}
let caption = handle.world.lock().unwrap_or_else(|e| e.into_inner()).pipe_outgoing(caption);
let mut payload = json!({ "content": caption, "attachments": ids, "nonce": format!("moho-{}", crate::model::next_message_id()) });
if let Some(reply) = reply_to.filter(|r| !r.is_empty()) {
payload["replies"] = json!([{ "id": reply, "mention": false }]);
}
let sent = handle.api.post(&format!("/channels/{channel}/messages"), &payload).await.map_err(words)?;
messages::record(state, &handle, &sent);
Ok(())
}
/// Sends a message, in answer to another if `reply_to` names one.
pub async fn send(state: &AppState, buffer: &Buffer, body: &str, reply_to: Option<&str>) -> Result<(), String> {
let (handle, channel) = handle_for(state, buffer)?;
{
let world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
if let Some(c) = world.channels.get(&channel) {
if world.permissions(c, chrono::Utc::now().timestamp()) & SEND_MESSAGE == 0 && c.kind != Kind::Saved {
return Err(world.read_only(c, chrono::Utc::now().timestamp()).unwrap_or(super::world::NO_SEND_PERMISSION).to_string());
}
}
}
check(body, message_limit(&handle, chrono::Utc::now().timestamp_millis()))?;
slowed(&handle, &channel)?;
let body = &handle.world.lock().unwrap_or_else(|e| e.into_inner()).pipe_outgoing(body);
let mut payload = json!({ "content": body, "nonce": format!("moho-{}", crate::model::next_message_id()) });
if let Some(reply) = reply_to.filter(|r| !r.is_empty()) {
payload["replies"] = json!([{ "id": reply, "mention": false }]);
}
let sent = handle.api.post(&format!("/channels/{channel}/messages"), &payload).await.map_err(|e| noted_slowmode(&handle, &channel, e))?;
// At once rather than when the event comes back: the echo finds it kept already.
messages::record(state, &handle, &sent);
Ok(())
}
pub async fn edit(state: &AppState, buffer: &Buffer, message_id: &str, body: &str) -> Result<(), String> {
let (handle, channel) = handle_for(state, buffer)?;
check(body, message_limit(&handle, chrono::Utc::now().timestamp_millis()))?;
let wire = handle.world.lock().unwrap_or_else(|e| e.into_inner()).pipe_outgoing(body);
handle.api.patch(&format!("/channels/{channel}/messages/{message_id}"), &json!({ "content": wire })).await.map_err(words)?;
let text = {
let world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
let server = world.channels.get(&channel).and_then(|c| c.server.clone());
messages::words(&world, Some((&handle.instance, &handle.account_id)), server.as_deref(), body)
};
state.runtime.update_message_body_only(state, &buffer.id, message_id, &text);
Ok(())
}
pub async fn delete(state: &AppState, buffer: &Buffer, message_id: &str) -> Result<(), String> {
let (handle, channel) = handle_for(state, buffer)?;
handle.api.delete(&format!("/channels/{channel}/messages/{message_id}")).await.map_err(words)?;
state.runtime.delete_message(state, &buffer.id, message_id);
Ok(())
}
/// Says the account is typing here.
pub fn typing(state: &AppState, buffer: &Buffer) -> Result<(), String> {
let (handle, channel) = handle_for(state, buffer)?;
handle.socket.send(json!({ "type": "BeginTyping", "channel": channel }).to_string()).map_err(|_| "the connection is not up".to_string())
}
/// Says everything in the conversation up to its newest message was read, so it is read on the account's other apps.
pub async fn mark_read(state: &AppState, buffer: &Buffer) -> Result<(), String> {
let (handle, channel) = handle_for(state, buffer)?;
let newest = state.store.newest_message_id(&buffer.id).ok().flatten().filter(|id| id.len() == 26);
state.runtime.set_buffer_unread(state, &buffer.id, 0, 0);
let Some(newest) = newest else { return Ok(()) };
handle.api.put(&format!("/channels/{channel}/ack/{newest}"), None).await.map_err(words)?;
Ok(())
}
/// A reaction put on a message, or taken off.
pub async fn react(state: &AppState, buffer: &Buffer, message_id: &str, emoji: &str, add: bool) -> Result<(), String> {
let (handle, channel) = handle_for(state, buffer)?;
// A custom emoji is named by its id on the wire, and by its name in what the window shows.
let wire = {
let world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
let name = messages::reaction_name(emoji);
world.emojis.iter().find(|(_, e)| e.name == name).map(|(id, _)| id.clone()).unwrap_or_else(|| emoji.to_string())
};
if add {
let already: Vec<String> = state.store.get_message(&buffer.id, message_id).ok().flatten().map(|m| m.reactions.iter().map(|r| r.emoji.clone()).collect()).unwrap_or_default();
super::limits::may_react(&handle.instance, &already, emoji)?;
}
let path = format!("/channels/{channel}/messages/{message_id}/reactions/{}", urlencode(&wire));
if add {
handle.api.put(&path, None).await.map_err(words)?;
} else {
handle.api.delete(&path).await.map_err(words)?;
}
state.runtime.update_reaction(state, &buffer.id, message_id, emoji, true, add);
Ok(())
}
fn urlencode(text: &str) -> String {
url::form_urlencoded::byte_serialize(text.as_bytes()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_message_needs_something_in_it_and_not_more_than_the_limit() {
assert!(check("hello", 2000).is_ok());
assert!(check(" ", 2000).unwrap_err().contains("nothing to send"));
assert!(check(&"a".repeat(2001), 2000).unwrap_err().contains("at most 2000"));
assert!(check(&"é".repeat(2000), 2000).is_ok(), "a limit of characters, not of bytes");
}
#[test]
fn a_file_is_sent_under_its_own_name_and_a_spoiler_under_the_name_stoat_reads_as_one() {
assert_eq!(sent_name("/home/me/cat.png", false), "cat.png");
assert_eq!(sent_name("/home/me/cat.png", true), "SPOILER_cat.png");
assert_eq!(sent_name("/home/me/SPOILER_cat.png", true), "SPOILER_cat.png", "not twice");
assert_eq!(content_type_of("a.JPG"), "image/jpeg");
assert_eq!(content_type_of("a.unknown"), "application/octet-stream");
}
#[test]
fn a_slowmode_stoat_told_of_is_kept_to_and_not_asked_about_again() {
let h = crate::backend::stoat::tests::bare_handle("stoat:ME");
assert!(slowed(&h, "C").is_ok());
let said = noted_slowmode(&h, "C", super::super::api::ApiError::Refused { status: 429, kind: "InSlowmode".into(), detail: json!({ "type": "InSlowmode", "retry_after": 20 }) });
assert!(said.contains("slowmode") && said.contains("20"), "{said}");
let wait = slowed(&h, "C").unwrap_err();
assert!(wait.contains("slowmode on: wait"), "{wait}");
assert!(slowed(&h, "other").is_ok(), "only that channel");
}
#[test]
fn an_emoji_is_written_in_an_address_the_way_the_server_reads_it() {
assert_eq!(urlencode("👍"), "%F0%9F%91%8D");
assert_eq!(urlencode("01EMOJ000000000000000000AA"), "01EMOJ000000000000000000AA");
}
}
-161
View File
@@ -1,161 +0,0 @@
//! What this account has silenced on Stoat itself.
//!
//! Stoat keeps an account's settings on the account (`/sync/settings`), so every app signed in to it hears the same.
//! The one this reads is `notifications`, which the official web app writes as a JSON string:
//!
//! ```text
//! { "server": { id: "all" | "mention" | "none" | "muted" }, "channel": { ... },
//! "server_mutes": { id: { "until": ms } }, "channel_mutes": { id: { } } }
//! ```
//!
//! A mute with no `until` lasts for ever; one with a time ends then (the app expects the client to look at the
//! clock). The older form said `"muted"` in the first two. Muting a conversation from here sets it on the account, so
//! it is quiet on a phone as well, and what is not understood in the settings is written back as it was found.
use super::messages;
use super::world::Kind;
use super::Handle;
use crate::model::Buffer;
use crate::state::AppState;
use anyhow::{anyhow, Result};
use serde_json::{json, Map, Value};
/// The settings' key for notifications.
const KEY: &str = "notifications";
/// Whether a server's or a channel's entry silences it now.
fn silenced_in(settings: &Value, level: &str, mutes: &str, id: &str, now_ms: i64) -> bool {
if settings[level][id].as_str() == Some("muted") {
return true;
}
match settings[mutes].get(id).filter(|m| m.is_object()) {
Some(mute) => mute["until"].as_i64().map(|until| until > now_ms).unwrap_or(true),
None => false,
}
}
/// Whether a conversation is silenced: itself, or the server holding it.
pub fn is_muted(settings: &Value, channel: &str, server: Option<&str>, now_ms: i64) -> bool {
silenced_in(settings, "channel", "channel_mutes", channel, now_ms) || server.is_some_and(|s| silenced_in(settings, "server", "server_mutes", s, now_ms))
}
/// The settings with one channel muted for ever, or let be heard again.
pub fn with_channel_muted(settings: &Value, channel: &str, muted: bool) -> Value {
let mut out = if settings.is_object() { settings.clone() } else { json!({}) };
let object = out.as_object_mut().expect("it was made an object");
let mutes = object.entry("channel_mutes").or_insert_with(|| json!({}));
if !mutes.is_object() {
*mutes = json!({});
}
let mutes = mutes.as_object_mut().expect("it was made an object");
if muted {
mutes.insert(channel.to_string(), json!({}));
} else {
mutes.remove(channel);
// The older form of the same thing.
if let Some(levels) = object.get_mut("channel").and_then(Value::as_object_mut) {
if levels.get(channel).and_then(Value::as_str) == Some("muted") {
levels.remove(channel);
}
}
}
out
}
/// The settings as Stoat sends them: a pair of when and a JSON string.
fn parse(entry: &Value) -> Option<Value> {
let text = entry.as_array().and_then(|a| a.get(1)).and_then(Value::as_str).or_else(|| entry.as_str())?;
serde_json::from_str::<Value>(text).ok().filter(Value::is_object)
}
/// Puts what is silenced onto the conversations this account has.
pub fn apply(state: &AppState, handle: &Handle) {
let now_ms = chrono::Utc::now().timestamp_millis();
let settings = handle.world.lock().unwrap_or_else(|e| e.into_inner()).notifications.clone();
let wanted: Vec<(String, bool)> = {
let world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
world.channels.values().filter(|c| c.kind != Kind::Voice || c.voice).map(|c| (c.id.clone(), is_muted(&settings, &c.id, c.server.as_deref(), now_ms))).collect()
};
for (channel, muted) in wanted {
if let Some(buffer) = messages::buffer_of(state, &handle.account_id, &channel) {
if buffer.server_muted != muted {
state.runtime.set_silenced(state, &buffer.id, muted);
}
}
}
}
/// Reads the account's notification settings and applies them.
pub async fn load(state: &AppState, handle: &Handle) {
let answer = match handle.api.post("/sync/settings/fetch", &json!({ "keys": [KEY] })).await {
Ok(a) => a,
Err(e) => {
tracing::debug!("stoat[{}]: the settings were not read: {e}", handle.account_id);
return;
}
};
if let Some(settings) = parse(&answer[KEY]) {
handle.world.lock().unwrap_or_else(|e| e.into_inner()).notifications = settings;
}
apply(state, handle);
}
/// A settings change another app made: `UserSettingsUpdate`.
pub fn changed(state: &AppState, handle: &Handle, update: &Value) {
if let Some(settings) = parse(&update[KEY]) {
handle.world.lock().unwrap_or_else(|e| e.into_inner()).notifications = settings;
apply(state, handle);
}
}
/// Mutes a conversation on the account, or lets it be heard again.
pub async fn set_muted(state: &AppState, handle: &Handle, buffer: &Buffer, muted: bool) -> Result<()> {
let channel = buffer.remote_id.as_deref().ok_or_else(|| anyhow!("this is not a Stoat conversation"))?;
// Read again just before, so a change made on a phone a moment ago is not written over.
load(state, handle).await;
let next = with_channel_muted(&handle.world.lock().unwrap_or_else(|e| e.into_inner()).notifications, channel, muted);
let text = serde_json::to_string(&next)?;
let mut body = Map::new();
body.insert(KEY.to_string(), Value::String(text));
handle.api.post(&format!("/sync/settings/set?timestamp={}", chrono::Utc::now().timestamp_millis()), &Value::Object(body)).await.map_err(|e| anyhow!("{e}"))?;
handle.world.lock().unwrap_or_else(|e| e.into_inner()).notifications = next;
apply(state, handle);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const NOW: i64 = 1_800_000_000_000;
#[test]
fn a_channel_or_its_server_can_silence_it_and_a_timed_mute_ends() {
let s = json!({ "channel_mutes": { "C1": {}, "C2": { "until": NOW + 1000 }, "C3": { "until": NOW - 1000 } }, "server_mutes": { "S1": {} }, "channel": { "C4": "muted" }, "server": { "S2": "mention" } });
assert!(is_muted(&s, "C1", None, NOW));
assert!(is_muted(&s, "C2", None, NOW), "still running");
assert!(!is_muted(&s, "C3", None, NOW), "ran out");
assert!(is_muted(&s, "C4", None, NOW), "the older form");
assert!(is_muted(&s, "C9", Some("S1"), NOW), "everything in a muted server");
assert!(!is_muted(&s, "C9", Some("S2"), NOW), "mentions only is not a mute");
assert!(!is_muted(&json!({}), "C1", Some("S1"), NOW));
}
#[test]
fn muting_one_channel_leaves_the_rest_of_the_settings_as_they_were() {
let s = json!({ "server": { "S": "mention" }, "channel_mutes": { "A": {} }, "channel": { "B": "muted", "Z": "all" }, "later": 1 });
let on = with_channel_muted(&s, "C", true);
assert_eq!(on["channel_mutes"], json!({ "A": {}, "C": {} }));
assert_eq!(on["later"], 1);
let off = with_channel_muted(&on, "B", false);
assert_eq!(off["channel"], json!({ "Z": "all" }), "the older form is let go too");
assert_eq!(with_channel_muted(&Value::Null, "C", true), json!({ "channel_mutes": { "C": {} } }));
}
#[test]
fn the_settings_come_as_a_time_and_a_string() {
assert_eq!(parse(&json!([1700, "{\"channel_mutes\":{\"A\":{}}}"])).unwrap()["channel_mutes"]["A"], json!({}));
assert!(parse(&json!([1700, "not json"])).is_none());
assert!(parse(&Value::Null).is_none());
}
}
-222
View File
@@ -1,222 +0,0 @@
//! The whole of the connection against a made-up Stoat: an HTTP side that answers the root, `/users/@me` and the
//! message routes, and an events side that authenticates a token, says `Ready` and then speaks. What is asserted is
//! what lands in a daemon: the conversations, how they are filed, what may be written in them, a message, a send.
#![allow(clippy::result_large_err)]
use super::*;
use crate::backend::kick::testkit;
use serde_json::{json, Value};
use std::sync::Mutex;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio_tungstenite::tungstenite::Message as Ws;
/// A handle with nothing behind it, for what only reads and keeps what it is told.
pub(super) fn bare_handle(account_id: &str) -> Handle {
let (socket, _) = tokio::sync::mpsc::unbounded_channel();
Handle {
account_id: account_id.to_string(),
api: Arc::new(api::Api::new(account_id, "http://127.0.0.1:9", Some("token".into()))),
world: Arc::new(Mutex::new(world::World::default())),
instance: Arc::new(world::Instance::default()),
socket,
}
}
fn id(name: &str) -> String {
format!("01H{name:0<23}")
}
/// What the made-up Stoat was told.
#[derive(Default)]
struct Heard {
/// Every request that was made: method, path and body.
requests: Vec<(String, String, String)>,
/// What the events side was sent.
socket: Vec<String>,
/// The address the events side was asked at.
socket_path: String,
}
async fn fake_http(heard: Arc<Mutex<Heard>>, ws_url: String, me: String) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move {
loop {
let Ok((mut tcp, _)) = listener.accept().await else { return };
let (heard, ws_url, me) = (heard.clone(), ws_url.clone(), me.clone());
tokio::spawn(async move {
let mut raw = Vec::new();
let mut buf = [0u8; 4096];
// Until the headers and as much body as they promise have come.
loop {
let n = tcp.read(&mut buf).await.unwrap_or(0);
if n == 0 {
break;
}
raw.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&raw).to_string();
if let Some(split) = text.find("\r\n\r\n") {
let length = text[..split].lines().find_map(|l| l.to_lowercase().strip_prefix("content-length:").and_then(|v| v.trim().parse::<usize>().ok())).unwrap_or(0);
if raw.len() >= split + 4 + length {
break;
}
}
}
let text = String::from_utf8_lossy(&raw).to_string();
let head = text.lines().next().unwrap_or("").to_string();
let mut parts = head.split(' ');
let (method, path) = (parts.next().unwrap_or("").to_string(), parts.next().unwrap_or("").to_string());
let body = text.split("\r\n\r\n").nth(1).unwrap_or("").to_string();
heard.lock().unwrap().requests.push((method.clone(), path.clone(), body.clone()));
let answer = match (method.as_str(), path.split('?').next().unwrap_or("")) {
("GET", "/") => json!({ "stoat": "0.16.2", "revolt": "0.16.2", "ws": ws_url, "features": { "autumn": { "enabled": true, "url": "https://cdn.example" }, "january": { "enabled": true, "url": "https://proxy.example" }, "limits": { "global": { "new_user_hours": 72 }, "default": { "message_length": 2000 }, "new_user": { "message_length": 2000 } } } }),
("GET", "/users/@me") => json!({ "_id": me, "username": "me", "discriminator": "0001", "display_name": "Me" }),
("POST", p) if p.ends_with("/messages") => {
let sent: Value = serde_json::from_str(&body).unwrap_or(Value::Null);
json!({ "_id": id("SENT1"), "channel": id("GEN"), "author": me, "content": sent["content"], "nonce": sent["nonce"] })
}
("PUT", _) | ("DELETE", _) | ("PATCH", _) => Value::Null,
_ => json!({ "type": "NotFound" }),
};
let status = if answer["type"] == "NotFound" { "404 Not Found" } else { "200 OK" };
let payload = if answer.is_null() { String::new() } else { answer.to_string() };
let response = format!("HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{payload}", payload.len());
let _ = tcp.write_all(response.as_bytes()).await;
let _ = tcp.shutdown().await;
});
}
});
base
}
/// The events side: wants the token in an `Authenticate`, then says `Authenticated`, `Ready` and a message.
async fn fake_events(heard: Arc<Mutex<Heard>>, token: &'static str, ready: Value, then: Vec<Value>) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("ws://{}", listener.local_addr().unwrap());
tokio::spawn(async move {
let (tcp, _) = listener.accept().await.unwrap();
let seen = heard.clone();
let mut ws = tokio_tungstenite::accept_hdr_async(tcp, move |request: &tokio_tungstenite::tungstenite::handshake::server::Request, response| {
seen.lock().unwrap().socket_path = request.uri().to_string();
Ok(response)
})
.await
.unwrap();
while let Some(Ok(frame)) = ws.next().await {
let Ws::Text(text) = frame else { continue };
heard.lock().unwrap().socket.push(text.to_string());
let v: Value = serde_json::from_str(&text).unwrap_or(Value::Null);
if v["type"] == "Authenticate" {
if v["token"] != token {
let _ = ws.send(Ws::text(json!({ "type": "Error", "error": "InvalidSession" }).to_string())).await;
continue;
}
let _ = ws.send(Ws::text(json!({ "type": "Authenticated" }).to_string())).await;
let _ = ws.send(Ws::text(ready.to_string())).await;
for event in &then {
let _ = ws.send(Ws::text(event.to_string())).await;
}
}
}
});
url
}
use futures::{SinkExt, StreamExt};
fn ready_event(me: &str) -> Value {
let (pal, srv, cat) = (id("PAL"), id("SRV"), id("CAT"));
json!({
"type": "Ready",
"users": [
{ "_id": me, "username": "me", "display_name": "Me", "relationship": "User" },
{ "_id": pal, "username": "pal", "display_name": "Pal", "relationship": "Friend" }
],
"servers": [{
"_id": srv, "owner": pal, "name": "Chasm", "default_permissions": world::VIEW_ONLY | world::SEND_MESSAGE,
"channels": [id("GEN"), id("RULES"), id("VOICE")],
"categories": [{ "id": cat, "title": "Lobby", "channels": [id("GEN"), id("RULES")] }],
"roles": {}
}],
"channels": [
{ "_id": id("GEN"), "channel_type": "TextChannel", "server": srv, "name": "general" },
{ "_id": id("RULES"), "channel_type": "TextChannel", "server": srv, "name": "rules", "default_permissions": { "a": 0, "d": world::SEND_MESSAGE } },
{ "_id": id("VOICE"), "channel_type": "VoiceChannel", "server": srv, "name": "hangout" },
{ "_id": id("DM"), "channel_type": "DirectMessage", "recipients": [me, pal], "active": true },
{ "_id": id("NOTES"), "channel_type": "SavedMessages", "user": me }
],
"members": [{ "_id": { "server": srv, "user": me }, "roles": [] }],
"emojis": [],
"channel_unreads": [{ "_id": { "channel": id("GEN"), "user": me }, "last_id": id("OLD"), "mentions": [] }]
})
}
async fn until(what: &str, mut ready: impl FnMut() -> bool) {
for _ in 0..200 {
if ready() {
return;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
panic!("never saw {what}");
}
#[tokio::test]
async fn an_account_connects_makes_its_conversations_hears_a_message_and_sends_one() {
let me = id("ME");
let heard = Arc::new(Mutex::new(Heard::default()));
let hello = json!({ "type": "Message", "_id": id("MSG1"), "channel": id("GEN"), "author": id("PAL"), "content": format!("hello <@{}> !!twist!!", me), "mentions": [me.clone()] });
let ws_url = fake_events(heard.clone(), "secret-token", ready_event(&me), vec![hello]).await;
let base = fake_http(heard.clone(), ws_url, me.clone()).await;
let state = testkit::simulated_daemon("stoat-connect");
let account = format!("stoat:{me}");
let config = crate::accounts::StoatAccountConfig { instance: base, user_id: me.clone(), token: "secret-token".into(), ..Default::default() };
state.accounts.add_stoat(config.clone()).unwrap();
spawn(state.clone(), config);
until("the account connected", || state.runtime.list_accounts(&state).iter().any(|a| a.id == account && a.state == "connected")).await;
until("a message in the general channel", || state.store.get_backlog(&crate::model::buffer_id(&account, "Chasm/#general"), 0, 10).map(|l| !l.is_empty()).unwrap_or(false)).await;
// The conversations, and where each is filed.
let buffers = state.runtime.list_buffers();
let named = |name: &str| buffers.iter().find(|b| b.account_id == account && b.name == name).cloned();
let general = named("Chasm/#general").expect("the general channel");
assert_eq!((general.kind.as_str(), general.category.as_deref(), general.remote_id.as_deref()), ("channel", Some("Lobby"), Some(id("GEN").as_str())));
assert_eq!(general.group_id.as_deref(), Some(events::rail_id(&account, &id("SRV")).as_str()), "under its server's entry on the rail");
assert_eq!(general.read_only, None);
assert_eq!(named("Chasm/#rules").unwrap().read_only.as_deref(), Some(world::NO_SEND_PERMISSION), "a channel it may not write in says so");
assert!(named("Chasm/#hangout").is_none(), "a voice channel is not a conversation");
let dm = named("Pal").expect("the direct message");
assert_eq!((dm.kind.as_str(), dm.category.as_deref()), ("dm", Some("Direct Messages")));
assert!(named("Saved Notes").is_some());
assert!(state.runtime.list_buffer_groups().iter().any(|g| g.name == "Chasm" && g.service == "stoat"));
// The message that was heard: its mention said by name, its spoiler in the window's marks, from who sent it.
let log = state.store.get_backlog(&general.id, 0, 10).unwrap();
assert_eq!((log[0].from.as_str(), log[0].body.as_str()), ("Pal", "hello @Me ||twist||"));
assert!(log[0].is_highlight, "it mentions the account");
// Said: through the connection that is up, answered by Stoat, and kept once.
send::send(&state, &general, "hi there", None).await.unwrap();
let log = state.store.get_backlog(&general.id, 0, 10).unwrap();
assert_eq!(log.iter().filter(|m| m.body == "hi there").count(), 1);
{
let heard = heard.lock().unwrap();
let post = heard.requests.iter().find(|(m, p, _)| m == "POST" && p.ends_with("/messages")).expect("a message was posted");
assert!(post.1.contains(&id("GEN")));
let body: Value = serde_json::from_str(&post.2).unwrap();
assert_eq!(body["content"], "hi there");
assert!(body["nonce"].as_str().is_some_and(|n| n.starts_with("moho-")));
// The token went in a message, and is in no address.
assert!(heard.socket[0].contains("secret-token") && heard.socket[0].contains("Authenticate"));
assert!(heard.requests.iter().all(|(_, p, _)| !p.contains("secret-token")));
assert!(heard.socket_path.starts_with("/?version=1&format=json") && !heard.socket_path.contains("secret-token"), "{}", heard.socket_path);
}
// And refused before it is sent where it may not be.
let rules = named("Chasm/#rules").unwrap();
assert!(send::send(&state, &rules, "no", None).await.unwrap_err().contains("permission"));
}
-51
View File
@@ -1,51 +0,0 @@
//! Calls: asking Stoat for a way into a channel's call.
//!
//! Stoat's calls are held on a LiveKit media server, like Element's. The daemon asks for the token (`join_call`) - the
//! one thing that needs the session - and the window, which is a LiveKit client already, joins with it and sends the
//! microphone, the camera and a shared screen. Who is in each channel's call comes as events (see `events`), so a
//! channel can say who is talking before anybody joins.
use super::Handle;
use crate::model::Buffer;
use anyhow::{anyhow, Result};
use serde_json::{json, Value};
/// What the window needs to join: the media server's address and the account's token for it.
pub async fn join(handle: &Handle, buffer: &Buffer) -> Result<Value> {
let channel = buffer.remote_id.as_deref().ok_or_else(|| anyhow!("this is not a Stoat conversation"))?;
// The instance says which media servers it has, and asks for one by name. A call this account is still in somewhere
// else (another device, or a window that went away without leaving) is let go of: joining here is the newer wish.
let node = handle.instance.voice_node.clone().ok_or_else(|| anyhow!("this instance has no voice"))?;
// Beginning a call in a direct message or a group rings the others (on their phones, where the app is asleep); joining
// one already going rings nobody.
let recipients: Vec<String> = {
let world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
match world.channels.get(channel) {
Some(c) if matches!(c.kind, super::world::Kind::Direct | super::world::Kind::Group) && !world.voice.contains_key(channel) => c.recipients.iter().filter(|r| **r != world.own_id).cloned().collect(),
_ => Vec::new(),
}
};
let mut body = json!({ "node": node, "force_disconnect": true });
if !recipients.is_empty() {
body["recipients"] = json!(recipients);
}
let answer = handle.api.post(&format!("/channels/{channel}/join_call"), &body).await.map_err(|e| anyhow!("{e}"))?;
let (token, url) = (answer["token"].as_str(), answer["url"].as_str());
match (token, url) {
(Some(token), Some(url)) => Ok(json!({ "token": token, "url": url, "note": super::limits::call_note(handle) })),
_ => Err(anyhow!("Stoat did not give a way into the call")),
}
}
/// Turns down a ring: Stoat stops ringing this account for the call. It is no error if it had already stopped.
pub async fn decline(state: &crate::state::AppState, handle: &Handle, buffer: &Buffer) -> Result<()> {
let channel = buffer.remote_id.as_deref().ok_or_else(|| anyhow!("this is not a Stoat conversation"))?;
let own = {
let mut world = handle.world.lock().unwrap_or_else(|e| e.into_inner());
world.ringing.remove(channel);
world.own_id.clone()
};
state.events.emit("incomingCall", json!({ "accountId": handle.account_id, "bufferId": buffer.id, "channelId": channel, "ringing": false }));
let _ = handle.api.put(&format!("/channels/{channel}/end_ring/{own}"), None).await;
Ok(())
}
-736
View File
@@ -1,736 +0,0 @@
//! What the account can see: the people, servers, channels, roles and emoji Stoat has told it of, and what each channel
//! lets it do.
//!
//! Kept as plain data read out of the JSON Stoat sends (`Ready` and the events after it), so that the question that
//! matters most - may this account write here? - is a function of data, written down once and tested. It is Stoat's
//! own calculation (its backend's `permissions` crate): the server's default, then the channel's default, then the
//! account's roles from the lowest-ranked up, first in the server and then in the channel; an account that is timed out
//! may only look; one that cannot see a channel has nothing there.
use serde_json::Value;
use std::collections::HashMap;
// Permission bits, as the developer documentation gives them.
pub const MANAGE_CHANNEL: u64 = 1 << 0;
pub const VIEW_CHANNEL: u64 = 1 << 20;
pub const READ_MESSAGE_HISTORY: u64 = 1 << 21;
pub const SEND_MESSAGE: u64 = 1 << 22;
pub const KICK_MEMBERS: u64 = 1 << 6;
pub const BAN_MEMBERS: u64 = 1 << 7;
pub const TIMEOUT_MEMBERS: u64 = 1 << 8;
pub const ASSIGN_ROLES: u64 = 1 << 9;
pub const MANAGE_MESSAGES: u64 = 1 << 23;
pub const INVITE_OTHERS: u64 = 1 << 25;
pub const SEND_EMBEDS: u64 = 1 << 26;
pub const UPLOAD_FILES: u64 = 1 << 27;
pub const MASQUERADE: u64 = 1 << 28;
pub const REACT: u64 = 1 << 29;
pub const CONNECT: u64 = 1 << 30;
pub const SPEAK: u64 = 1 << 31;
pub const VIDEO: u64 = 1 << 32;
pub const LISTEN: u64 = 1 << 36;
/// Every permission there is, safely: what an owner has.
pub const GRANT_ALL_SAFE: u64 = 0x000F_FFFF_FFFF_FFFF;
/// What a member held in a timeout may still do.
pub const ALLOWED_IN_TIMEOUT: u64 = VIEW_CHANNEL | READ_MESSAGE_HISTORY;
pub const VIEW_ONLY: u64 = VIEW_CHANNEL | READ_MESSAGE_HISTORY;
const DEFAULT_PERMISSION: u64 = VIEW_ONLY | SEND_MESSAGE | INVITE_OTHERS | SEND_EMBEDS | UPLOAD_FILES | CONNECT | SPEAK | LISTEN | VIDEO;
/// What a direct message or a group gives by default.
pub const DIRECT_MESSAGE: u64 = DEFAULT_PERMISSION | MANAGE_CHANNEL | REACT | MASQUERADE;
/// Said in place of the message box where the account may not write.
pub const NO_SEND_PERMISSION: &str = "You do not have permission to send messages in this channel.";
pub const BLOCKED_DM: &str = "You cannot message this person: one of you has blocked the other.";
pub const NOT_FRIENDS: &str = "Stoat only lets you message people who are your friends, or bots. Add them as a friend first.";
pub const TIMED_OUT: &str = "You are timed out in this server.";
/// What an account of one standing may do, as the instance states it.
#[derive(Debug, Clone, Default)]
pub struct UserLimits {
pub message_length: usize,
pub message_attachments: usize,
pub outgoing_friend_requests: usize,
pub servers: usize,
/// The audio rate calls are held to, in hertz.
pub voice_quality: u64,
pub video: bool,
/// The most each kind of file may weigh, in bytes.
pub attachment_bytes: u64,
pub avatar_bytes: u64,
pub icon_bytes: u64,
pub banner_bytes: u64,
pub emoji_bytes: u64,
}
impl UserLimits {
fn from_json(v: &Value) -> Self {
let n = |x: &Value, d: usize| x.as_u64().map(|n| n as usize).unwrap_or(d);
let bytes = |key: &str, d: u64| v["file_upload_size_limits"][key].as_u64().unwrap_or(d);
Self {
message_length: n(&v["message_length"], 2000),
message_attachments: n(&v["message_attachments"], 5),
outgoing_friend_requests: n(&v["outgoing_friend_requests"], 10),
servers: n(&v["servers"], 100),
voice_quality: v["voice_quality"].as_u64().unwrap_or(16_000),
video: v["video"].as_bool().unwrap_or(true),
attachment_bytes: bytes("attachments", 20_000_000),
avatar_bytes: bytes("avatars", 4_000_000),
icon_bytes: bytes("icons", 2_500_000),
banner_bytes: bytes("banners", 6_000_000),
emoji_bytes: bytes("emojis", 500_000),
}
}
}
/// What the instance said of itself at its root.
#[derive(Debug, Clone, Default)]
pub struct Instance {
pub ws: String,
/// The media server to join calls on: the first the instance lists (the official one lists one; a self-hosted one,
/// "worldwide").
pub voice_node: Option<String>,
/// The instance's own web app, where an invite link lives.
pub app: Option<String>,
/// The file server (Autumn).
pub autumn: Option<String>,
/// The link proxy (January).
pub january: Option<String>,
/// Longest message, in characters, for an account of ordinary standing.
pub message_length: usize,
pub message_attachments: usize,
/// The most a file attached to a message may weigh, in bytes.
pub attachment_bytes: u64,
/// For a new account, until `new_user_hours` have passed.
pub new_message_length: usize,
pub new_message_attachments: usize,
pub new_attachment_bytes: u64,
pub new_user_hours: u64,
/// The same, whole, for an account of ordinary standing and for a new one.
pub standing: UserLimits,
pub fresh: UserLimits,
/// What holds for everybody: how many roles and channels a server may have, how many different reactions a
/// message.
pub server_roles: usize,
pub server_channels: usize,
pub message_reactions: usize,
}
impl Instance {
pub fn from_root(root: &Value) -> Self {
let url = |v: &Value| v["enabled"].as_bool().unwrap_or(false).then(|| v["url"].as_str().map(|u| u.trim_end_matches('/').to_string())).flatten();
let limits = &root["features"]["limits"];
let number = |v: &Value, d: usize| v.as_u64().map(|n| n as usize).unwrap_or(d);
let (standing, fresh) = (UserLimits::from_json(&limits["default"]), UserLimits::from_json(&limits["new_user"]));
Self {
ws: root["ws"].as_str().unwrap_or("").to_string(),
voice_node: root["features"]["livekit"]["nodes"][0]["name"].as_str().map(str::to_string),
app: root["app"].as_str().filter(|a| !a.is_empty()).map(|a| a.trim_end_matches('/').to_string()),
autumn: url(&root["features"]["autumn"]),
january: url(&root["features"]["january"]),
message_length: standing.message_length,
message_attachments: standing.message_attachments,
attachment_bytes: standing.attachment_bytes,
new_message_attachments: fresh.message_attachments,
new_attachment_bytes: fresh.attachment_bytes,
new_message_length: fresh.message_length,
new_user_hours: limits["global"]["new_user_hours"].as_u64().unwrap_or(72),
server_roles: number(&limits["global"]["server_roles"], 200),
server_channels: number(&limits["global"]["server_channels"], 200),
message_reactions: number(&limits["global"]["message_reactions"], 20),
standing,
fresh,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct User {
pub name: String,
/// The avatar's file id.
pub avatar: Option<String>,
pub bot: bool,
/// What they chose to be shown as: `Online`, `Idle`, `Focus`, `Busy`, `Invisible`.
pub presence: String,
/// The words they put beside it.
pub status_text: Option<String>,
/// What this account is to them: `Friend`, `Blocked`, `BlockedOther`, `Incoming`, `Outgoing`, `User`, `None`.
pub relationship: String,
pub online: bool,
}
#[derive(Debug, Clone, Default)]
pub struct Role {
pub name: String,
pub rank: i64,
pub allow: u64,
pub deny: u64,
pub colour: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct Category {
pub title: String,
pub channels: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct Server {
pub id: String,
pub name: String,
pub owner: String,
pub roles: HashMap<String, Role>,
pub default_permissions: u64,
pub categories: Vec<Category>,
pub channels: Vec<String>,
pub icon: Option<String>,
}
/// This account's own membership of a server.
#[derive(Debug, Clone, Default)]
pub struct Member {
pub roles: Vec<String>,
/// Until when it may not speak, Unix seconds.
pub timeout: Option<i64>,
pub nickname: Option<String>,
pub can_publish: bool,
pub can_receive: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Text,
Voice,
Direct,
Group,
Saved,
}
#[derive(Debug, Clone)]
pub struct Channel {
pub id: String,
pub kind: Kind,
pub name: String,
pub server: Option<String>,
pub description: Option<String>,
/// A text channel that can also be joined for a call: Stoat's voice channels are text channels with a `voice` part.
pub voice: bool,
pub recipients: Vec<String>,
pub owner: Option<String>,
/// A server channel's default override, and its overrides by role.
pub default: (u64, u64),
pub roles: HashMap<String, (u64, u64)>,
/// A group's own permissions.
pub group_permissions: Option<u64>,
pub icon: Option<String>,
pub last_message_id: Option<String>,
}
impl Default for Channel {
fn default() -> Self {
Self {
id: String::new(),
kind: Kind::Text,
name: String::new(),
server: None,
description: None,
voice: false,
recipients: Vec::new(),
owner: None,
default: (0, 0),
roles: HashMap::new(),
group_permissions: None,
icon: None,
last_message_id: None,
}
}
}
/// A custom emoji: what it is called, the server it belongs to (none for one that stands alone), and whether it moves.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct EmojiInfo {
pub name: String,
pub server: Option<String>,
pub animated: bool,
}
pub fn parse_emoji(v: &Value) -> Option<(String, EmojiInfo)> {
Some((
v["_id"].as_str()?.to_string(),
EmojiInfo {
name: v["name"].as_str()?.to_string(),
server: (v["parent"]["type"].as_str() == Some("Server")).then(|| v["parent"]["id"].as_str().map(str::to_string)).flatten(),
animated: v["animated"].as_bool().unwrap_or(false),
},
))
}
/// Somebody in a voice channel, and what they are sending.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct VoiceMember {
pub user: String,
pub camera: bool,
pub screen: bool,
/// Whether they are sending sound: not muted.
pub publishing: bool,
}
pub fn parse_voice_member(v: &Value) -> Option<VoiceMember> {
Some(VoiceMember {
user: v["id"].as_str()?.to_string(),
camera: v["camera"].as_bool().unwrap_or(false),
screen: v["screensharing"].as_bool().unwrap_or(false),
publishing: v["is_publishing"].as_bool().unwrap_or(true),
})
}
#[derive(Debug, Default)]
pub struct World {
/// Who is in each voice channel, by channel id.
pub voice: HashMap<String, Vec<VoiceMember>>,
/// Until when (Unix seconds) a channel's slowmode keeps this account from sending, as Stoat last said.
pub slow_until: HashMap<String, i64>,
/// The direct messages and groups a call has begun in without this account: ringing, until it joins or the call ends.
pub ringing: std::collections::HashSet<String>,
/// The account's notification settings, as Stoat keeps them (see `settings`).
pub notifications: Value,
pub own_id: String,
pub users: HashMap<String, User>,
pub servers: HashMap<String, Server>,
pub channels: HashMap<String, Channel>,
/// Custom emoji by id.
pub emojis: HashMap<String, EmojiInfo>,
/// This account's membership of each server, by server id.
pub members: HashMap<String, Member>,
}
fn file_id(v: &Value) -> Option<String> {
v["_id"].as_str().map(str::to_string)
}
fn pair(v: &Value) -> (u64, u64) {
(v["a"].as_u64().unwrap_or(0), v["d"].as_u64().unwrap_or(0))
}
fn when(v: &Value) -> Option<i64> {
v.as_str().and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok()).map(|t| t.timestamp())
}
pub fn parse_user(v: &Value) -> Option<(String, User)> {
let id = v["_id"].as_str()?.to_string();
let username = v["username"].as_str().unwrap_or("").to_string();
let name = v["display_name"].as_str().filter(|n| !n.is_empty()).map(str::to_string).unwrap_or_else(|| username.clone());
Some((
id,
User {
name,
avatar: file_id(&v["avatar"]),
bot: v.get("bot").is_some_and(|b| b.is_object()),
presence: v["status"]["presence"].as_str().unwrap_or("Online").to_string(),
status_text: v["status"]["text"].as_str().filter(|t| !t.is_empty()).map(str::to_string),
relationship: v["relationship"].as_str().unwrap_or("None").to_string(),
online: v["online"].as_bool().unwrap_or(false),
},
))
}
pub fn parse_server(v: &Value) -> Option<Server> {
let id = v["_id"].as_str()?.to_string();
let roles = v["roles"]
.as_object()
.map(|o| {
o.iter()
.map(|(rid, r)| {
let (allow, deny) = pair(&r["permissions"]);
(rid.clone(), Role { name: r["name"].as_str().unwrap_or("role").to_string(), rank: r["rank"].as_i64().unwrap_or(0), allow, deny, colour: r["colour"].as_str().map(str::to_string) })
})
.collect()
})
.unwrap_or_default();
let categories = v["categories"]
.as_array()
.map(|a| {
a.iter()
.map(|c| Category { title: c["title"].as_str().unwrap_or("").to_string(), channels: c["channels"].as_array().map(|l| l.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default() })
.collect()
})
.unwrap_or_default();
Some(Server {
id,
name: v["name"].as_str().unwrap_or("server").to_string(),
owner: v["owner"].as_str().unwrap_or("").to_string(),
roles,
default_permissions: v["default_permissions"].as_u64().unwrap_or(0),
categories,
channels: v["channels"].as_array().map(|l| l.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default(),
icon: file_id(&v["icon"]),
})
}
pub fn parse_channel(v: &Value) -> Option<Channel> {
let id = v["_id"].as_str()?.to_string();
let kind = match v["channel_type"].as_str()? {
"TextChannel" => Kind::Text,
"VoiceChannel" => Kind::Voice,
"DirectMessage" => Kind::Direct,
"Group" => Kind::Group,
"SavedMessages" => Kind::Saved,
_ => return None,
};
Some(Channel {
id,
kind,
name: v["name"].as_str().unwrap_or("").to_string(),
server: v["server"].as_str().map(str::to_string),
description: v["description"].as_str().filter(|d| !d.is_empty()).map(str::to_string),
voice: v.get("voice").is_some_and(|x| x.is_object()),
recipients: v["recipients"].as_array().map(|l| l.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default(),
owner: v["owner"].as_str().map(str::to_string),
default: pair(&v["default_permissions"]),
roles: v["role_permissions"].as_object().map(|o| o.iter().map(|(k, p)| (k.clone(), pair(p))).collect()).unwrap_or_default(),
group_permissions: v["permissions"].as_u64(),
icon: file_id(&v["icon"]),
last_message_id: v["last_message_id"].as_str().map(str::to_string),
})
}
pub fn parse_member(v: &Value) -> Option<(String, String, Member)> {
let server = v["_id"]["server"].as_str()?.to_string();
let user = v["_id"]["user"].as_str()?.to_string();
Some((
server,
user,
Member {
roles: v["roles"].as_array().map(|l| l.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default(),
timeout: when(&v["timeout"]),
nickname: v["nickname"].as_str().filter(|n| !n.is_empty()).map(str::to_string),
can_publish: v["can_publish"].as_bool().unwrap_or(true),
can_receive: v["can_receive"].as_bool().unwrap_or(true),
},
))
}
impl World {
/// What is written in the box, as Stoat reads it (see `messages::outgoing`).
pub fn pipe_outgoing(&self, text: &str) -> String {
super::messages::outgoing(self, text)
}
/// Takes in what `Ready` carries.
pub fn absorb_ready(&mut self, own_id: &str, ready: &Value) {
self.own_id = own_id.to_string();
for u in ready["users"].as_array().into_iter().flatten() {
if let Some((id, user)) = parse_user(u) {
self.users.insert(id, user);
}
}
self.servers.clear();
for s in ready["servers"].as_array().into_iter().flatten() {
if let Some(server) = parse_server(s) {
self.servers.insert(server.id.clone(), server);
}
}
self.channels.clear();
for c in ready["channels"].as_array().into_iter().flatten() {
if let Some(channel) = parse_channel(c) {
self.channels.insert(channel.id.clone(), channel);
}
}
self.members.clear();
for m in ready["members"].as_array().into_iter().flatten() {
if let Some((server, user, member)) = parse_member(m) {
if user == self.own_id {
self.members.insert(server, member);
}
}
}
self.voice.clear();
for state in ready["voice_states"].as_array().into_iter().flatten() {
if let Some(channel) = state["id"].as_str() {
let members: Vec<VoiceMember> = state["participants"].as_array().into_iter().flatten().filter_map(parse_voice_member).collect();
if !members.is_empty() {
self.voice.insert(channel.to_string(), members);
}
}
}
for e in ready["emojis"].as_array().into_iter().flatten() {
if let Some((id, info)) = parse_emoji(e) {
self.emojis.insert(id, info);
}
}
}
/// What somebody is called: their display name, or their id if nothing is known of them yet.
pub fn name_of(&self, user: &str) -> String {
self.users.get(user).map(|u| u.name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| user.to_string())
}
/// What a channel is called in the list: a direct message is the other person, a group its name, a server channel
/// `Server/#channel`.
pub fn title_of(&self, channel: &Channel) -> String {
match channel.kind {
Kind::Text | Kind::Voice => {
let server = channel.server.as_ref().and_then(|s| self.servers.get(s)).map(|s| s.name.as_str()).unwrap_or("server");
format!("{server}/#{}", channel.name)
}
Kind::Direct => channel.recipients.iter().find(|r| **r != self.own_id).map(|r| self.name_of(r)).unwrap_or_else(|| "Direct message".to_string()),
Kind::Group => channel.name.clone(),
Kind::Saved => "Saved Notes".to_string(),
}
}
/// What the account may do in a channel, by Stoat's own reckoning.
pub fn permissions(&self, channel: &Channel, now: i64) -> u64 {
match channel.kind {
Kind::Saved => GRANT_ALL_SAFE,
Kind::Direct => {
// Stoat's own rule: a direct message may be written in with a friend, or where either side is a bot;
// between anybody else it can be read and not written in, and a block closes it.
let other = channel.recipients.iter().find(|r| **r != self.own_id).and_then(|r| self.users.get(r));
let relationship = other.map(|u| u.relationship.as_str()).unwrap_or("None");
let a_bot = other.is_some_and(|u| u.bot) || self.users.get(&self.own_id).is_some_and(|u| u.bot);
if relationship == "Friend" || relationship == "User" || (a_bot && relationship != "Blocked" && relationship != "BlockedOther") {
DIRECT_MESSAGE
} else {
VIEW_ONLY
}
}
Kind::Group => {
if channel.owner.as_deref() == Some(self.own_id.as_str()) {
GRANT_ALL_SAFE
} else {
VIEW_ONLY | channel.group_permissions.unwrap_or(DIRECT_MESSAGE)
}
}
Kind::Text | Kind::Voice => {
let Some(server) = channel.server.as_ref().and_then(|s| self.servers.get(s)) else { return 0 };
if server.owner == self.own_id {
return GRANT_ALL_SAFE;
}
let Some(member) = self.members.get(&server.id) else { return 0 };
let mut permissions = server.default_permissions;
let apply = |p: &mut u64, (allow, deny): (u64, u64)| {
*p |= allow;
*p &= !deny;
};
apply(&mut permissions, channel.default);
// The account's roles, the lowest-ranked (the least powerful) first, so the most powerful speaks last.
let mut mine: Vec<(&String, &Role)> = server.roles.iter().filter(|(id, _)| member.roles.contains(id)).collect();
mine.sort_by_key(|(_, role)| std::cmp::Reverse(role.rank));
for (_, role) in &mine {
apply(&mut permissions, (role.allow, role.deny));
}
for (id, _) in &mine {
if let Some(over) = channel.roles.get(*id) {
apply(&mut permissions, *over);
}
}
if !member.can_publish {
permissions &= !(SPEAK | VIDEO);
}
if !member.can_receive {
permissions &= !LISTEN;
}
if member.timeout.is_some_and(|t| t > now) {
permissions &= ALLOWED_IN_TIMEOUT;
}
if permissions & VIEW_CHANNEL == 0 {
permissions = 0;
}
permissions
}
}
}
/// What the account may do in a server as a whole: its default, then the account's roles, the most powerful last.
pub fn server_permissions(&self, server: &Server) -> u64 {
if server.owner == self.own_id {
return GRANT_ALL_SAFE;
}
let Some(member) = self.members.get(&server.id) else { return 0 };
let mut permissions = server.default_permissions;
let mut mine: Vec<&Role> = server.roles.iter().filter(|(id, _)| member.roles.contains(id)).map(|(_, r)| r).collect();
mine.sort_by_key(|r| std::cmp::Reverse(r.rank));
for role in mine {
permissions |= role.allow;
permissions &= !role.deny;
}
permissions
}
/// How powerful the account's best role in a server is, as Stoat ranks them (a lower number is more powerful), or
/// `None` where it holds none; the owner outranks everything.
pub fn own_rank(&self, server: &Server) -> Option<i64> {
if server.owner == self.own_id {
return Some(i64::MIN);
}
let member = self.members.get(&server.id)?;
member.roles.iter().filter_map(|r| server.roles.get(r)).map(|r| r.rank).min()
}
/// Why a channel is read-only, or nothing where it is not.
pub fn read_only(&self, channel: &Channel, now: i64) -> Option<&'static str> {
let permissions = self.permissions(channel, now);
if permissions & SEND_MESSAGE != 0 {
return None;
}
let timed_out = channel.server.as_ref().and_then(|s| self.members.get(s)).is_some_and(|m| m.timeout.is_some_and(|t| t > now));
let blocked = channel.kind == Kind::Direct && channel.recipients.iter().find(|r| **r != self.own_id).and_then(|r| self.users.get(r)).is_some_and(|u| u.relationship == "Blocked" || u.relationship == "BlockedOther");
Some(if timed_out {
TIMED_OUT
} else if blocked {
BLOCKED_DM
} else if channel.kind == Kind::Direct {
NOT_FRIENDS
} else {
NO_SEND_PERMISSION
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const ME: &str = "01ME000000000000000000000A";
const OWNER: &str = "01OWNER00000000000000000A";
fn world() -> World {
let mut w = World::default();
w.absorb_ready(
ME,
&json!({
"users": [
{ "_id": ME, "username": "me", "display_name": "Me" },
{ "_id": OWNER, "username": "boss" },
{ "_id": "U3", "username": "pal", "display_name": "Pal", "relationship": "Friend" },
{ "_id": "U4", "username": "foe", "relationship": "Blocked" }
],
"servers": [{
"_id": "S1", "owner": OWNER, "name": "Chasm", "default_permissions": VIEW_ONLY | SEND_MESSAGE,
"channels": ["C1", "C2"],
"categories": [{ "id": "K1", "title": "General", "channels": ["C1"] }],
"roles": {
"R1": { "name": "Mods", "rank": 1, "permissions": { "a": MANAGE_CHANNEL, "d": 0 }, "colour": "#ff0000" },
"R2": { "name": "Muted", "rank": 5, "permissions": { "a": 0, "d": SEND_MESSAGE } }
}
}],
"channels": [
{ "_id": "C1", "channel_type": "TextChannel", "server": "S1", "name": "general" },
{ "_id": "C2", "channel_type": "TextChannel", "server": "S1", "name": "rules", "default_permissions": { "a": 0, "d": SEND_MESSAGE }, "role_permissions": { "R1": { "a": SEND_MESSAGE, "d": 0 } } },
{ "_id": "D1", "channel_type": "DirectMessage", "recipients": [ME, "U3"], "active": true },
{ "_id": "D2", "channel_type": "DirectMessage", "recipients": ["U4", ME], "active": true },
{ "_id": "G1", "channel_type": "Group", "name": "Raid", "owner": "U3", "recipients": [ME, "U3"] },
{ "_id": "N1", "channel_type": "SavedMessages", "user": ME }
],
"members": [
{ "_id": { "server": "S1", "user": ME }, "roles": [] },
{ "_id": { "server": "S1", "user": "U3" }, "roles": ["R1"] }
]
}),
);
w
}
#[test]
fn a_text_channel_with_a_voice_part_is_still_a_conversation_and_can_be_called_into() {
let voice = parse_channel(&json!({ "_id": "V", "channel_type": "TextChannel", "server": "S1", "name": "Hangout", "voice": {} })).unwrap();
assert!(voice.voice && voice.kind == Kind::Text);
let plain = parse_channel(&json!({ "_id": "T", "channel_type": "TextChannel", "server": "S1", "name": "talk" })).unwrap();
assert!(!plain.voice);
}
#[test]
fn a_channel_is_named_for_what_it_is() {
let w = world();
assert_eq!(w.title_of(&w.channels["C1"]), "Chasm/#general");
assert_eq!(w.title_of(&w.channels["D1"]), "Pal");
assert_eq!(w.title_of(&w.channels["D2"]), "foe");
assert_eq!(w.title_of(&w.channels["G1"]), "Raid");
assert_eq!(w.title_of(&w.channels["N1"]), "Saved Notes");
assert_eq!(w.name_of("nobody"), "nobody");
assert_eq!(w.name_of(OWNER), "boss", "a user with no display name is called by their username");
}
#[test]
fn a_member_with_the_server_default_may_write_and_a_channel_that_denies_it_says_so() {
let w = world();
assert!(w.permissions(&w.channels["C1"], 0) & SEND_MESSAGE != 0);
assert_eq!(w.read_only(&w.channels["C1"], 0), None);
assert_eq!(w.read_only(&w.channels["C2"], 0), Some(NO_SEND_PERMISSION));
}
#[test]
fn a_role_can_lift_what_a_channel_denies_and_a_denying_role_takes_the_default_away() {
let mut w = world();
w.members.get_mut("S1").unwrap().roles = vec!["R1".into()];
assert_eq!(w.read_only(&w.channels["C2"], 0), None, "the Mods role is allowed to write in the rules channel");
w.members.get_mut("S1").unwrap().roles = vec!["R2".into()];
assert_eq!(w.read_only(&w.channels["C1"], 0), Some(NO_SEND_PERMISSION), "the Muted role denies sending");
}
#[test]
fn the_more_powerful_role_has_the_last_word() {
let mut w = world();
// Rank 1 allows what rank 5 denies: the lower number is the stronger.
w.servers.get_mut("S1").unwrap().roles.get_mut("R1").unwrap().allow |= SEND_MESSAGE;
w.members.get_mut("S1").unwrap().roles = vec!["R1".into(), "R2".into()];
assert_eq!(w.read_only(&w.channels["C1"], 0), None);
}
#[test]
fn the_owner_may_do_everything_and_a_timed_out_member_may_only_look() {
let mut w = world();
w.own_id = OWNER.to_string();
assert_eq!(w.permissions(&w.channels["C2"], 0), GRANT_ALL_SAFE);
w.own_id = ME.to_string();
w.members.get_mut("S1").unwrap().timeout = Some(1000);
assert_eq!(w.permissions(&w.channels["C1"], 500), VIEW_ONLY);
assert_eq!(w.read_only(&w.channels["C1"], 500), Some(TIMED_OUT));
assert!(w.permissions(&w.channels["C1"], 2000) & SEND_MESSAGE != 0, "it is over");
}
#[test]
fn a_channel_that_cannot_be_seen_has_nothing_in_it() {
let mut w = world();
w.servers.get_mut("S1").unwrap().default_permissions = SEND_MESSAGE;
assert_eq!(w.permissions(&w.channels["C1"], 0), 0);
w.members.clear();
assert_eq!(w.permissions(&w.channels["C1"], 0), 0, "not a member");
}
#[test]
fn direct_messages_groups_and_saved_notes() {
let mut w = world();
assert_eq!(w.read_only(&w.channels["D1"], 0), None);
assert_eq!(w.read_only(&w.channels["D2"], 0), Some(BLOCKED_DM));
// Somebody who is neither a friend nor a bot can be read and not written to.
w.users.get_mut("U3").unwrap().relationship = "None".into();
assert_eq!(w.read_only(&w.channels["D1"], 0), Some(NOT_FRIENDS));
w.users.get_mut("U3").unwrap().bot = true;
assert_eq!(w.read_only(&w.channels["D1"], 0), None, "a bot may be written to");
w.users.get_mut("U3").unwrap().relationship = "Friend".into();
assert_eq!(w.read_only(&w.channels["G1"], 0), None);
assert_eq!(w.permissions(&w.channels["N1"], 0), GRANT_ALL_SAFE);
}
#[test]
fn the_instance_says_where_its_files_are_and_how_long_a_message_may_be() {
let root = json!({
"ws": "wss://events.example", "features": {
"autumn": { "enabled": true, "url": "https://cdn.example/" },
"livekit": { "enabled": true, "nodes": [{ "name": "hel1", "public_url": "wss://livekit.example" }] },
"january": { "enabled": false, "url": "https://proxy.example" },
"limits": { "global": { "new_user_hours": 72 }, "default": { "message_length": 2000, "message_attachments": 5 }, "new_user": { "message_length": 1500, "message_attachments": 3, "file_upload_size_limits": { "attachments": 5000000 } } }
}
});
let i = Instance::from_root(&root);
assert_eq!(i.ws, "wss://events.example");
assert_eq!(i.autumn.as_deref(), Some("https://cdn.example"));
assert_eq!(i.voice_node.as_deref(), Some("hel1"));
assert_eq!(i.january, None, "a disabled service has no address");
assert_eq!((i.message_length, i.new_message_length, i.new_user_hours), (2000, 1500, 72));
assert_eq!((i.message_attachments, i.new_message_attachments, i.attachment_bytes, i.new_attachment_bytes), (5, 3, 20_000_000, 5_000_000));
}
}
-3
View File
@@ -346,9 +346,6 @@ async fn run() -> Result<()> {
for cfg in state.accounts.all_telegram() {
backend::telegram::connect::spawn(state.clone(), cfg);
}
for cfg in state.accounts.all_stoat() {
backend::stoat::spawn(state.clone(), cfg);
}
// What was going on last time, before anything is served: a window that
// connects immediately should see the same list it was looking at.
-3
View File
@@ -40,8 +40,6 @@ pub const CACHES: &[Cache] = &[
Cache { dir: "discord-thumbnails", group: "media", max_bytes: 100 * MB },
Cache { dir: "telegram-media", group: "media", max_bytes: 500 * MB },
Cache { dir: "telegram-avatars", group: "avatars", max_bytes: 64 * MB },
Cache { dir: "stoat-media", group: "media", max_bytes: 500 * MB },
Cache { dir: "stoat-avatars", group: "avatars", max_bytes: 64 * MB },
Cache { dir: "sneedchat-avatars", group: "avatars", max_bytes: 100 * MB },
Cache { dir: "discord-icons", group: "avatars", max_bytes: 16 * MB },
Cache { dir: "kick-emotes", group: "emotes", max_bytes: 64 * MB },
@@ -220,7 +218,6 @@ pub async fn restore(state: &AppState, path: &str, buffer_id: Option<&str>, mess
return Err(e);
}
},
"stoat-media" | "stoat-avatars" => crate::backend::stoat::media::restore_cached(&path).await?,
_ => bail!("{} is fetched again by what uses it, not by name", cache.dir),
};
Ok(format!("file://{}", restored.display()))
+1 -2
View File
@@ -933,7 +933,6 @@ pub fn service_of(account_id: &str) -> &'static str {
Some("steam") => "steam",
Some("twitch") => "twitch",
Some("telegram") => "telegram",
Some("stoat") => "stoat",
Some("jabber") => "jabber",
Some("slack") => "slack",
_ => "irc",
@@ -1102,7 +1101,7 @@ pub fn status_supported(account_id: &str, status: &str) -> bool {
match status {
"online" | "dnd" => true,
"idle" => account_id.starts_with("discord:") || !(account_id.starts_with("matrix:") || account_id.starts_with("kick:") || account_id.starts_with("sneedchat:")),
"invisible" => account_id.starts_with("discord:") || account_id.starts_with("matrix:") || account_id.starts_with("steam:") || account_id.starts_with("stoat:"),
"invisible" => account_id.starts_with("discord:") || account_id.starts_with("matrix:") || account_id.starts_with("steam:"),
_ => false,
}
}
-317
View File
@@ -53,14 +53,6 @@ async fn apply_ignore(state: &AppState, account_id: &str, target: &str, ignored:
state.ignores.set(account_id, target, ignored);
Ok("account")
}
// Stoat has a real block too, told as well as recorded for the same reason: a blocked person's messages in a
// server still arrive, and this client has to stop showing them.
"stoat" => {
let handle = state.runtime.stoat_handle(account_id).ok_or_else(|| anyhow::anyhow!("that Stoat account is not connected"))?;
backend::stoat::people::block(state, &handle, target, ignored).await?;
state.ignores.set(account_id, target, ignored);
Ok("account")
}
_ => {
state.ignores.set(account_id, target, ignored);
Ok("window")
@@ -121,7 +113,6 @@ fn adding_service(method: &str) -> Option<&'static str> {
"addSteamAccount" | "addSteamAccountQr" => Some("steam"),
"addTwitchAccount" | "startTwitchLogin" => Some("twitch"),
"addTelegramAccount" => Some("telegram"),
"addStoatAccount" | "addStoatAccountToken" => Some("stoat"),
_ => None,
}
}
@@ -437,7 +428,6 @@ pub async fn dispatch(
{ "id": "steam", "name": "Steam", "available": true },
{ "id": "twitch", "name": "Twitch", "available": true },
{ "id": "telegram", "name": "Telegram", "available": true },
{ "id": "stoat", "name": "Stoat", "available": true },
])),
None,
),
@@ -516,16 +506,6 @@ pub async fn dispatch(
tracing::debug!("telegram history: {e:#}");
}
}
// Stoat keeps every message: opening a conversation asks for its recent end, and scrolling to the
// top asks for the page before the oldest one held (its paging is by message id, not by time).
if buffer.account_id.starts_with("stoat:") {
if let Some(handle) = state.runtime.stoat_handle(&buffer.account_id) {
let cursor = if before > 0 { state.store.oldest_msg_id(buffer_id).ok().flatten() } else { None };
if let Err(e) = backend::stoat::messages::history(state, &handle, &buffer, cursor.as_deref()).await {
tracing::debug!("stoat history: {e:#}");
}
}
}
// Kick pages back through its own history endpoint,
// continuing from where the last page stopped rather than
// by timestamp - the endpoint is cursor-based, and asking
@@ -823,18 +803,8 @@ pub async fn dispatch(
let twitch_tokens = state.accounts.get_twitch(id).filter(|a| a.is_signed_in()).map(|a| (a.access_token, a.refresh_token));
// A Telegram session is ended at Telegram too, and its file taken away.
let telegram_account = state.accounts.get_telegram(id);
// A Stoat session this client made is ended at Stoat; a token that was pasted belongs to another app
// of the person's, which ending would sign out.
let stoat_session = state.accounts.get_stoat(id).filter(|a| !a.session_id.is_empty());
match state.accounts.remove(id) {
Ok(true) => {
if let Some(config) = stoat_session {
tokio::spawn(async move {
let api = backend::stoat::api::Api::new(&config.account_id(), &config.instance, Some(config.token.clone()));
let _ = api.post("/auth/session/logout", &serde_json::json!({})).await;
});
}
state.runtime.clear_stoat_handle(id);
if let Some(config) = telegram_account {
backend::telegram::folders::forget(&config.account_id());
let state = state.clone();
@@ -1069,16 +1039,6 @@ pub async fn dispatch(
// Twitch is the same shape: a channel is a streamer, naming one is
// the whole of joining it, and the connection remembers it once
// Twitch has said it is there.
// A Stoat server is joined by an invite link or code.
if id.starts_with("stoat:") {
let Some(handle) = state.runtime.stoat_handle(id) else {
return (None, Some("that Stoat account is not connected".to_string()));
};
return match backend::stoat::people::join_invite(state, &handle, name).await {
Ok(()) => (Some(ok_node()), None),
Err(e) => (None, Some(format!("{e:#}"))),
};
}
if id.starts_with("telegram:") {
return match backend::telegram::membership::join(state, id, name).await {
Ok(()) => (Some(ok_node()), None),
@@ -1153,15 +1113,6 @@ pub async fn dispatch(
}
(Some(ok_node()), None)
}
Some(buffer) if buffer.account_id.starts_with("stoat:") => {
// It lapses by itself, so only "typing" is said.
if typing {
if let Err(e) = backend::stoat::send::typing(state, &buffer) {
tracing::debug!("sendTyping: {e}");
}
}
(Some(ok_node()), None)
}
Some(buffer) if buffer.account_id.starts_with("telegram:") => {
// It lapses by itself, so only "typing" is said.
if typing {
@@ -1275,20 +1226,6 @@ pub async fn dispatch(
// The same for a Discord channel or direct message: set on the
// account, so it is quiet on a phone as well.
// The same for a Stoat conversation, set in the account's synced settings.
"setStoatChannelMuted" => {
let Some(buffer) = p_str_opt(params, "bufferId").and_then(|b| state.runtime.get_buffer(b)).filter(|b| b.account_id.starts_with("stoat:")) else {
return (None, Some("setStoatChannelMuted requires a Stoat \"bufferId\"".to_string()));
};
let Some(handle) = state.runtime.stoat_handle(&buffer.account_id) else {
return (None, Some("that Stoat account is not connected".to_string()));
};
match backend::stoat::settings::set_muted(state, &handle, &buffer, p_bool(params, "muted", true)).await {
Ok(()) => (Some(ok_node()), None),
Err(e) => (None, Some(format!("{e:#}"))),
}
}
"setDiscordChannelMuted" => {
let Some(buffer_id) = p_str_opt(params, "bufferId") else {
return (None, Some("setDiscordChannelMuted requires \"bufferId\"".to_string()));
@@ -1359,7 +1296,6 @@ pub async fn dispatch(
"sneedchat" => backend::sneedchat::profile(&state, &account_id, &buffer_id, &who),
"steam" => backend::steam::profile::build(&state, &account_id, &buffer_id, &who, &user_id).await,
"telegram" => backend::telegram::profile::build(&state, &account_id, &buffer_id, &who, &user_id).await,
"stoat" => backend::stoat::profile::build(&state, &account_id, &buffer_id, &who, &user_id).await,
// A service with nothing to add still answers, so the
// card stops waiting and shows the name.
other => {
@@ -1441,13 +1377,6 @@ pub async fn dispatch(
}
(Some(ok_node()), None)
}
// Stoat keeps one too.
Some(buffer) if buffer.account_id.starts_with("stoat:") => {
if let Err(e) = backend::stoat::send::mark_read(state, &buffer).await {
tracing::debug!("stoat read position: {e}");
}
(Some(ok_node()), None)
}
// Telegram keeps a read position for each conversation too, and tells the account's other apps.
Some(buffer) if buffer.account_id.starts_with("telegram:") => {
if let Err(e) = backend::telegram::send::mark_read(state, &buffer).await {
@@ -1881,8 +1810,6 @@ pub async fn dispatch(
} else if state.accounts.get_steam(account_id).is_some() {
// A choice, as against stepping away: Steam keeps the two apart.
backend::steam::apply_status(state, account_id, status, true)
} else if let Some(handle) = state.runtime.stoat_handle(account_id).filter(|_| account_id.starts_with("stoat:")) {
backend::stoat::own::apply_status(&handle, status, text.as_deref()).await
} else if let Some(sender) = state.runtime.irc_sender(account_id) {
// IRC has only away and back.
let result = match status {
@@ -1932,11 +1859,6 @@ pub async fn dispatch(
backend::discord::apply_session_presence(state, id, &shown)
} else if state.accounts.get_steam(id).is_some() {
backend::steam::apply_status(state, id, &shown, false)
} else if let Some(handle) = state.runtime.stoat_handle(id).filter(|_| id.starts_with("stoat:")) {
let handle = handle.clone();
let shown = shown.clone();
tokio::spawn(async move { backend::stoat::own::apply_status(&handle, &shown, None).await });
true
} else if let Some(sender) = state.runtime.irc_sender(id) {
let away = if shown == "idle" { Some("Idle".to_string()) } else { None };
sender.send(irc::proto::Command::AWAY(away)).is_ok()
@@ -1978,18 +1900,6 @@ pub async fn dispatch(
false
}
}
Some(buffer) if buffer.account_id.starts_with("stoat:") => match state.runtime.stoat_handle(&buffer.account_id) {
Some(handle) => {
let state = state.clone();
tokio::spawn(async move {
if let Err(e) = backend::stoat::members::refresh(&state, &handle, &buffer).await {
tracing::debug!("stoat members: {e}");
}
});
true
}
None => false,
},
Some(buffer) if buffer.account_id.starts_with("telegram:") => {
let (state, buffer) = (state.clone(), buffer);
tokio::spawn(async move {
@@ -2135,28 +2045,6 @@ pub async fn dispatch(
}
}
// The account's own profile on Stoat, and changing it. What is not given is left as it is.
"stoatOwnProfile" | "setStoatProfile" | "setStoatProfileAvatar" => {
let Some(handle) = p_str_opt(params, "accountId").filter(|a| a.starts_with("stoat:")).and_then(|a| state.runtime.stoat_handle(a)) else {
return (None, Some(format!("{method} requires a connected Stoat \"accountId\"")));
};
let done = match method {
"stoatOwnProfile" => return match backend::stoat::own::get(&handle).await {
Ok(profile) => (Some(profile), None),
Err(e) => (None, Some(format!("{e:#}"))),
},
"setStoatProfile" => backend::stoat::own::set(&handle, p_str_opt(params, "displayName"), p_str_opt(params, "pronouns"), p_str_opt(params, "bio"), p_str_opt(params, "statusText")).await.map(|_| serde_json::json!({ "ok": true })),
_ => match p_str_opt(params, "path").filter(|p| !p.is_empty()) {
Some(path) => backend::stoat::own::set_avatar(&handle, path).await.map(|url| serde_json::json!({ "avatarUrl": url })),
None => backend::stoat::own::clear_avatar(&handle).await.map(|_| serde_json::json!({ "avatarUrl": null })),
},
};
match done {
Ok(answer) => (Some(answer), None),
Err(e) => (None, Some(format!("{e:#}"))),
}
}
// Which conversations a room has going on beside the main one.
//
// From the server rather than from scrollback: a thread whose root
@@ -2526,8 +2414,6 @@ pub async fn dispatch(
backend::matrix::load_context(state, &buffer.account_id, buffer_id, message_id).await
} else if buffer.account_id.starts_with("telegram:") {
backend::telegram::history::context(state, &buffer, message_id).await
} else if let Some(handle) = state.runtime.stoat_handle(&buffer.account_id).filter(|_| buffer.account_id.starts_with("stoat:")) {
backend::stoat::search::context(state, &handle, &buffer, message_id).await
} else {
Err(anyhow::anyhow!("this service cannot be asked for one message"))
};
@@ -2570,8 +2456,6 @@ pub async fn dispatch(
backend::matrix::load_newer(state, &buffer.account_id, buffer_id, message_id).await
} else if buffer.account_id.starts_with("telegram:") {
backend::telegram::history::newer(state, &buffer, message_id).await
} else if let Some(handle) = state.runtime.stoat_handle(&buffer.account_id).filter(|_| buffer.account_id.starts_with("stoat:")) {
backend::stoat::search::newer(state, &handle, &buffer, message_id).await
} else {
Err(anyhow::anyhow!("this service cannot be read forward"))
};
@@ -2599,8 +2483,6 @@ pub async fn dispatch(
backend::matrix::list_pinned(state, &buffer.account_id, buffer_id).await
} else if buffer.account_id.starts_with("telegram:") {
backend::telegram::pins::list(state, &buffer).await.map_err(|e| anyhow::anyhow!(e))
} else if let Some(handle) = state.runtime.stoat_handle(&buffer.account_id).filter(|_| buffer.account_id.starts_with("stoat:")) {
backend::stoat::search::pins(&handle, &buffer).await
} else {
Err(anyhow::anyhow!("this service has no pinned messages"))
};
@@ -2652,8 +2534,6 @@ pub async fn dispatch(
backend::matrix::set_pinned(state, &buffer.account_id, buffer_id, message_id, pinned).await
} else if buffer.account_id.starts_with("telegram:") {
backend::telegram::pins::set(state, &buffer, message_id, pinned).await.map_err(|e| anyhow::anyhow!(e))
} else if let Some(handle) = state.runtime.stoat_handle(&buffer.account_id).filter(|_| buffer.account_id.starts_with("stoat:")) {
backend::stoat::search::set_pinned(&handle, &buffer, message_id, pinned).await
} else {
Err(anyhow::anyhow!("this service has no pinned messages"))
};
@@ -2663,27 +2543,6 @@ pub async fn dispatch(
}
}
// The same question asked of Stoat's own index, for what this client never read: one channel at a time.
"searchStoatMessages" => {
let (buffer_id, query) = match (p_str_opt(params, "bufferId"), p_str_opt(params, "query")) {
(Some(b), Some(q)) => (b, q),
_ => return (None, Some("searchStoatMessages requires \"bufferId\" and \"query\"".to_string())),
};
if query.trim().is_empty() {
return (Some(serde_json::json!({ "results": [], "roomNames": {}, "count": 0 })), None);
}
let Some(buffer) = state.runtime.get_buffer(buffer_id).filter(|b| b.account_id.starts_with("stoat:")) else {
return (None, Some("that conversation is not on Stoat".to_string()));
};
let Some(handle) = state.runtime.stoat_handle(&buffer.account_id) else {
return (None, Some("that Stoat account is not connected".to_string()));
};
match backend::stoat::search::run(&handle, &buffer, query.trim(), p_i64(params, "limit", 25) as usize).await {
Ok(answer) => (Some(answer), None),
Err(e) => (None, Some(format!("{e:#}"))),
}
}
// The same question asked of Telegram's own index, for what this client never read.
"searchTelegramMessages" => {
let (buffer_id, query) = match (p_str_opt(params, "bufferId"), p_str_opt(params, "query")) {
@@ -2816,7 +2675,6 @@ pub async fn dispatch(
"ringing": true
})
})
.chain(state.runtime.all_stoat_handles().into_iter().flat_map(|h| backend::stoat::events::ringing(state, &h)))
.collect();
(Some(Value::Array(calls)), None)
}
@@ -3599,11 +3457,6 @@ pub async fn dispatch(
backend::discord::leave_guild(state, account_id, guild_id).await
} else if let Some(room_id) = rest.strip_prefix("space:") {
backend::matrix::leave_room(state, account_id, room_id).await
} else if let Some(server_id) = rest.strip_prefix("server:").filter(|_| account_id.starts_with("stoat:")) {
match state.runtime.stoat_handle(account_id) {
Some(handle) => backend::stoat::people::leave_server(state, &handle, server_id).await,
None => Err(anyhow::anyhow!("that Stoat account is not connected")),
}
} else if rest.starts_with("tggroup:") {
// A Telegram group is its rail entry and the one conversation under it: leaving is leaving that.
let under: Vec<crate::model::Buffer> = state.runtime.list_buffers().into_iter().filter(|b| b.group_id.as_deref() == Some(group_id)).collect();
@@ -3722,24 +3575,6 @@ pub async fn dispatch(
}
}
},
Some(buffer) if buffer.account_id.starts_with("stoat:") => {
let paths: Vec<String> = match params.get("attachmentPaths").and_then(|v| v.as_array()) {
Some(list) => list.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(),
None => attachment_path.map(|p| vec![p.to_string()]).unwrap_or_default(),
};
let spoilers: Vec<&str> = params.get("spoilerPaths").and_then(|v| v.as_array()).map(|list| list.iter().filter_map(|v| v.as_str()).collect()).unwrap_or_default();
let files: Vec<(String, bool)> = paths.iter().map(|p| (p.clone(), spoilers.contains(&p.as_str()))).collect();
let sent = backend::stoat::send::send_files(state, &buffer, body, &files, reply_to_id, progress).await;
if let Some(p) = progress {
if !files.is_empty() {
p.done(sent.as_ref().err().map(String::as_str));
}
}
match sent {
Ok(()) => (Some(ok_node()), None),
Err(e) => (None, Some(e)),
}
}
Some(buffer) if buffer.account_id.starts_with("telegram:") && params.get("thread").and_then(|v| v.as_bool()).unwrap_or(false) && attachment_path.is_none() && reply_to_id.is_some() => {
match backend::telegram::threads::reply(state, &buffer, reply_to_id.unwrap_or_default(), body).await {
Ok(()) => (Some(ok_node()), None),
@@ -3960,10 +3795,6 @@ pub async fn dispatch(
Err(e) => (None, Some(format!("{e:#}"))),
},
},
Some(buffer) if buffer.account_id.starts_with("stoat:") => match backend::stoat::send::edit(state, &buffer, msg_id, body).await {
Ok(()) => (Some(ok_node()), None),
Err(e) => (None, Some(e)),
},
Some(buffer) if buffer.account_id.starts_with("telegram:") => match backend::telegram::send::edit(state, &buffer, msg_id, body).await {
Ok(()) => (Some(ok_node()), None),
Err(e) => (None, Some(e)),
@@ -4008,10 +3839,6 @@ pub async fn dispatch(
Err(e) => (None, Some(format!("{e:#}"))),
},
},
Some(buffer) if buffer.account_id.starts_with("stoat:") => match backend::stoat::send::react(state, &buffer, msg_id, emoji, add).await {
Ok(()) => (Some(ok_node()), None),
Err(e) => (None, Some(e)),
},
Some(buffer) if buffer.account_id.starts_with("telegram:") => match backend::telegram::reactions::toggle(state, &buffer, msg_id, emoji, add).await {
Ok(()) => (Some(ok_node()), None),
Err(e) => (None, Some(e)),
@@ -4042,10 +3869,6 @@ pub async fn dispatch(
Err(e) => (None, Some(format!("{e:#}"))),
},
},
Some(buffer) if buffer.account_id.starts_with("stoat:") => match backend::stoat::send::delete(state, &buffer, msg_id).await {
Ok(()) => (Some(ok_node()), None),
Err(e) => (None, Some(e)),
},
Some(buffer) if buffer.account_id.starts_with("telegram:") => {
// For everyone unless the window says it is only to be removed from this account's view.
let everyone = params.get("forEveryone").and_then(|v| v.as_bool()).unwrap_or(true);
@@ -4811,104 +4634,6 @@ pub async fn dispatch(
(Some(serde_json::json!({ "loginId": login_id })), None)
}
// Looking after the Stoat server a conversation is in (backend/stoat/manage.rs): one call, with the action to run.
"stoatManage" => {
let Some(buffer) = p_str_opt(params, "bufferId").and_then(|b| state.runtime.get_buffer(b)).filter(|b| b.account_id.starts_with("stoat:")) else {
return (None, Some("stoatManage requires a Stoat \"bufferId\"".to_string()));
};
let Some(handle) = state.runtime.stoat_handle(&buffer.account_id) else {
return (None, Some("that Stoat account is not connected".to_string()));
};
match backend::stoat::manage::run(&handle, &buffer, p_str(params, "action", ""), params).await {
Ok(answer) => (Some(answer), None),
Err(e) => (None, Some(format!("{e:#}"))),
}
}
// What a Stoat account may do to a server's members, and doing it; and an invite to the server.
"getStoatPowers" | "moderateStoatMember" | "setStoatMemberRole" | "createStoatInvite" => {
let Some(buffer) = p_str_opt(params, "bufferId").and_then(|b| state.runtime.get_buffer(b)).filter(|b| b.account_id.starts_with("stoat:")) else {
return (None, Some(format!("{method} requires a Stoat \"bufferId\"")));
};
let Some(handle) = state.runtime.stoat_handle(&buffer.account_id) else {
return (None, Some("that Stoat account is not connected".to_string()));
};
let user = p_str_opt(params, "userId").unwrap_or("");
let done = match method {
"getStoatPowers" => return (Some(backend::stoat::moderation::powers(&handle, &buffer)), None),
"createStoatInvite" => {
let web = state.accounts.get_stoat(&buffer.account_id).map(|a| a.instance).unwrap_or_default();
return match backend::stoat::moderation::invite(&handle, &buffer, &handle.instance.app.clone().unwrap_or(web)).await {
Ok(link) => (Some(serde_json::json!({ "link": link })), None),
Err(e) => (None, Some(format!("{e:#}"))),
};
}
"moderateStoatMember" => backend::stoat::moderation::moderate(&handle, &buffer, user, p_str(params, "action", ""), params.get("minutes").and_then(|v| v.as_i64()), p_str_opt(params, "reason")).await,
_ => backend::stoat::moderation::set_role(&handle, &buffer, user, p_str(params, "roleId", ""), p_bool(params, "give", true)).await,
};
match done {
Ok(()) => (Some(ok_node()), None),
Err(e) => (None, Some(format!("{e:#}"))),
}
}
// A Stoat call: the token and address of the media server to join (what is done with them is the window's, as for
// a Matrix call on a media server), and who is in each channel's call.
"declineStoatCall" => {
let Some(buffer) = p_str_opt(params, "bufferId").and_then(|b| state.runtime.get_buffer(b)).filter(|b| b.account_id.starts_with("stoat:")) else {
return (None, Some("declineStoatCall requires a Stoat \"bufferId\"".to_string()));
};
let Some(handle) = state.runtime.stoat_handle(&buffer.account_id) else {
return (None, Some("that Stoat account is not connected".to_string()));
};
match backend::stoat::voice::decline(state, &handle, &buffer).await {
Ok(()) => (Some(ok_node()), None),
Err(e) => (None, Some(format!("{e:#}"))),
}
}
"joinStoatCall" | "listStoatVoice" => {
if method == "listStoatVoice" {
let Some(handle) = p_str_opt(params, "accountId").and_then(|a| state.runtime.stoat_handle(a)) else {
return (None, Some("listStoatVoice requires a connected Stoat \"accountId\"".to_string()));
};
let channels: Vec<String> = handle.world.lock().unwrap_or_else(|e| e.into_inner()).voice.keys().cloned().collect();
let out: serde_json::Map<String, serde_json::Value> = channels
.into_iter()
.filter_map(|c| backend::stoat::messages::buffer_of(state, &handle.account_id, &c).map(|b| (b.id, serde_json::Value::Array(backend::stoat::events::voice_members(&handle, &c)))))
.collect();
return (Some(serde_json::Value::Object(out)), None);
}
let Some(buffer) = p_str_opt(params, "bufferId").and_then(|b| state.runtime.get_buffer(b)).filter(|b| b.account_id.starts_with("stoat:")) else {
return (None, Some("joinStoatCall requires a Stoat \"bufferId\"".to_string()));
};
let Some(handle) = state.runtime.stoat_handle(&buffer.account_id) else {
return (None, Some("that Stoat account is not connected".to_string()));
};
match backend::stoat::voice::join(&handle, &buffer).await {
Ok(joined) => (Some(joined), None),
Err(e) => (None, Some(format!("{e:#}"))),
}
}
// Stoat's friends: who has asked, answering, asking, and blocking.
"stoatFriendRequests" | "answerStoatFriendRequest" | "addStoatFriend" | "stoatBlock" => {
let Some(handle) = p_str_opt(params, "accountId").filter(|a| a.starts_with("stoat:")).and_then(|a| state.runtime.stoat_handle(a)) else {
return (None, Some(format!("{method} requires a connected Stoat \"accountId\"")));
};
let user = p_str_opt(params, "userId").unwrap_or("");
let done = match method {
"stoatFriendRequests" => return (Some(serde_json::json!({ "incoming": backend::stoat::people::incoming(&handle) })), None),
"answerStoatFriendRequest" => backend::stoat::people::answer_friend(state, &handle, user, p_bool(params, "accept", true)).await,
"addStoatFriend" => backend::stoat::people::add_friend(&handle, p_str(params, "username", "")).await,
_ => backend::stoat::people::block(state, &handle, user, p_bool(params, "block", true)).await,
};
match done {
Ok(()) => (Some(ok_node()), None),
Err(e) => (None, Some(format!("{e:#}"))),
}
}
// The same sign-in without a number: a QR code to scan with Telegram on a phone that is signed in already.
"addTelegramAccountQr" => {
let (Some(api_id), Some(api_hash)) = (params.get("apiId").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))), p_str_opt(params, "apiHash")) else {
@@ -4922,43 +4647,6 @@ pub async fn dispatch(
(Some(serde_json::json!({ "loginId": login_id })), None)
}
// Signing in to Stoat: an id now and the rest as events (backend/stoat/login.rs). The instance is optional and
// is the official one when it is left out.
"addStoatAccount" | "addStoatAccountToken" => {
let instance = p_str(params, "instance", "").to_string();
let login_id = format!("stoat-{}", crate::model::next_message_id());
if method == "addStoatAccount" {
let (Some(email), Some(password)) = (p_str_opt(params, "email"), p_str_opt(params, "password")) else {
return (None, Some("addStoatAccount requires \"email\" and \"password\"".to_string()));
};
backend::stoat::login::start(state.clone(), login_id.clone(), instance, email.to_string(), password.to_string());
} else {
let Some(token) = p_str_opt(params, "token") else {
return (None, Some("addStoatAccountToken requires \"token\"".to_string()));
};
backend::stoat::login::start_token(state.clone(), login_id.clone(), instance, token.to_string());
}
(Some(serde_json::json!({ "loginId": login_id })), None)
}
// The second factor Stoat asked for, for a sign-in that is waiting.
"submitStoatCode" => {
let (Some(login_id), Some(value)) = (p_str_opt(params, "loginId"), p_str_opt(params, "value")) else {
return (None, Some("submitStoatCode requires \"loginId\" and \"value\"".to_string()));
};
match backend::stoat::login::submit_code(login_id, value) {
Ok(()) => (Some(ok_node()), None),
Err(e) => (None, Some(format!("{e:#}"))),
}
}
"cancelStoatLogin" => {
if let Some(login_id) = p_str_opt(params, "loginId") {
backend::stoat::login::cancel(login_id);
}
(Some(ok_node()), None)
}
// The code Telegram sent, and the cloud password it may ask for next, for a sign-in that is waiting.
"submitTelegramCode" | "submitTelegramPassword" => {
let (Some(login_id), Some(value)) = (p_str_opt(params, "loginId"), p_str_opt(params, "value")) else {
@@ -6371,11 +6059,6 @@ pub async fn dispatch(
Ok(state.runtime.ensure_buffer(state, account_id, &backend::twitch::chat::whisper_buffer(&who), "dm").id)
}
}
"stoat" => match (state.runtime.stoat_handle(account_id), user_id.is_empty()) {
(None, _) => Err(anyhow::anyhow!("that Stoat account is not connected")),
(_, true) => Err(anyhow::anyhow!("Stoat needs the person's id, not their name")),
(Some(handle), false) => backend::stoat::people::open_dm(state, &handle, user_id).await,
},
other => Err(anyhow::anyhow!("{other} has no direct messages")),
};
match opened {
+1 -26
View File
@@ -545,8 +545,6 @@ pub struct Runtime {
steam_links: Mutex<HashMap<String, crate::backend::steam::link::Link>>,
/// A connected Telegram account's client, by account id (see backend/telegram).
telegram_handles: Mutex<HashMap<String, crate::backend::telegram::Handle>>,
/// A connected Stoat account's client, by account id (see backend/stoat).
stoat_handles: Mutex<HashMap<String, crate::backend::stoat::Handle>>,
/// Who has asked to be a Steam account's friend, and who it has asked:
/// what the window is shown on the account's page, kept so a window opened
/// after the request arrived can still be told.
@@ -1115,7 +1113,6 @@ impl Runtime {
twitch_rights: Mutex::new(HashMap::new()),
steam_links: Mutex::new(HashMap::new()),
telegram_handles: Mutex::new(HashMap::new()),
stoat_handles: Mutex::new(HashMap::new()),
steam_requests: Mutex::new(HashMap::new()),
kick_channels: Mutex::new(HashMap::new()),
kick_streams: Mutex::new(HashMap::new()),
@@ -1271,11 +1268,6 @@ impl Runtime {
let conn = conn_states.get(&id).map(|s| s.as_str()).unwrap_or("disconnected");
crate::accounts::telegram_account_to_json(cfg, conn)
}));
out.extend(state.accounts.all_stoat().iter().map(|cfg| {
let id = cfg.account_id();
let conn = conn_states.get(&id).map(|s| s.as_str()).unwrap_or("disconnected");
crate::accounts::stoat_account_to_json(cfg, conn)
}));
out
}
@@ -4436,23 +4428,6 @@ impl Runtime {
self.telegram_handles.lock().unwrap().get(account_id).cloned()
}
pub fn set_stoat_handle(&self, account_id: &str, handle: crate::backend::stoat::Handle) {
self.stoat_handles.lock().unwrap().insert(account_id.to_string(), handle);
}
pub fn clear_stoat_handle(&self, account_id: &str) {
self.stoat_handles.lock().unwrap().remove(account_id);
}
pub fn stoat_handle(&self, account_id: &str) -> Option<crate::backend::stoat::Handle> {
self.stoat_handles.lock().unwrap().get(account_id).cloned()
}
/// Every connected Stoat account.
pub fn all_stoat_handles(&self) -> Vec<crate::backend::stoat::Handle> {
self.stoat_handles.lock().unwrap().values().cloned().collect()
}
pub fn set_steam_link(&self, account_id: &str, link: crate::backend::steam::link::Link) {
self.steam_links.lock().unwrap().insert(account_id.to_string(), link);
}
@@ -5736,7 +5711,7 @@ impl Runtime {
discord_channels, discord_gateway_sessions, ringing_calls,
discord_guild_id, discord_buffer_emojis, emoji_catalogue,
kick_watching, matrix_emoticons, discord_friends,
sneedchat_senders, kick_senders, twitch_senders, twitch_rights, steam_links, telegram_handles, stoat_handles, steam_requests, kick_channels,
sneedchat_senders, kick_senders, twitch_senders, twitch_rights, steam_links, telegram_handles, steam_requests, kick_channels,
kick_streams, kick_stream_fetched, live_cards,
irc_channel_lists, irc_channel_directory, irc_caps, irc_metadata,
discord_last_interaction, matrix_room_creators, matrix_room_versions,
+3 -5
View File
@@ -1361,12 +1361,10 @@ fn drop_missing_local_copies(attachments: &mut [Attachment]) {
let gone = |p: &Option<String>| -> bool {
let Some(p) = p.as_deref() else { return false };
let Some(rest) = p.strip_prefix("file://") else { return false };
// Telegram's and Stoat's are named before they are fetched, and fetched when the window asks (see
// backend/telegram/media.rs and backend/stoat/media.rs): a file that is not there yet is the usual state of one,
// not a sign it was swept.
// Telegram's are named before they are fetched, and fetched when the window asks (see backend/telegram/media.rs):
// a file that is not there yet is the usual state of one, not a sign it was swept.
let path = std::path::Path::new(rest);
let named_first = [crate::backend::telegram::media::MEDIA, crate::backend::telegram::media::AVATARS, crate::backend::stoat::media::MEDIA, crate::backend::stoat::media::AVATARS];
if path.parent().and_then(|d| d.file_name()).and_then(|n| n.to_str()).is_some_and(|n| named_first.contains(&n)) {
if path.parent().and_then(|d| d.file_name()).and_then(|n| n.to_str()).is_some_and(|n| n == crate::backend::telegram::media::MEDIA || n == crate::backend::telegram::media::AVATARS) {
return false;
}
!path.exists()