15 Commits
Author SHA1 Message Date
SalastilandClaude Sonnet 5.5 152b832961 Stoat (#32): mutes kept on the account, search and pins, the account's own profile and presence, looking after a server (channels, headings, roles, permissions, webhooks, audit log), a ring for calls in direct messages, the instance's limits kept to, a bridge's name marked, custom emoji reactions drawn; file ids with a dash or underscore were never fetched
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 23:25:18 -04:00
SalastilandClaude Sonnet 5.5 39a65bd0df Stoat (#32): voice calls over LiveKit, slowmode kept to and said in words, events and sockets tidied
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 22:09:03 -04:00
SalastilandClaude Sonnet 5.5 9648ae56c8 Stoat (#32): custom emoji, moderation, invites; a deadlock fixed
- custom emoji: the picker's sections (one per server and the account's own), drawn as pictures in what is said (named in
  the cache), typed by name and sent as the id; the echo of a message with a spoiler or an emoji is matched
- moderation: what the account may do from its permissions and its best role's rank, kick, ban, timeout and lift it,
  roles given and taken, an invite link to a server
- fixed: a role or membership change took the world's lock while making the channels, which needs it, so the connection
  stopped (found against a self-hosted instance); a test now covers it

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 19:59:00 -04:00
SalastilandClaude Sonnet 5.5 da274d273f Stoat: clippy
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 19:35:55 -04:00
SalastilandClaude Sonnet 5.5 21f87b97ed Stoat (#32): friends, requests, blocks, direct messages, server invites; Stoat's own rule for who may be written to
- opening a conversation with somebody (and the account's own notes), asking by name#1234, answering and refusing a
  request, blocking; the requests are the people whose relationship is Incoming and wait on the invitations tile
- a direct message may be written in with a friend, or where a side is a bot; otherwise it can be read and the box says why
- joining a server by an invite link or code, and leaving one (not one the account owns: that would delete it)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 19:33:49 -04:00
SalastilandClaude Sonnet 5.5 a67a0ce08c Stoat (#32): sending files, the member list with presence, profile cards, voice rooms as conversations
- files go to the file server and are named in the message (spoilers under SPOILER_), within the instance's limits
- the member list of a server, group or direct message, with status, picture and roles; presence kept as people come
  and go (Subscribe), the list read again when members join or leave
- a person's card: name, handle, picture, bio, badges, when the account was made and the server joined, roles, what is
  in common
- a text channel with a voice part is still a conversation, marked as one
- the events address is the instance's own path, no slash added (a self-hosted instance serves /ws exactly)
- Stoat's cache folders are named before they are fetched, like Telegram's

Checked end to end against a local self-hosted Stoat: connect, a message each way, edit, reaction, delete, a picture each
way, the member list and a card.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 19:18:21 -04:00
SalastilandClaude Sonnet 5.5 7dc274d498 Stoat (#32): accounts, sign-in, the events connection, conversations and messages
- StoatAccountConfig (instance, user id, session token and id, route), kept like the other services' tokens
- the REST client: the token as X-Session-Token, buckets kept to from the rate-limit headers, a 429 waited out, a 401 ends
  the session for good
- sign-in by email and password with the second factor (TOTP or recovery code), or a pasted token; never an account made,
  a captcha answered or a password kept
- the events websocket: Authenticate in a message (never in an address), Ready, Ping every 20 s, reconnect with back-off,
  stop on InvalidSession / Logout / a deleted session
- what the account can see, and Stoat's own permission calculation (server default, channel default, roles by rank,
  timeout, no view means nothing there) to say where it may not write
- servers on the rail, categories as headings, direct messages, groups and Saved Notes as conversations; unread seeds
- messages: ULID time, mentions/channels/roles/emoji/spoilers/timestamps turned into what the window reads, replies,
  attachments and embeds through the file server and link proxy, masquerade names, system messages, reactions,
  history by id; live edits, deletions, reactions, typing and read marks
- send, edit, delete, react, typing and read marks; the instance's message length (lower for a new account)
- an end-to-end test against a made-up instance (HTTP and events)

Files, voice, members, profiles and moderation are later rounds.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 18:52:22 -04:00
SalastilandClaude Sonnet 5.5 5983d71baf 1.3.3
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 16:26:14 -04:00
SalastilandClaude Sonnet 5.5 b1862bb1f2 Discord (#338): the green new-member badge closes a server's box for the member's first week
A member who joined a server less than seven days ago wears Discord's green badge, and (presumed, from a
flagging in a server where the account had spoken from the official apps) what is said from another client in
that time is treated as spam. Every channel of such a server is read-only with a banner saying so and when it
ends; it opens by itself at the week's end. The owner is exempt; what the server's own rules ask comes first.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 12:27:22 -04:00
SalastilandClaude Sonnet 5.5 64a13fa9fc Telegram links: t.me and tg:// links open the chat (joined if need be) and the post (#26)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 11:56:23 -04:00
SalastilandClaude Sonnet 5.5 c9c98dec3a Telegram folders: the refusal for an empty folder is worded as the library words it (seen on the real account)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 11:27:06 -04:00
SalastilandClaude Sonnet 5.5 430e89fdd3 Telegram: stickers, bot commands in groups, topic unread counts, QR sign-in, chat folders (#26)
- stickers: the account's recent, favourite and installed packs are listed (a few packs at a time, kept
  15 minutes) and one is sent as its document, in a topic too; pictures are the preview Telegram ships
  with each, fetched when drawn
- bot commands in groups: the commands of every bot in a group, read with the group (basic chat or
  supergroup), shared by its topics; a command two bots have is offered once per bot as cmd@bot
- topic unread counts: a topic's own unread and mention counts seed the window's count (Buffer
  unreadSeed/mentionsSeed, set_buffer_unread); reading a topic reads up to the newest message Telegram
  says it holds, and a topic read on another device clears here
- QR sign-in: exportLoginToken shown as a tg://login QR, renewed as Telegram says (updateLoginToken, with
  a 15 s ask-again as a fallback), DC migration through importLoginToken, then the cloud password if
  asked; addTelegramAccountQr and a telegramLoginQr event
- folders: the account's dialog filters are read after the conversations (what each holds by its rules is
  worked out from what is known of every chat), told to the window as telegramFolders, kept current from
  updateDialogFilter*, and edited from the window: create (with its first chat: Telegram keeps no empty
  folder), rename, delete, reorder, and file a chat into/out of the folder it is drawn under, taking it out of
  a rule-held folder as an exception

All unit-tested; none of it run against Telegram yet.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 11:19:39 -04:00
SalastilandClaude Sonnet 5.5 35573351a3 Discord (#338 H1-H12): no requests after a 401 or three 403s on a route, a captcha closes its place, heartbeats carry the sequence, the server's own rules checked before a send (verification, onboarding, timeout, slowmode), DMs need five of the account's own messages, empty channels locked, history asked once, the official wire shape and two-step uploads, one send at a time a channel
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 22:44:11 -04:00
SalastilandClaude Sonnet 5.5 26389385bc Discord (#338): READY with deduplicated users put back and declared, the gateway stream compressed, no conversation-list request when READY names them, acks while the newest line is read at the bottom
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 21:55:57 -04:00
SalastilandClaude Sonnet 5.5 cb01043f2c Discord first-word lock: a system message about the account (its own join) is not the account speaking
The join message of a server is authored by the account and counted as its first word, so a server was unlocked the moment it was joined. Only an ordinary message or a reply counts, live and in search.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 20:41:05 -04:00
53 changed files with 9576 additions and 117 deletions

No files matched your search

Generated
+1 -1
View File
@@ -4091,7 +4091,7 @@ dependencies = [
[[package]]
name = "nobilis"
version = "1.3.2"
version = "1.3.3"
dependencies = [
"aes-gcm",
"anyhow",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nobilis"
version = "1.3.2"
version = "1.3.3"
edition = "2021"
description = "Chat daemon: IRC, Discord, Sneedchat and Matrix behind one unified JSON model over a Unix socket"
license = "GPL-3.0-or-later"
+215 -3
View File
@@ -537,6 +537,51 @@ 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")]
@@ -559,6 +604,8 @@ 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,
@@ -580,6 +627,8 @@ 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 {
@@ -590,7 +639,7 @@ impl AccountStore {
}
pub fn open(path: PathBuf) -> Result<Self> {
let (irc, discord, sneedchat, matrix, kick, steam, twitch, telegram) = if path.exists() {
let (irc, discord, sneedchat, matrix, kick, steam, twitch, telegram, stoat) = if path.exists() {
let text = std::fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
let file: AccountsFile = toml::from_str(&text)
@@ -603,9 +652,10 @@ 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();
(irc, discord, sneedchat, matrix, kick, steam, twitch, telegram)
let stoat = file.stoat.into_iter().map(|a| (a.account_id(), a)).collect();
(irc, discord, sneedchat, matrix, kick, steam, twitch, telegram, stoat)
} 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,
@@ -617,6 +667,7 @@ impl AccountStore {
steam: Mutex::new(steam),
twitch: Mutex::new(twitch),
telegram: Mutex::new(telegram),
stoat: Mutex::new(stoat),
})
}
@@ -640,6 +691,7 @@ 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
@@ -919,6 +971,48 @@ 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> {
@@ -1065,6 +1159,10 @@ 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
};
@@ -1098,6 +1196,9 @@ 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))
}
@@ -1192,6 +1293,13 @@ 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)?;
@@ -1359,6 +1467,9 @@ 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),
@@ -1398,6 +1509,9 @@ 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))
}
@@ -1787,6 +1901,48 @@ 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
@@ -2097,3 +2253,59 @@ 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);
}
}
+233 -18
View File
@@ -7,7 +7,134 @@
use super::*;
pub(super) const GATEWAY_URL: &str = "wss://gateway.discord.gg/?v=10&encoding=json";
pub(super) const GATEWAY_URL: &str = "wss://gateway.discord.gg/?v=10&encoding=json&compress=zlib-stream";
/// The same address asking for the stream compressed, as Discord's web client and Dissent do: what a session was last
/// given to resume at (kept on disk) may not say so, and a gateway that is not asked to compress does not.
fn compressed(url: &str) -> String {
if url.contains("compress=zlib-stream") {
url.to_string()
} else if url.contains('?') {
format!("{url}&compress=zlib-stream")
} else {
format!("{url}?compress=zlib-stream")
}
}
/// Discord's `zlib-stream`: every message is a piece of one zlib stream that lasts as long as the connection, each
/// ending in the four bytes of a sync flush (`00 00 FF FF`) - a message may arrive in several frames, and is whole when
/// the last of them ends so. Inflating needs the whole stream so far, not the frame alone.
pub(super) struct Inflater {
inflate: flate2::Decompress,
pending: Vec<u8>,
}
impl Inflater {
const SYNC_FLUSH: [u8; 4] = [0x00, 0x00, 0xff, 0xff];
pub(super) fn new() -> Self {
Inflater { inflate: flate2::Decompress::new(true), pending: Vec::new() }
}
/// Takes a frame; hands back the message it completes, if it does.
pub(super) fn push(&mut self, frame: &[u8]) -> Result<Option<String>> {
self.pending.extend_from_slice(frame);
if !self.pending.ends_with(&Self::SYNC_FLUSH) {
return Ok(None);
}
let input = std::mem::take(&mut self.pending);
let mut out: Vec<u8> = Vec::with_capacity(input.len() * 4);
let start = self.inflate.total_in();
loop {
let consumed = (self.inflate.total_in() - start) as usize;
if out.capacity() == out.len() {
out.reserve(input.len().max(4096));
}
let (before_in, before_out) = (self.inflate.total_in(), out.len());
self.inflate.decompress_vec(&input[consumed..], &mut out, flate2::FlushDecompress::Sync).context("inflating the gateway's stream")?;
let all_in = (self.inflate.total_in() - start) as usize >= input.len();
// Done when everything given has been taken and the output stopped for want of input, not of room: while
// the buffer is full there may be more to come out of what was taken.
if all_in && out.len() < out.capacity() {
break;
}
if self.inflate.total_in() == before_in && out.len() == before_out {
bail!("the gateway's stream stopped making sense");
}
}
Ok(Some(String::from_utf8(out).context("the gateway's message was not text")?))
}
}
#[cfg(test)]
mod heartbeat_tests {
use super::heartbeat_message;
#[test]
fn a_heartbeat_says_how_far_the_connection_has_got() {
assert_eq!(heartbeat_message(0), r#"{"d":null,"op":1}"#);
assert_eq!(heartbeat_message(42), r#"{"d":42,"op":1}"#);
}
}
#[cfg(test)]
mod inflate_tests {
use super::*;
/// A zlib stream the way Discord makes one: one compressor, each message flushed (sync) at its end.
fn frames(messages: &[&str]) -> Vec<Vec<u8>> {
let mut compress = flate2::Compress::new(flate2::Compression::default(), true);
messages
.iter()
.map(|m| {
let mut out = Vec::with_capacity(m.len() + 64);
let start = compress.total_in();
while ((compress.total_in() - start) as usize) < m.len() {
let done = (compress.total_in() - start) as usize;
compress.compress_vec(&m.as_bytes()[done..], &mut out, flate2::FlushCompress::None).unwrap();
if out.capacity() == out.len() {
out.reserve(64);
}
}
loop {
compress.compress_vec(&[], &mut out, flate2::FlushCompress::Sync).unwrap();
if out.ends_with(&Inflater::SYNC_FLUSH) {
break;
}
out.reserve(64);
}
out
})
.collect()
}
#[test]
fn messages_of_one_stream_are_read_in_turn() {
let big = format!(r#"{{"op":0,"t":"READY","d":{{"name":"{}"}}}}"#, "x".repeat(50_000));
let sent = [r#"{"op":10,"d":{"heartbeat_interval":41250}}"#, big.as_str(), r#"{"op":11}"#];
let mut inflater = Inflater::new();
for (frame, want) in frames(&sent).iter().zip(sent) {
assert_eq!(inflater.push(frame).unwrap().as_deref(), Some(want));
}
}
#[test]
fn a_message_in_two_frames_is_whole_when_the_second_arrives() {
let sent = [r#"{"op":0,"t":"MESSAGE_CREATE","d":{"content":"hello hello hello hello"}}"#];
let all = &frames(&sent)[0];
let (first, second) = all.split_at(all.len() / 2);
let mut inflater = Inflater::new();
assert_eq!(inflater.push(first).unwrap(), None, "not yet");
assert_eq!(inflater.push(second).unwrap().as_deref(), Some(sent[0]));
}
#[test]
fn the_address_asks_for_the_stream_compressed_once() {
assert_eq!(compressed("wss://x/?v=10&encoding=json"), "wss://x/?v=10&encoding=json&compress=zlib-stream");
assert_eq!(compressed(GATEWAY_URL), GATEWAY_URL);
assert_eq!(compressed("wss://x"), "wss://x?compress=zlib-stream");
}
}
/// Discord's gateway close code for "the token in your IDENTIFY payload is
/// invalid/revoked" - documented at discord.com/developers/docs/topics/
@@ -89,12 +216,25 @@ impl std::fmt::Display for ReconnectRequested {
impl std::error::Error for ReconnectRequested {}
pub(super) async fn next_json<S>(stream: &mut S) -> Result<Value>
where
S: futures::Stream<Item = std::result::Result<WsMessage, tokio_tungstenite::tungstenite::Error>> + Unpin,
{
next_json_inflating(stream, None).await
}
/// `next_json` for a connection that was asked to compress its stream: binary frames are pieces of it.
pub(super) async fn next_json_inflating<S>(stream: &mut S, mut inflater: Option<&mut Inflater>) -> Result<Value>
where
S: futures::Stream<Item = std::result::Result<WsMessage, tokio_tungstenite::tungstenite::Error>> + Unpin,
{
loop {
match stream.next().await {
Some(Ok(WsMessage::Text(text))) => return serde_json::from_str(&text).context("parsing gateway JSON"),
Some(Ok(WsMessage::Binary(frame))) if inflater.is_some() => {
if let Some(text) = inflater.as_mut().expect("checked").push(&frame)? {
return serde_json::from_str(&text).context("parsing gateway JSON");
}
}
Some(Ok(WsMessage::Close(frame))) => {
if frame.as_ref().is_some_and(|f| u16::from(f.code) == CLOSE_CODE_AUTH_FAILED) {
return Err(GatewayAuthFailed.into());
@@ -207,17 +347,23 @@ pub(super) struct Resume {
seq: u64,
}
/// A heartbeat: its data is the number of the last dispatch received, and null before there has been one.
pub(super) fn heartbeat_message(seq: u64) -> String {
json!({ "op": 1, "d": if seq == 0 { Value::Null } else { Value::from(seq) } }).to_string()
}
/// The gateway capabilities this client declares in IDENTIFY, as a user client does (Dissent's `ningen` sends the same
/// five of the seven it knows): notes fetched lazily, read states and guild settings in their versioned form, guild
/// experiments as populations, and read states for things that are not channels. Each changes only the shape of a
/// field READY carries and this client either ignores (`read_state`, notes, experiments) or reads in both shapes
/// (`user_guild_settings`, see `mutes::note_settings`).
/// six of the seven it knows): notes fetched lazily, read states and guild settings in their versioned form, user
/// objects deduplicated, guild experiments as populations, and read states for things that are not channels. Each
/// changes only the shape of a field READY carries and this client either ignores (`read_state`, notes, experiments),
/// reads in both shapes (`user_guild_settings`, see `mutes::note_settings`) or has put back (the deduplicated users,
/// see `ready_shape`).
///
/// Not declared: DEDUPE_USER_OBJECTS (16) and PRIORITIZED_READY_PAYLOAD (32). They move users out of READY into a
/// table by id and split the rest into a second event, and the friends list, the names of conversations and the
/// presences here are built from the objects that would no longer be there. They need a real READY to be written
/// against, not a guess at one.
pub(super) const CAPABILITIES: u32 = (1 << 0) | (1 << 2) | (1 << 3) | (1 << 6) | (1 << 7);
/// Not declared: PRIORITIZED_READY_PAYLOAD (32). It splits what READY carries in two, the second part (presences, the
/// rest of each guild's members, who is in which voice channel) arriving as READY_SUPPLEMENTAL after the guilds are
/// registered, and every one of those is read at the moment of registering here. It buys the client a faster first
/// event; the identity it adds is the one bit.
pub(super) const CAPABILITIES: u32 = (1 << 0) | (1 << 2) | (1 << 3) | (1 << 4) | (1 << 6) | (1 << 7);
/// What IDENTIFY says this client is: moho, on Linux, in this machine's language.
fn identify_properties() -> Value {
@@ -228,6 +374,29 @@ fn identify_properties() -> Value {
properties
}
/// Conversations asked about this run, found or not: one that Discord will not describe is not asked about again for
/// every message in it.
static ASKED_ABOUT: std::sync::Mutex<Option<std::collections::HashSet<String>>> = std::sync::Mutex::new(None);
/// Looks up a direct message that READY did not list - the one request it costs is made when somebody speaks in it,
/// which is the moment it matters - and registers it like any other.
async fn unknown_dm(state: &AppState, config: &DiscordAccountConfig, channel_id: &str, channel_map: &mut HashMap<String, (String, String)>) {
let first = ASKED_ABOUT.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).insert(format!("{}:{channel_id}", config.user_id));
if !first {
return;
}
let resp = http_client_for(&config.token).get(format!("{API_BASE}/channels/{channel_id}")).header("Authorization", &config.token).send_paced().await;
let Ok(resp) = resp else { return };
if !resp.status().is_success() {
return;
}
let Ok(channel) = resp.json::<Value>().await else { return };
// 1 is a direct message and 3 a group.
if matches!(channel["type"].as_i64(), Some(1) | Some(3)) {
register_dm_channel(state, &config.account_id(), &channel, channel_map, &HashMap::new());
}
}
pub(super) async fn run_gateway_with_retry(state: &AppState, config: &DiscordAccountConfig, account_id: &str) {
let mut delay = RECONNECT_INITIAL_DELAY;
let mut session = GatewaySession {
@@ -241,6 +410,11 @@ pub(super) async fn run_gateway_with_retry(state: &AppState, config: &DiscordAcc
session.resume = Some(Resume { session_id: position.session_id, url: position.url, seq: position.seq });
session.pending = replay.into();
}
// A login Discord has already refused is not tried again: the person signs in afresh, which is a new token.
if token_is_halted(&config.token) {
state.runtime.set_conn_state(state, account_id, ConnState::AuthFailed, Some("Discord has refused this account's login - sign in again from Accounts"));
return;
}
state.runtime.set_conn_state(state, account_id, ConnState::Connecting, None);
loop {
// A connection that will log in afresh (not pick a session back up) is counted, and held when the account has
@@ -265,6 +439,8 @@ pub(super) async fn run_gateway_with_retry(state: &AppState, config: &DiscordAcc
Ok(Err(e)) => {
if e.downcast_ref::<GatewayAuthFailed>().is_some() {
tracing::warn!("discord[{account_id}]: {e} - giving up, needs a fresh login");
// Every other request with it stops too: see http::Safety.
halt_token(&config.token);
state.runtime.set_conn_state(state, account_id, ConnState::AuthFailed, Some(&e.to_string()));
return;
}
@@ -330,7 +506,9 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
let GatewaySession { resume, channel_map, guild_context, persist, pending } = session;
// Discord asks that a resume go to the url it handed out with the
// session rather than to the front door.
let connect_url = resume.as_ref().map(|r| r.url.clone()).unwrap_or_else(|| GATEWAY_URL.to_string());
let connect_url = compressed(&resume.as_ref().map(|r| r.url.clone()).unwrap_or_else(|| GATEWAY_URL.to_string()));
// One stream for the whole connection, and a new one for the next: the compressor at the far end starts afresh.
let mut inflater = Inflater::new();
let (ws, _resp) = tokio::time::timeout(CONNECT_TIMEOUT, crate::net::route::websocket(&config.token, connect_url.as_str()))
.await
.map_err(|_| anyhow!("timed out connecting to Discord's gateway"))?
@@ -354,7 +532,7 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
}
let _forwarder_guard = AbortOnDrop(forwarder);
let hello = next_json(&mut stream).await.context("waiting for gateway hello")?;
let hello = next_json_inflating(&mut stream, Some(&mut inflater)).await.context("waiting for gateway hello")?;
let heartbeat_interval = hello["d"]["heartbeat_interval"].as_u64().unwrap_or(41_250).max(1);
// Discord's own documented client contract: if an ack (op 11) hasn't
@@ -369,6 +547,10 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
// false-positive as a missed ack.
let ack_pending = Arc::new(AtomicBool::new(false));
let stale_notify = Arc::new(Notify::new());
// The number of the last dispatch received, which a heartbeat says: it tells Discord how far this connection has
// got, as it does for every client. (A resume starts from where the saved session had got to.)
let last_seq = Arc::new(std::sync::atomic::AtomicU64::new(resume.as_ref().map(|r| r.seq).unwrap_or(0)));
let hb_seq = last_seq.clone();
let hb_tx = out_tx.clone();
let hb_ack_pending = ack_pending.clone();
let hb_stale_notify = stale_notify.clone();
@@ -381,7 +563,7 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
hb_stale_notify.notify_one();
break;
}
if hb_tx.send(json!({ "op": 1, "d": Value::Null }).to_string()).is_err() {
if hb_tx.send(heartbeat_message(hb_seq.load(Ordering::SeqCst))).is_err() {
break;
}
}
@@ -453,7 +635,7 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
}
None => {
let msg = tokio::select! {
result = next_json(&mut stream) => result?,
result = next_json_inflating(&mut stream, Some(&mut inflater)) => result?,
_ = stale_notify.notified() => bail!("no heartbeat ack received - connection is zombied"),
};
(msg, false)
@@ -464,8 +646,11 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
// Tracked before the match so it counts events this client chose
// not to handle - the server's count includes them either way, and
// resuming from a lower number would replay what was already seen.
if let (Some(seq), Some(r)) = (msg.get("s").and_then(|v| v.as_u64()), resume.as_mut()) {
r.seq = seq;
if let Some(seq) = msg.get("s").and_then(|v| v.as_u64()) {
last_seq.store(seq, Ordering::SeqCst);
if let Some(r) = resume.as_mut() {
r.seq = seq;
}
}
match op {
0 => {
@@ -494,6 +679,15 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
tracing::info!("discord[{account_id}]: resumed");
}
"READY" => {
// A new login has missed what the last one did not: what was asked about is asked afresh.
if !replaying {
super::history::forget_asked(&account_id);
}
// Kept as it came (it is what a resume replays), and read with its users put back: see
// ready_shape. A READY that was not dehydrated is read as it is.
let raw = d;
let hydrated = super::ready_shape::rehydrate(raw);
let d = hydrated.as_ref().unwrap_or(raw);
let username = d["user"]["global_name"]
.as_str()
.filter(|s| !s.is_empty())
@@ -501,6 +695,8 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
.unwrap_or("me")
.to_string();
state.runtime.set_own_identity(&account_id, &username);
// What the account can say of itself to a server's rules: a verified email or phone, how old.
super::send_guard::note_account(&account_id, &d["user"]);
// Nitro, which is what lets this account's emoji be
// used away from the guild they belong to. Read here
// because READY is the only place the payload carries
@@ -523,13 +719,13 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
session_id: session_id.to_string(),
url: d["resume_gateway_url"]
.as_str()
.map(|u| format!("{u}/?v=10&encoding=json"))
.map(|u| format!("{u}/?v=10&encoding=json&compress=zlib-stream"))
.unwrap_or_else(|| GATEWAY_URL.to_string()),
seq: msg.get("s").and_then(|v| v.as_u64()).unwrap_or(0),
};
// A new session replaces what was kept; the saved one handled again is that same one.
if !replaying {
persist.ready(&next.session_id, &next.url, next.seq, d.clone());
persist.ready(&next.session_id, &next.url, next.seq, raw.clone());
}
*resume = Some(next);
}
@@ -649,6 +845,11 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
// again is a request for nothing.
let listed: Option<Vec<Value>> = if replaying {
d["moho_dms"].as_array().cloned()
} else if d["private_channels"].as_array().is_some_and(|l| !l.is_empty()) {
// READY names the account's conversations, as it does for Discord's own clients, and
// any it leaves out is found when somebody speaks in it (see `unknown_dm`): no request
// for a list that was just sent.
None
} else {
match http_client_for(&config.token).get(format!("{API_BASE}/users/@me/channels")).header("Authorization", &config.token).send_paced().await {
Ok(resp) => {
@@ -789,6 +990,10 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
if let Some(topic) = d.get("topic") {
state.runtime.set_buffer_topic(state, &crate::model::buffer_id(&account_id, &old_name), topic.as_str().unwrap_or_default());
}
// And so is a channel's slowmode.
if let Some(seconds) = d["rate_limit_per_user"].as_u64() {
super::send_guard::set_slowmode(&crate::model::buffer_id(&account_id, &old_name), seconds);
}
continue;
}
state.runtime.remove_buffer(state, &crate::model::buffer_id(&account_id, &old_name));
@@ -954,6 +1159,8 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
if d["user"]["id"].as_str() != Some(config.user_id.as_str()) {
continue;
}
// What the server's rules ask of the account: pending, flags, roles, a timeout.
super::send_guard::note_member(state, &account_id, guild_id, Some(d));
if d["pending"].as_bool().unwrap_or(false) {
continue;
}
@@ -993,6 +1200,10 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
}
"MESSAGE_CREATE" => {
let channel_id = d["channel_id"].as_str().unwrap_or_default();
// A conversation READY did not name: asked about, once, when it first speaks.
if !channel_map.contains_key(channel_id) && d["guild_id"].is_null() {
unknown_dm(state, config, channel_id, channel_map).await;
}
let Some((buffer_name, kind)) = channel_map.get(channel_id).cloned() else {
tracing::info!("discord[{account_id}]: a message in {channel_id} was dropped - that channel is not one this connection knows ({} are)", channel_map.len());
continue;
@@ -1511,6 +1722,10 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
}
}
}
// Discord asking for a heartbeat now: answered at once, as its own clients answer.
1 => {
let _ = out_tx.send(heartbeat_message(last_seq.load(Ordering::SeqCst)));
}
7 => return Err(ReconnectRequested.into()),
// The resume was refused, or the session is gone. Everything
// carried across goes with it: the next connection gets a fresh
+10 -2
View File
@@ -391,6 +391,7 @@ pub(super) fn can_view_channel(is_owner: bool, guild_id: &str, roles: &[Value],
/// written by almost nobody - rules, announcements - and showing a message box
/// that every send is refused from is a worse answer than saying so.
#[allow(clippy::too_many_arguments)]
#[cfg(test)]
pub(super) fn can_send_in_channel(is_owner: bool, guild_id: &str, roles: &[Value], member_role_ids: &[String], user_id: &str, channel: &Value) -> bool {
channel_permissions(is_owner, guild_id, roles, member_role_ids, user_id, channel) & PERM_SEND_MESSAGES != 0
}
@@ -420,6 +421,9 @@ pub(super) async fn register_guild_channels(state: &AppState, config: &DiscordAc
// the way the roles-only version could.
let me = own_member(config, guild).await;
let own_roles = member_role_ids(me.as_ref());
// What the server asks of its members, and what the account is to it: see rules.
super::send_guard::note_guild(&config.account_id(), guild, &config.user_id);
super::send_guard::note_member(state, &config.account_id(), guild_id, me.as_ref());
// The account may not have spoken here: see send_guard. Begun before any channel is registered, so they open locked.
super::send_guard::open_guild(state, config, guild);
@@ -577,15 +581,19 @@ pub(super) async fn register_guild_channels(state: &AppState, config: &DiscordAc
// Said on the conversation, so the window can put a banner where the
// message box would be. Re-asked every time the guild is read, since
// permissions change under a channel that is already registered.
let read_only = (!can_send_in_channel(is_owner, guild_id, &roles, &member_role_ids, &config.user_id, ch)).then_some(NO_SEND_PERMISSION);
let permissions = channel_permissions(is_owner, guild_id, &roles, &member_role_ids, &config.user_id, ch);
let read_only = (permissions & PERM_SEND_MESSAGES == 0).then_some(NO_SEND_PERMISSION);
if channel_map.contains_key(channel_id) {
super::send_guard::set_permission(state, &account_id, &crate::model::buffer_id(&account_id, &name), read_only);
let existing = crate::model::buffer_id(&account_id, &name);
super::send_guard::note_channel(state, &existing, ch, permissions, is_owner);
super::send_guard::set_permission(state, &account_id, &existing, read_only);
continue;
}
let buf = state.runtime.ensure_buffer(state, &account_id, &name, "channel");
// The guild is known before the reason is worked out: the lock is the guild's.
state.runtime.set_discord_channel(state, &buf.id, channel_id);
state.runtime.set_discord_guild(&buf.id, guild_id);
super::send_guard::note_channel(state, &buf.id, ch, permissions, is_owner);
super::send_guard::set_permission(state, &account_id, &buf.id, read_only);
state.runtime.set_buffer_group(state, &buf.id, &group_id);
+122
View File
@@ -158,6 +158,69 @@ pub async fn refill_history(state: &AppState, buffer_id: &str, limit: u32) -> Re
Ok(missing.len())
}
/// What has been asked of Discord about each conversation's history this connection, so it is not asked again for what
/// it has already said. A conversation is caught up once in a while, not at every opening of it (its messages arrive
/// live); older history is asked for until its start is reached and never after; a failed ask waits before the next.
#[derive(Default)]
pub struct Asked {
caught_up: HashMap<String, std::time::Instant>,
exhausted: std::collections::HashSet<String>,
failed: HashMap<String, std::time::Instant>,
}
/// How long after a catch-up another is not made for the same conversation.
const CATCH_UP_EVERY: Duration = Duration::from_secs(10 * 60);
/// How long after a failed request for older history the next one waits.
const FAILED_WAIT: Duration = Duration::from_secs(60);
impl Asked {
pub fn catch_up_due(&mut self, buffer: &str, now: std::time::Instant) -> bool {
if self.caught_up.get(buffer).is_some_and(|at| now.duration_since(*at) < CATCH_UP_EVERY) {
return false;
}
self.caught_up.insert(buffer.to_string(), now);
true
}
/// The catch-up did not come to anything: the next opening asks again.
pub fn catch_up_failed(&mut self, buffer: &str) {
self.caught_up.remove(buffer);
}
pub fn older_due(&mut self, buffer: &str, now: std::time::Instant) -> bool {
!self.exhausted.contains(buffer) && !self.failed.get(buffer).is_some_and(|at| now.duration_since(*at) < FAILED_WAIT)
}
/// Asked for `limit` and got `got`: fewer than asked for is the start of the conversation.
pub fn older_answered(&mut self, buffer: &str, got: usize, limit: usize) {
self.failed.remove(buffer);
if got < limit {
self.exhausted.insert(buffer.to_string());
}
}
pub fn older_failed(&mut self, buffer: &str, now: std::time::Instant) {
self.failed.insert(buffer.to_string(), now);
}
/// A new login has missed what the last one did not: everything of the account's is asked about afresh.
pub fn forget_account(&mut self, account: &str) {
let prefix = format!("{account}|");
self.caught_up.retain(|b, _| !b.starts_with(&prefix));
self.failed.retain(|b, _| !b.starts_with(&prefix));
}
}
static ASKED: std::sync::Mutex<Option<Asked>> = std::sync::Mutex::new(None);
fn asked<R>(f: impl FnOnce(&mut Asked) -> R) -> R {
f(ASKED.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Asked::default))
}
pub(super) fn forget_asked(account: &str) {
asked(|a| a.forget_account(account));
}
/// Notes what a channel object says its newest message is: `None` for a
/// conversation with no messages, nothing at all when it does not say.
pub(super) fn note_newest(into: &mut HashMap<String, Option<String>>, channel: &Value) {
@@ -232,8 +295,12 @@ pub async fn catch_up_channel(
buffer_id: &str,
channel_id: &str,
) {
if !asked(|a| a.catch_up_due(buffer_id, std::time::Instant::now())) {
return;
}
let Some(_fetch) = HistoryFetch::begin(state, buffer_id) else {
tracing::info!("discord: catching up {buffer_id} skipped - a fetch for it is already under way");
asked(|a| a.catch_up_failed(buffer_id));
return;
};
let result: Result<()> = async {
@@ -255,16 +322,22 @@ pub async fn catch_up_channel(
store_history_messages(state, buffer_id, &messages, user_id, own_display_name);
} else {
tracing::info!("discord: catching up {buffer_id}: Discord answered {}", resp.status());
asked(|a| a.catch_up_failed(buffer_id));
}
Ok(())
}
.await;
if let Err(e) = result {
tracing::warn!("discord: catching up {buffer_id}: {e}");
asked(|a| a.catch_up_failed(buffer_id));
}
}
pub async fn extend_history(state: &AppState, token: &str, user_id: &str, own_display_name: Option<&str>, buffer_id: &str, channel_id: &str) {
// The start of a conversation is asked for once: after it is reached, nothing is older.
if !asked(|a| a.older_due(buffer_id, std::time::Instant::now())) {
return;
}
let Some(_fetch) = HistoryFetch::begin(state, buffer_id) else { return };
let result: Result<()> = async {
let Some(before_id) = state.store.oldest_msg_id(buffer_id)? else { return Ok(()) };
@@ -277,13 +350,17 @@ pub async fn extend_history(state: &AppState, token: &str, user_id: &str, own_di
.context("fetching more history")?;
if resp.status().is_success() {
let messages: Vec<Value> = resp.json().await.context("parsing more history")?;
asked(|a| a.older_answered(buffer_id, messages.len(), 50));
store_history_messages(state, buffer_id, &messages, user_id, own_display_name);
} else {
asked(|a| a.older_failed(buffer_id, std::time::Instant::now()));
}
Ok(())
}
.await;
if let Err(e) = result {
tracing::warn!("discord: extending history for {buffer_id}: {e}");
asked(|a| a.older_failed(buffer_id, std::time::Instant::now()));
}
}
@@ -385,3 +462,48 @@ mod catch_up_tests {
assert!(!into.contains_key("3"));
}
}
#[cfg(test)]
mod asked_tests {
use super::*;
use std::time::Instant;
#[test]
fn a_conversation_is_caught_up_once_in_a_while_and_again_after_a_failure() {
let mut a = Asked::default();
let t = Instant::now();
assert!(a.catch_up_due("b", t));
assert!(!a.catch_up_due("b", t + Duration::from_secs(60)), "opened again a minute later");
assert!(a.catch_up_due("other", t));
assert!(a.catch_up_due("b", t + Duration::from_secs(601)), "ten minutes on");
a.catch_up_failed("b");
assert!(a.catch_up_due("b", t + Duration::from_secs(602)), "a failed one is asked again");
}
#[test]
fn older_history_is_asked_for_until_the_start_is_reached_and_not_after() {
let mut a = Asked::default();
let t = Instant::now();
assert!(a.older_due("b", t));
a.older_answered("b", 50, 50);
assert!(a.older_due("b", t), "a full page means more");
a.older_answered("b", 12, 50);
assert!(!a.older_due("b", t + Duration::from_secs(9999)), "the start was reached");
assert!(a.older_due("c", t));
a.older_failed("c", t);
assert!(!a.older_due("c", t + Duration::from_secs(30)));
assert!(a.older_due("c", t + Duration::from_secs(61)));
}
#[test]
fn a_new_login_asks_about_its_own_conversations_afresh() {
let mut a = Asked::default();
let t = Instant::now();
a.catch_up_due("discord:1|chat", t);
a.catch_up_due("discord:2|chat", t);
a.forget_account("discord:1");
assert!(a.catch_up_due("discord:1|chat", t));
assert!(!a.catch_up_due("discord:2|chat", t));
}
}
+210 -5
View File
@@ -96,24 +96,175 @@ fn note_bucket(resp: &reqwest::Response) {
}
}
/// What this client has learned about where it is no longer welcome, and so does not ask again: a token Discord has
/// refused (every request stops until the person signs in again - a revoked login that keeps asking is a client
/// hammering a dead door), and a route that keeps answering 403 (three times in five minutes closes it for five, so a
/// channel that cannot be read is not asked about at every opening).
#[derive(Default)]
pub struct Safety {
halted: std::collections::HashSet<String>,
circuits: HashMap<(String, String, String), Circuit>,
}
struct Circuit {
refusals: u8,
last: std::time::Instant,
open_until: Option<std::time::Instant>,
}
/// Why a request was not made.
#[derive(Debug, PartialEq, Eq)]
pub enum Blocked {
/// The token was refused (401): nothing more is sent with it.
Refused,
/// The route was refused (403) repeatedly: not for this long.
Closed(Duration),
}
impl std::fmt::Display for Blocked {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Blocked::Refused => write!(f, "Discord has refused this account's login, so nothing more is sent for it - sign in again from Accounts"),
Blocked::Closed(wait) => write!(f, "Discord has refused this three times; not asking again for {} minutes", wait.as_secs().div_ceil(60).max(1)),
}
}
}
impl std::error::Error for Blocked {}
const CIRCUIT_REFUSALS: u8 = 3;
const CIRCUIT_WINDOW: Duration = Duration::from_secs(5 * 60);
const CIRCUIT_OPEN_FOR: Duration = Duration::from_secs(5 * 60);
/// Routes remembered: they include ids, so a few refusals on many messages must not be kept all run.
const MAX_CIRCUITS: usize = 512;
impl Safety {
pub fn preflight_at(&mut self, token: &str, method: &str, path: &str, now: std::time::Instant) -> Result<(), Blocked> {
if self.halted.contains(token) {
return Err(Blocked::Refused);
}
self.circuits.retain(|_, c| match c.open_until {
Some(until) => until > now,
None => now.saturating_duration_since(c.last) < CIRCUIT_WINDOW,
});
let key = (token.to_string(), method.to_string(), path.to_string());
match self.circuits.get(&key).and_then(|c| c.open_until) {
Some(until) => Err(Blocked::Closed(until.saturating_duration_since(now))),
None => Ok(()),
}
}
pub fn record_at(&mut self, token: &str, method: &str, path: &str, status: u16, now: std::time::Instant) {
let key = (token.to_string(), method.to_string(), path.to_string());
if status == 401 {
self.halted.insert(token.to_string());
return;
}
if status != 403 {
self.circuits.remove(&key);
return;
}
if !self.circuits.contains_key(&key) && self.circuits.len() >= MAX_CIRCUITS {
if let Some(oldest) = self.circuits.iter().min_by_key(|(_, c)| c.last).map(|(k, _)| k.clone()) {
self.circuits.remove(&oldest);
}
}
let circuit = self.circuits.entry(key).or_insert(Circuit { refusals: 0, last: now, open_until: None });
if now.saturating_duration_since(circuit.last) >= CIRCUIT_WINDOW {
circuit.refusals = 0;
circuit.open_until = None;
}
circuit.refusals = circuit.refusals.saturating_add(1);
circuit.last = now;
if circuit.refusals >= CIRCUIT_REFUSALS {
circuit.open_until = Some(now + CIRCUIT_OPEN_FOR);
}
}
pub fn halt(&mut self, token: &str) {
self.halted.insert(token.to_string());
}
pub fn is_halted(&self, token: &str) -> bool {
self.halted.contains(token)
}
}
static SAFETY: std::sync::Mutex<Option<Safety>> = std::sync::Mutex::new(None);
fn safety<R>(f: impl FnOnce(&mut Safety) -> R) -> R {
let mut slot = SAFETY.lock().unwrap_or_else(|e| e.into_inner());
f(slot.get_or_insert_with(Safety::default))
}
/// Nothing more is sent with this token: Discord has said it is no good.
pub(super) fn halt_token(token: &str) {
safety(|s| s.halt(token));
}
pub(super) fn token_is_halted(token: &str) -> bool {
safety(|s| s.is_halted(token))
}
/// Which token, method and path a request is for, where it can be told without sending it.
fn inspect(request: &reqwest::RequestBuilder) -> Option<(String, String, String)> {
let built = request.try_clone()?.build().ok()?;
let token = built.headers().get("authorization")?.to_str().ok()?.to_string();
Some((token, built.method().as_str().to_string(), built.url().path().to_string()))
}
/// Asked before a request is sent, told after: see `Safety`.
fn preflight(who: &Option<(String, String, String)>) -> Result<()> {
if let Some((token, method, path)) = who {
safety(|s| s.preflight_at(token, method, path, std::time::Instant::now())).map_err(anyhow::Error::from)?;
}
Ok(())
}
fn note_status(who: &Option<(String, String, String)>, status: reqwest::StatusCode) {
if let Some((token, method, path)) = who {
if status == reqwest::StatusCode::UNAUTHORIZED {
tracing::warn!("discord: a request was refused as unauthorised: nothing more is sent with this login");
}
safety(|s| s.record_at(token, method, path, status.as_u16(), std::time::Instant::now()));
}
}
/// Whether a 429's body is a channel's slowmode (code 20016): the wait is the person's, and sending again when it is
/// over is not for this client to do on their behalf.
fn is_slowmode(body: &[u8]) -> bool {
serde_json::from_slice::<Value>(body).ok().and_then(|v| v["code"].as_u64()) == Some(20016)
}
/// A response put back together once its body has been read, so a caller that reads it again finds it whole.
fn rebuilt(status: reqwest::StatusCode, headers: reqwest::header::HeaderMap, body: bytes::Bytes) -> reqwest::Response {
let mut response = tokio_tungstenite::tungstenite::http::Response::new(body);
*response.status_mut() = status;
*response.headers_mut() = headers;
reqwest::Response::from(response)
}
/// Sending that behaves as a client of an API with rate limits should: waits out any hold Discord has put on this
/// client, notes how much of a bucket is left and waits for it to refill rather than running into the limit, and when
/// it is told to slow down anyway waits as long as it was told and asks again - instead of reading a refusal as an
/// answer. Reads were sent plain, so a 429 was a silent "nothing found" and the next request followed straight on.
pub(super) trait SendPaced {
fn send_paced(self) -> impl std::future::Future<Output = reqwest::Result<reqwest::Response>> + Send;
fn send_paced(self) -> impl std::future::Future<Output = Result<reqwest::Response>> + Send;
}
impl SendPaced for reqwest::RequestBuilder {
async fn send_paced(self) -> reqwest::Result<reqwest::Response> {
async fn send_paced(self) -> Result<reqwest::Response> {
let who = inspect(&self);
preflight(&who)?;
wait_out_hold().await;
let mut pending = Some(self);
for attempt in 0..=READ_RETRIES {
let Some(current) = pending.take() else { break };
// A body that cannot be copied is sent once.
let again = current.try_clone();
let resp = current.send().await?;
let resp = current.send().await.context("talking to Discord")?;
note_bucket(&resp);
note_status(&who, resp.status());
if resp.status() != reqwest::StatusCode::TOO_MANY_REQUESTS || attempt == READ_RETRIES {
return Ok(resp);
}
@@ -162,6 +313,8 @@ where
/// history quickly would be a slow client, and what Discord judges an account
/// on is what it sends.
pub(super) async fn send_write(request: reqwest::RequestBuilder) -> Result<reqwest::Response> {
let who = inspect(&request);
preflight(&who)?;
wait_out_hold().await;
pace().await;
@@ -173,11 +326,19 @@ pub(super) async fn send_write(request: reqwest::RequestBuilder) -> Result<reqwe
let again = current.try_clone();
let resp = current.send().await.context("talking to Discord")?;
note_bucket(&resp);
note_status(&who, resp.status());
if resp.status() != reqwest::StatusCode::TOO_MANY_REQUESTS || attempt == RATE_LIMIT_RETRIES {
return Ok(resp);
}
let Some(again) = again else { return Ok(resp) };
let pause = retry_after(&resp);
// A channel's slowmode is answered with a 429 too. It is the person's wait, and a message they have not seen
// refused is not one to send again for them when it ends.
let (status, headers) = (resp.status(), resp.headers().clone());
let body = resp.bytes().await.context("reading Discord's answer")?;
if is_slowmode(&body) {
return Ok(rebuilt(status, headers, body));
}
let pause = pause_for(headers.get("retry-after").and_then(|v| v.to_str().ok()));
let Some(again) = again else { return Ok(rebuilt(status, headers, body)) };
tracing::debug!("discord: rate limited, waiting {}ms", pause.as_millis());
hold_for(pause);
wait_out_hold().await;
@@ -208,6 +369,12 @@ pub(super) fn http_client_for(key: &str) -> reqwest::Client {
discord_client(crate::net::route::router().routed(key), true)
}
/// The client for a request that is not for Discord's API but is made on an account's behalf - an upload to the storage
/// address Discord hands out - routed as the account is, and carrying none of the headers that belong to the API.
pub(super) fn bare_client_for(key: &str) -> reqwest::Client {
discord_client(crate::net::route::router().routed(key), false)
}
/// The client for requests that belong to no account yet - the sign-in - or
/// that cannot say whose they are, such as a thumbnail off the CDN. Routed
/// when the add form's switch says so, or when any Discord account is.
@@ -401,4 +568,42 @@ mod error_tests {
let seen = server.await.unwrap();
assert_eq!(seen.len(), 3);
}
#[test]
fn a_refused_login_stops_everything_and_repeated_403s_close_their_route() {
use std::time::Instant;
let mut s = Safety::default();
let t = Instant::now();
let at = |secs: u64| t + Duration::from_secs(secs);
assert!(s.preflight_at("tok", "GET", "/channels/1/messages", t).is_ok());
// Two refusals are not three.
s.record_at("tok", "GET", "/channels/1/messages", 403, at(1));
s.record_at("tok", "GET", "/channels/1/messages", 403, at(2));
assert!(s.preflight_at("tok", "GET", "/channels/1/messages", at(3)).is_ok());
s.record_at("tok", "GET", "/channels/1/messages", 403, at(4));
let closed = s.preflight_at("tok", "GET", "/channels/1/messages", at(5)).unwrap_err();
assert!(matches!(closed, Blocked::Closed(w) if w > Duration::from_secs(290)));
// Other routes, other methods and other accounts are unaffected.
assert!(s.preflight_at("tok", "GET", "/channels/2/messages", at(5)).is_ok());
assert!(s.preflight_at("tok", "POST", "/channels/1/messages", at(5)).is_ok());
assert!(s.preflight_at("other", "GET", "/channels/1/messages", at(5)).is_ok());
// It opens again after the five minutes, and a success clears the count.
assert!(s.preflight_at("tok", "GET", "/channels/1/messages", at(4 + 301)).is_ok());
s.record_at("tok", "GET", "/channels/9/messages", 403, at(10));
s.record_at("tok", "GET", "/channels/9/messages", 200, at(11));
s.record_at("tok", "GET", "/channels/9/messages", 403, at(12));
s.record_at("tok", "GET", "/channels/9/messages", 403, at(13));
assert!(s.preflight_at("tok", "GET", "/channels/9/messages", at(14)).is_ok(), "403, 200, 403, 403 is not three in a row");
// A 401 ends all of it for that token, for good.
s.record_at("tok", "GET", "/users/@me", 401, at(20));
assert_eq!(s.preflight_at("tok", "POST", "/anything", at(21)), Err(Blocked::Refused));
assert!(s.preflight_at("fresh", "POST", "/anything", at(21)).is_ok(), "a new login is a new token");
}
#[test]
fn a_slowmode_answer_is_told_from_any_other_429() {
assert!(is_slowmode(br#"{"message":"The resource is being rate limited.","retry_after":3.5,"code":20016}"#));
assert!(!is_slowmode(br#"{"message":"You are being rate limited.","retry_after":1.2,"global":false}"#));
assert!(!is_slowmode(b"not json"));
}
}
+2
View File
@@ -55,6 +55,8 @@ pub mod golive;
pub mod guilds;
pub mod history;
pub mod install_headers;
pub mod ready_shape;
pub mod rules;
pub mod send_guard;
pub mod session_store;
pub mod http;
+152
View File
@@ -0,0 +1,152 @@
//! READY as a client that asked for DEDUPE_USER_OBJECTS receives it, put back as every reader here expects it.
//!
//! With that capability Discord "dehydrates" READY (<https://docs.discord.food/topics/gateway-events#ready>): every
//! user object is moved into one `users` table and left behind as an id - `user_id` in members, presences and
//! relationships, `recipient_ids` in private channels - each guild's `members` is collapsed into READY's
//! `merged_members` (one list a guild, in the order of `guilds`), and `presences`, with each guild's, becomes
//! `merged_presences`. Everything that reads READY - the friends list, the names of conversations, who is where in a
//! voice channel - was written against the objects. So they are put back first, here, once, and nothing else has to
//! know the shape changed. A READY without a `users` table is already whole and comes back unchanged.
use serde_json::{json, Map, Value};
use std::collections::HashMap;
/// READY with its users put back, or `None` when there is nothing to put back.
pub fn rehydrate(ready: &Value) -> Option<Value> {
let users: HashMap<String, Value> = ready["users"]
.as_array()?
.iter()
.filter_map(|u| Some((u["id"].as_str()?.to_string(), u.clone())))
.collect();
let mut out = ready.clone();
// The members the guilds were relieved of, back where they were: in the guild, by its place in the list.
if let Some(merged) = ready["merged_members"].as_array() {
if let Some(guilds) = out["guilds"].as_array_mut() {
for (guild, members) in guilds.iter_mut().zip(merged) {
if guild["members"].is_null() && members.is_array() {
guild["members"] = members.clone();
}
}
}
}
// And the presences: the friends' as READY's own `presences`, each guild's with the guild.
if let Some(merged) = ready["merged_presences"].as_object() {
if out["presences"].is_null() {
out["presences"] = merged.get("friends").cloned().unwrap_or_else(|| json!([]));
}
if let (Some(per_guild), Some(guilds)) = (merged.get("guilds").and_then(Value::as_array), out["guilds"].as_array_mut()) {
for (guild, presences) in guilds.iter_mut().zip(per_guild) {
if guild["presences"].is_null() && presences.is_array() {
guild["presences"] = presences.clone();
}
}
}
}
restore(&mut out, &users);
Some(out)
}
/// Gives every object that names a user by id the user itself, and every conversation that lists its recipients by id
/// the recipients, wherever in the payload they are.
fn restore(value: &mut Value, users: &HashMap<String, Value>) {
match value {
Value::Object(object) => {
fill(object, users);
for child in object.values_mut() {
restore(child, users);
}
}
Value::Array(list) => {
for child in list {
restore(child, users);
}
}
_ => {}
}
}
fn fill(object: &mut Map<String, Value>, users: &HashMap<String, Value>) {
if !object.contains_key("user") {
if let Some(user) = object.get("user_id").and_then(Value::as_str).and_then(|id| users.get(id)) {
object.insert("user".to_string(), user.clone());
}
}
if !object.contains_key("recipients") {
if let Some(ids) = object.get("recipient_ids").and_then(Value::as_array) {
let recipients: Vec<Value> = ids.iter().filter_map(|id| users.get(id.as_str()?).cloned()).collect();
object.insert("recipients".to_string(), Value::Array(recipients));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// READY as the documentation describes it for a client with DEDUPE_USER_OBJECTS: users in a table, ids in their
/// place, members and presences merged.
fn dehydrated() -> Value {
json!({
"user": { "id": "1", "username": "me" },
"users": [
{ "id": "1", "username": "me" },
{ "id": "2", "username": "ada", "global_name": "Ada" },
{ "id": "3", "username": "bob" }
],
"relationships": [{ "id": "2", "type": 1, "user_id": "2" }, { "id": "3", "type": 3, "user_id": "3" }],
"private_channels": [{ "id": "20", "type": 1, "recipient_ids": ["2"], "last_message_id": null }, { "id": "21", "type": 3, "recipient_ids": ["2", "3"] }],
"merged_presences": { "friends": [{ "user_id": "2", "status": "online", "activities": [] }], "guilds": [[{ "user_id": "3", "status": "idle" }]] },
"guilds": [{ "id": "g", "voice_states": [{ "user_id": "3", "channel_id": "v" }] }],
"merged_members": [[{ "user_id": "1", "roles": ["r"], "pending": false }, { "user_id": "3", "roles": [] }]]
})
}
#[test]
fn a_dehydrated_ready_is_made_whole_again() {
let whole = rehydrate(&dehydrated()).expect("there was a users table");
// Friends and their requests, by the user object every reader looks for.
assert_eq!(whole["relationships"][0]["user"]["username"], "ada");
assert_eq!(whole["relationships"][1]["user"]["id"], "3");
// Conversations with the people in them.
assert_eq!(whole["private_channels"][0]["recipients"][0]["global_name"], "Ada");
assert_eq!(whole["private_channels"][1]["recipients"].as_array().unwrap().len(), 2);
// Presences where READY used to carry them.
assert_eq!(whole["presences"][0]["user"]["id"], "2");
assert_eq!(whole["presences"][0]["status"], "online");
assert_eq!(whole["guilds"][0]["presences"][0]["user"]["username"], "bob");
// The guild's members, and our own among them as `own_member` looks for it.
let members = whole["guilds"][0]["members"].as_array().unwrap();
assert_eq!(members.len(), 2);
assert_eq!(members[0]["user"]["id"], "1");
assert_eq!(members[0]["roles"][0], "r");
// Voice states, which name their user the same way.
assert_eq!(whole["guilds"][0]["voice_states"][0]["user"]["username"], "bob");
}
#[test]
fn a_ready_that_was_never_dehydrated_is_left_as_it_was() {
let whole = json!({ "user": { "id": "1" }, "relationships": [{ "id": "2", "user": { "id": "2" } }] });
assert!(rehydrate(&whole).is_none());
}
#[test]
fn what_is_already_there_is_not_overwritten_and_the_unknown_is_left_alone() {
let mut ready = dehydrated();
ready["relationships"][0]["user"] = json!({ "id": "2", "username": "already" });
ready["private_channels"][0]["recipient_ids"] = json!(["2", "999"]);
ready["guilds"][0]["members"] = json!([{ "user": { "id": "1" } }]);
let whole = rehydrate(&ready).unwrap();
assert_eq!(whole["relationships"][0]["user"]["username"], "already");
assert_eq!(whole["private_channels"][0]["recipients"].as_array().unwrap().len(), 1, "an id nobody told us about is not made up");
assert_eq!(whole["guilds"][0]["members"].as_array().unwrap().len(), 1, "a guild's own members are not replaced");
}
#[test]
fn the_whole_thing_can_be_done_twice() {
let once = rehydrate(&dehydrated()).unwrap();
let twice = rehydrate(&once).unwrap();
assert_eq!(once, twice);
}
}
+329
View File
@@ -0,0 +1,329 @@
//! What Discord's own rules say a message needs before it is worth sending - checked here, from what login and the
//! guild already told us, so a message that is bound to be turned down is not sent at all. A refused send is one more
//! request from an account that is being watched, and says nothing that a sentence cannot say sooner.
//!
//! The server rules are Discord's documented ones (and Concord's reading of them, `discord/verification.rs`): a
//! server's verification level asks for a verified email (low), an account older than five minutes (medium), a member
//! of ten minutes' standing (high) or a verified phone (very high); membership screening and onboarding must be done;
//! and a member who is timed out may not speak. The owner, anybody with every permission, anybody with a role (for
//! the levels) and anybody the server exempts (`BYPASSES_VERIFICATION`) are not held to the levels.
use std::time::{Duration, Instant};
/// Member flags (the `flags` of a guild member).
const FLAG_COMPLETED_ONBOARDING: u64 = 1 << 1;
const FLAG_BYPASSES_VERIFICATION: u64 = 1 << 2;
/// How long a member of a server wears Discord's green "new member" badge by their name: the first week.
pub const NEW_MEMBER_SECS: i64 = 7 * 24 * 60 * 60;
const ACCOUNT_AGE_SECS: i64 = 5 * 60;
const MEMBER_AGE_SECS: i64 = 10 * 60;
/// What a server asks of everyone, as its payload says.
#[derive(Debug, Clone, Default)]
pub struct GuildFacts {
/// 0 none, 1 low, 2 medium, 3 high, 4 very high.
pub verification: u8,
pub community: bool,
pub owner: bool,
}
/// This account's membership of it.
#[derive(Debug, Clone, Default)]
pub struct MemberFacts {
pub pending: Option<bool>,
pub flags: Option<u64>,
pub has_roles: bool,
/// When it joined, Unix seconds.
pub joined_at: Option<i64>,
/// Until when it may not speak (a timeout), Unix seconds.
pub timed_out_until: Option<i64>,
}
/// And the account itself.
#[derive(Debug, Clone, Default)]
pub struct AccountFacts {
pub email_verified: Option<bool>,
pub phone_verified: Option<bool>,
/// When it was made, Unix seconds (from its id).
pub created_at: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Restriction {
Screening,
Onboarding,
EmailRequired,
PhoneRequired,
AccountTooNew(i64),
MemberTooNew(i64),
TimedOut(i64),
/// Wearing the green new-member badge: for as many more seconds.
NewMember(i64),
}
impl Restriction {
/// The sentence a refused send gives.
pub fn sentence(&self) -> String {
match self {
Restriction::Screening => "This server asks you to accept its rules first. Do that from the notice above the conversation, or in Discord's own app.".into(),
Restriction::Onboarding => "This server asks you to finish its onboarding first - do that in Discord's own app.".into(),
Restriction::EmailRequired => "This server needs a verified email on your Discord account before you can talk here.".into(),
Restriction::PhoneRequired => "This server needs a verified phone number on your Discord account before you can talk here.".into(),
Restriction::AccountTooNew(s) => format!("This server asks for an account more than five minutes old: {}.", wait(*s)),
Restriction::MemberTooNew(s) => format!("This server asks you to have been a member for ten minutes before you talk: {}.", wait(*s)),
Restriction::TimedOut(s) => format!("You are timed out in this server: {}.", wait(*s)),
Restriction::NewMember(s) => format!(
"You joined this server recently, so Discord shows the green new-member badge by your name here, for about {} more. Messages from another client while it shows are treated as spam: use Discord's own app until it is gone.",
long_wait(*s)
),
}
}
/// Whether it is the kind that stays until the person does something, and so is worth a lock over the box. The
/// others pass by themselves, and are said when a send is tried. (Screening has a notice of its own.)
pub fn banner(&self) -> Option<&'static str> {
match self {
Restriction::Onboarding => Some("This server asks you to finish its onboarding first - do that in Discord's own app."),
Restriction::EmailRequired => Some("This server needs a verified email on your Discord account before you can talk here."),
Restriction::PhoneRequired => Some("This server needs a verified phone number on your Discord account before you can talk here."),
Restriction::NewMember(_) => Some("You are a new member of this server: Discord shows a green badge by your name for the first week, and treats messages from another client as spam while it shows. Use Discord's own app until it is gone; this opens again then."),
_ => None,
}
}
}
/// A wait counted in days or hours, for one that is long.
fn long_wait(seconds: i64) -> String {
let hours = (seconds.max(0) + 3599) / 3600;
if hours >= 48 {
format!("{} days", (hours + 23) / 24)
} else if hours > 1 {
format!("{hours} hours")
} else {
"an hour".to_string()
}
}
fn wait(seconds: i64) -> String {
if seconds >= 120 {
format!("wait {} more minutes", (seconds + 59) / 60)
} else {
format!("wait {} more seconds", seconds.max(1))
}
}
/// Whether the account may speak in a server now, and if not why. `full` is every permission (the owner, an
/// administrator); `onboarding_enabled` is what the server's onboarding says of itself, where it was asked.
pub fn participation(
guild: &GuildFacts,
member: Option<&MemberFacts>,
account: &AccountFacts,
full: bool,
onboarding_enabled: Option<bool>,
now: i64,
) -> Option<Restriction> {
if guild.owner {
return None;
}
// Not known is not blocked: a check that stops everything for want of data is a lock nobody can open.
let member = member?;
if let Some(until) = member.timed_out_until.filter(|u| *u > now) {
return Some(Restriction::TimedOut(until - now));
}
if member.pending == Some(true) {
return Some(Restriction::Screening);
}
if full {
return None;
}
let flags = member.flags.unwrap_or(0);
if flags & FLAG_BYPASSES_VERIFICATION != 0 {
return None;
}
if onboarding_enabled == Some(true) && flags & FLAG_COMPLETED_ONBOARDING == 0 {
return Some(Restriction::Onboarding);
}
if guild.verification == 0 || member.has_roles || account.phone_verified == Some(true) {
return None;
}
if guild.verification >= 4 {
return (account.phone_verified == Some(false)).then_some(Restriction::PhoneRequired);
}
if account.email_verified == Some(false) {
return Some(Restriction::EmailRequired);
}
if guild.verification >= 2 {
let age = now - account.created_at;
if account.created_at > 0 && age < ACCOUNT_AGE_SECS {
return Some(Restriction::AccountTooNew(ACCOUNT_AGE_SECS - age));
}
}
if guild.verification >= 3 {
if let Some(joined) = member.joined_at {
let age = now - joined;
if age < MEMBER_AGE_SECS {
return Some(Restriction::MemberTooNew(MEMBER_AGE_SECS - age));
}
}
}
None
}
/// Whether the account still wears the green new-member badge: it joined the server in the last week. Discord treats
/// what a person says from another client in that time as spam, so it is a reason not to speak from here, whatever
/// else the server allows. The owner has no such badge; `joined` is when the account joined if the member does not say.
pub fn new_member(guild: &GuildFacts, member: Option<&MemberFacts>, joined: Option<i64>, now: i64) -> Option<Restriction> {
if guild.owner {
return None;
}
let joined = member.and_then(|m| m.joined_at).or(joined)?;
let age = now - joined;
// A join time in the future is a clock that is wrong, and not a reason to lock anything.
(0..NEW_MEMBER_SECS).contains(&age).then_some(Restriction::NewMember(NEW_MEMBER_SECS - age))
}
/// How long until a channel's slowmode lets the account speak again: `seconds` is the channel's setting, `last` the
/// account's last message there, `bypass` the permissions that are not held to it (manage messages, manage channel).
pub fn slowmode_remaining(seconds: u64, last: Option<Instant>, bypass: bool, now: Instant) -> Option<Duration> {
if seconds == 0 || bypass {
return None;
}
let ends = last? + Duration::from_secs(seconds);
(ends > now).then(|| ends - now)
}
/// Own messages (not sent from here) a direct message needs before it counts as a conversation that is begun.
pub const ESTABLISHED_DM_MESSAGES: usize = 5;
#[cfg(test)]
mod tests {
use super::*;
fn guild(level: u8) -> GuildFacts {
GuildFacts { verification: level, community: false, owner: false }
}
fn account() -> AccountFacts {
AccountFacts { email_verified: Some(true), phone_verified: Some(false), created_at: 1_000_000 }
}
const NOW: i64 = 2_000_000;
#[test]
fn an_open_server_asks_nothing_of_a_member_in_good_standing() {
let member = MemberFacts { pending: Some(false), flags: Some(0), ..Default::default() };
assert_eq!(participation(&guild(0), Some(&member), &account(), false, None, NOW), None);
}
#[test]
fn each_level_asks_for_what_it_says() {
let member = MemberFacts { pending: Some(false), flags: Some(0), joined_at: Some(NOW - 60), ..Default::default() };
let mut a = account();
a.email_verified = Some(false);
assert_eq!(participation(&guild(1), Some(&member), &a, false, None, NOW), Some(Restriction::EmailRequired));
a.email_verified = Some(true);
assert_eq!(participation(&guild(1), Some(&member), &a, false, None, NOW), None);
// Medium: the account must be over five minutes old.
a.created_at = NOW - 100;
assert_eq!(participation(&guild(2), Some(&member), &a, false, None, NOW), Some(Restriction::AccountTooNew(200)));
a.created_at = 1_000_000;
assert_eq!(participation(&guild(2), Some(&member), &a, false, None, NOW), None);
// High: and the membership over ten minutes.
assert_eq!(participation(&guild(3), Some(&member), &a, false, None, NOW), Some(Restriction::MemberTooNew(540)));
let old = MemberFacts { joined_at: Some(NOW - 700), ..member.clone() };
assert_eq!(participation(&guild(3), Some(&old), &a, false, None, NOW), None);
// Very high: a phone, and nothing else stands in for it but a role or an exemption.
assert_eq!(participation(&guild(4), Some(&old), &a, false, None, NOW), Some(Restriction::PhoneRequired));
a.phone_verified = Some(true);
assert_eq!(participation(&guild(4), Some(&old), &a, false, None, NOW), None);
}
#[test]
fn the_owner_a_role_and_an_exemption_are_not_held_to_the_levels() {
let mut a = account();
a.phone_verified = Some(false);
let plain = MemberFacts { pending: Some(false), flags: Some(0), joined_at: Some(NOW - 5), ..Default::default() };
assert!(participation(&guild(4), Some(&plain), &a, false, None, NOW).is_some());
let owner = GuildFacts { owner: true, ..guild(4) };
assert_eq!(participation(&owner, Some(&plain), &a, false, None, NOW), None);
let roled = MemberFacts { has_roles: true, ..plain.clone() };
assert_eq!(participation(&guild(4), Some(&roled), &a, false, None, NOW), None);
let exempt = MemberFacts { flags: Some(1 << 2), ..plain.clone() };
assert_eq!(participation(&guild(4), Some(&exempt), &a, false, None, NOW), None);
assert_eq!(participation(&guild(4), Some(&plain), &a, true, None, NOW), None, "every permission");
}
#[test]
fn screening_onboarding_and_a_timeout_come_before_the_levels() {
let a = account();
let pending = MemberFacts { pending: Some(true), ..Default::default() };
assert_eq!(participation(&guild(0), Some(&pending), &a, false, None, NOW), Some(Restriction::Screening));
let member = MemberFacts { pending: Some(false), flags: Some(0), ..Default::default() };
assert_eq!(participation(&guild(0), Some(&member), &a, false, Some(true), NOW), Some(Restriction::Onboarding));
assert_eq!(participation(&guild(0), Some(&member), &a, false, Some(false), NOW), None);
let done = MemberFacts { flags: Some(1 << 1), ..member.clone() };
assert_eq!(participation(&guild(0), Some(&done), &a, false, Some(true), NOW), None);
let timed = MemberFacts { timed_out_until: Some(NOW + 300), ..member };
assert_eq!(participation(&guild(0), Some(&timed), &a, true, None, NOW), Some(Restriction::TimedOut(300)), "even with every permission");
let over = MemberFacts { timed_out_until: Some(NOW - 5), ..timed };
assert_eq!(participation(&guild(0), Some(&over), &a, false, None, NOW), None);
}
#[test]
fn what_is_not_known_is_not_a_block() {
assert_eq!(participation(&guild(4), None, &account(), false, None, NOW), None);
let unknown = AccountFacts { email_verified: None, phone_verified: None, created_at: 0 };
let member = MemberFacts { pending: Some(false), flags: Some(0), joined_at: Some(NOW - 5), ..Default::default() };
assert_eq!(participation(&guild(4), Some(&member), &unknown, false, None, NOW), None);
assert_eq!(participation(&guild(1), Some(&member), &unknown, false, None, NOW), None);
}
#[test]
fn a_member_of_under_a_week_wears_the_badge_until_the_week_is_up() {
let day = 24 * 60 * 60;
let member = |ago: i64| MemberFacts { joined_at: Some(NOW - ago), ..Default::default() };
assert_eq!(new_member(&guild(0), Some(&member(60)), None, NOW), Some(Restriction::NewMember(7 * day - 60)));
assert_eq!(new_member(&guild(0), Some(&member(6 * day)), None, NOW), Some(Restriction::NewMember(day)));
assert_eq!(new_member(&guild(0), Some(&member(7 * day)), None, NOW), None, "gone at a week");
assert_eq!(new_member(&guild(0), Some(&member(90 * day)), None, NOW), None);
}
#[test]
fn the_badge_is_not_worn_by_the_owner_or_by_a_member_nothing_is_known_of() {
let fresh = MemberFacts { joined_at: Some(NOW - 5), ..Default::default() };
assert_eq!(new_member(&GuildFacts { owner: true, ..guild(0) }, Some(&fresh), None, NOW), None);
assert_eq!(new_member(&guild(0), None, None, NOW), None, "not known is not blocked");
assert_eq!(new_member(&guild(0), Some(&MemberFacts::default()), None, NOW), None);
// The server's own join time stands in for a member object that does not carry one.
assert!(new_member(&guild(0), Some(&MemberFacts::default()), Some(NOW - 5), NOW).is_some());
assert_eq!(new_member(&guild(0), Some(&MemberFacts { joined_at: Some(NOW + 500), ..Default::default() }), None, NOW), None, "a join in the future is a wrong clock");
}
#[test]
fn the_badge_says_how_long_in_days_and_is_a_lock_over_the_box() {
let day = 24 * 60 * 60;
assert!(Restriction::NewMember(3 * day).sentence().contains("3 days"));
assert!(Restriction::NewMember(5 * 3600).sentence().contains("5 hours"));
assert!(Restriction::NewMember(600).sentence().contains("an hour"));
assert!(Restriction::NewMember(day).banner().is_some(), "it lasts days, so it is a lock and not a thing said when a send is tried");
}
#[test]
fn slowmode_lets_the_account_speak_when_it_is_over() {
let t = Instant::now();
let s = Duration::from_secs;
assert_eq!(slowmode_remaining(0, Some(t), false, t), None);
assert_eq!(slowmode_remaining(10, None, false, t), None, "nothing said yet");
assert_eq!(slowmode_remaining(10, Some(t), false, t + s(3)), Some(s(7)));
assert_eq!(slowmode_remaining(10, Some(t), false, t + s(10)), None);
assert_eq!(slowmode_remaining(10, Some(t), true, t + s(3)), None, "manage messages is not held to it");
}
#[test]
fn the_sentences_say_how_long_to_wait() {
assert!(Restriction::AccountTooNew(200).sentence().contains("4 more minutes"));
assert!(Restriction::MemberTooNew(30).sentence().contains("30 more seconds"));
assert!(Restriction::TimedOut(1).sentence().contains("1 more seconds") || Restriction::TimedOut(1).sentence().contains("wait"));
assert!(Restriction::Onboarding.banner().is_some() && Restriction::AccountTooNew(5).banner().is_none());
}
}
+322 -45
View File
@@ -35,6 +35,33 @@ pub(super) fn put_reply(payload: &mut Value, reply_to_id: Option<&str>, ping_aut
}
}
/// Says where the message answered is, as Discord's clients do: the channel, and the server where there is one. The id
/// alone is enough for the API, and is not what its own clients send.
pub(super) fn put_reply_place(payload: &mut Value, channel_id: &str, guild_id: Option<&str>) {
if payload["message_reference"].is_object() {
payload["message_reference"]["channel_id"] = Value::from(channel_id);
if let Some(guild) = guild_id {
payload["message_reference"]["guild_id"] = Value::from(guild);
}
}
}
/// Said when Discord refuses a message that creates one: a captcha closes the place for a while (moho does not answer
/// those, and trying again is what escalates), a channel's slowmode says how long is left, anything else is as
/// `refusal_text` has it.
pub(super) fn refused(state: &AppState, buffer_id: &str, status: reqwest::StatusCode, text: &str) -> String {
let parsed: Value = serde_json::from_str(text).unwrap_or(Value::Null);
if parsed.get("captcha_key").is_some() {
super::send_guard::hold_captcha(state, buffer_id);
return "Discord asked for a captcha before this message. moho does not answer Discord's captchas - trying again is what gets an account blocked - so this place is closed to it for a while: say something here from Discord's own app.".to_string();
}
if parsed["code"].as_u64() == Some(20016) {
let seconds = parsed["retry_after"].as_f64().unwrap_or(0.0).ceil() as u64;
return format!("This channel has slowmode on: wait {} more seconds.", seconds.max(1));
}
refusal_text(status, text)
}
/// REST message send - Discord's gateway is receive-only from the client's
/// perspective for user accounts; sending is always a plain HTTP POST.
/// Turns the names somebody typed into the mentions Discord understands.
@@ -220,7 +247,8 @@ pub async fn forward_message(
.runtime
.get_discord_channel(to_buffer)
.ok_or_else(|| anyhow!("there is no Discord conversation to send it to"))?;
super::send_guard::ensure_may_speak(state, to_buffer)?;
super::send_guard::ensure_may_speak(state, to_buffer).await?;
let _turn = super::send_guard::channel_turn(&target_channel).await;
let mut reference = json!({
// 1 is Discord's own number for a forward, as against 0 for a reply.
"type": 1,
@@ -247,8 +275,9 @@ pub async fn forward_message(
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
bail!("{}", discord_error_text(status, &text, "forwarding that message"));
bail!("{}", refused(state, to_buffer, status, &text));
}
super::send_guard::record_own_send(to_buffer);
super::send_guard::note_sent(resp).await;
Ok(())
}
@@ -259,13 +288,16 @@ pub async fn send_message(state: &AppState, buffer_id: &str, token: &str, body:
.get_discord_channel(buffer_id)
.ok_or_else(|| anyhow!("no known Discord channel for this buffer"))?;
// Nothing is said anywhere the account has not spoken before: see send_guard.
super::send_guard::ensure_may_speak(state, buffer_id)?;
super::send_guard::ensure_may_speak(state, buffer_id).await?;
let account_id = state.runtime.get_buffer(buffer_id).map(|b| b.account_id).unwrap_or_default();
let content = resolve_outgoing_mentions(body, mention_candidates(state, &account_id, buffer_id));
super::send_guard::check_text(&content, state.runtime.discord_premium(&account_id), false)?;
let mut payload = json!({ "content": content });
put_reply(&mut payload, reply_to_id, reply_ping);
put_reply_place(&mut payload, &channel_id, state.runtime.get_discord_guild(buffer_id).as_deref());
super::send_guard::stamp(&mut payload);
// One send at a time to a channel, and each a moment after the last.
let _turn = super::send_guard::channel_turn(&channel_id).await;
super::send_guard::take_turn(&account_id, false).await;
let resp = send_write(
http_client_for(token)
@@ -278,8 +310,9 @@ pub async fn send_message(state: &AppState, buffer_id: &str, token: &str, body:
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
bail!("{}", refusal_text(status, &text));
bail!("{}", refused(state, buffer_id, status, &text));
}
super::send_guard::record_own_send(buffer_id);
super::send_guard::note_sent(resp).await;
Ok(())
}
@@ -383,7 +416,8 @@ pub async fn send_attachments(
.runtime
.get_discord_channel(buffer_id)
.ok_or_else(|| anyhow!("no known Discord channel for this buffer"))?;
super::send_guard::ensure_may_speak(state, buffer_id)?;
super::send_guard::ensure_may_speak(state, buffer_id).await?;
let _turn = super::send_guard::channel_turn(&channel_id).await;
if let Some(p) = progress {
p.at(Phase::Preparing, 0, Meter::HOST);
}
@@ -407,6 +441,7 @@ pub async fn send_attachments(
super::send_guard::check_text(&content, state.runtime.discord_premium(&account_id), true)?;
let mut payload = json!({ "content": content });
put_reply(&mut payload, reply_to_id, reply_ping);
put_reply_place(&mut payload, &channel_id, state.runtime.get_discord_guild(buffer_id).as_deref());
super::send_guard::stamp(&mut payload);
// A file does not follow straight on from a message.
super::send_guard::take_turn(&account_id, true).await;
@@ -414,35 +449,180 @@ pub async fn send_attachments(
p.sent(0, total, Meter::HOST);
}
let client = http_client_for(token);
let url = format!("{API_BASE}/channels/{channel_id}/messages");
let resp = send_write_rebuilt(|| {
// Built afresh for every try: a streamed body is used up by sending
// it, so a rate-limited request has to open its files again.
let meter = std::sync::Arc::new(Meter {
sent: std::sync::atomic::AtomicU64::new(0),
total,
last: std::sync::Mutex::new((std::time::Instant::now(), 0)),
progress: progress.cloned(),
gap: Meter::GAP,
});
let mut form = reqwest::multipart::Form::new().text("payload_json", payload.to_string());
for (i, (path, name, len)) in files.iter().enumerate() {
form = form.part(format!("files[{i}]"), counted_part(path, name, *len, meter.clone())?);
let meter = std::sync::Arc::new(Meter {
sent: std::sync::atomic::AtomicU64::new(0),
total,
last: std::sync::Mutex::new((std::time::Instant::now(), 0)),
progress: progress.cloned(),
gap: Meter::GAP,
});
let uploads: Vec<Upload> = files.iter().map(|(path, name, len)| Upload { path: path.clone(), name: name.clone(), len: *len, voice: None }).collect();
let resp = match upload_in_steps(state, token, &channel_id, &uploads, &mut payload, &meter).await? {
Some(resp) => resp,
// Where the way Discord's own apps send files is not there, the one bots use is.
None => {
let client = http_client_for(token);
let url = format!("{API_BASE}/channels/{channel_id}/messages");
for key in ["attachments", "type", "sticker_ids", "channel_id"] {
if let Some(object) = payload.as_object_mut() {
object.remove(key);
}
}
send_write_rebuilt(|| {
// Built afresh for every try: a streamed body is used up by sending it, so a rate-limited request has
// to open its files again.
let meter = std::sync::Arc::new(Meter {
sent: std::sync::atomic::AtomicU64::new(0),
total,
last: std::sync::Mutex::new((std::time::Instant::now(), 0)),
progress: progress.cloned(),
gap: Meter::GAP,
});
let mut form = reqwest::multipart::Form::new().text("payload_json", payload.to_string());
for (i, (path, name, len)) in files.iter().enumerate() {
form = form.part(format!("files[{i}]"), counted_part(path, name, *len, meter.clone())?);
}
Ok(client.post(&url).header("Authorization", token).multipart(form))
})
.await
.context("uploading Discord attachment")?
}
Ok(client.post(&url).header("Authorization", token).multipart(form))
})
.await
.context("uploading Discord attachment")?;
};
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
bail!("{}", refusal_text(status, &text));
bail!("{}", refused(state, buffer_id, status, &text));
}
super::send_guard::record_own_send(buffer_id);
super::send_guard::note_sent(resp).await;
Ok(())
}
/// A file on its way, and what it is when it is a voice message.
struct Upload {
path: String,
name: String,
len: u64,
/// A voice message's duration and waveform.
voice: Option<(f64, String)>,
}
/// What Discord answered to a request for somewhere to put the files: for each, by its place in the request, where to
/// send its bytes and the name to give the message. None when the answer is not that.
fn parse_slots(body: &Value, count: usize) -> Option<Vec<(String, String)>> {
let list = body["attachments"].as_array()?;
let mut slots: Vec<Option<(String, String)>> = vec![None; count];
for slot in list {
let id = slot["id"].as_u64().or_else(|| slot["id"].as_str().and_then(|s| s.parse().ok()))? as usize;
let url = slot["upload_url"].as_str().filter(|u| u.starts_with("https://"))?;
let name = slot["upload_filename"].as_str().filter(|n| !n.is_empty())?;
*slots.get_mut(id)? = Some((url.to_string(), name.to_string()));
}
slots.into_iter().collect()
}
/// The `attachments` a message names once its files are uploaded.
fn attachments_named(uploads: &[Upload], slots: &[(String, String)]) -> Value {
Value::Array(
uploads
.iter()
.zip(slots)
.enumerate()
.map(|(i, (upload, (_, uploaded)))| {
let mut entry = json!({ "id": i.to_string(), "filename": upload.name, "uploaded_filename": uploaded });
if let Some((duration, waveform)) = &upload.voice {
entry["duration_secs"] = json!(duration);
entry["waveform"] = json!(waveform);
}
entry
})
.collect(),
)
}
/// The body of an upload: the file, read from disk as it goes, counted by `meter`.
fn counted_body(path: &str, meter: std::sync::Arc<Meter>) -> Result<reqwest::Body> {
let file = std::fs::File::open(path).with_context(|| format!("reading {path}"))?;
let stream = tokio_util::io::ReaderStream::with_capacity(tokio::fs::File::from_std(file), 64 * 1024);
let counted = futures::StreamExt::map(stream, move |chunk| {
if let Ok(bytes) = &chunk {
meter.add(bytes.len());
}
chunk
});
Ok(reqwest::Body::wrap_stream(counted))
}
/// Sends files the way Discord's own apps do: ask where to put them (a file too big is refused here, before a byte
/// goes), put each there, then send the message with the files named - not the way bots do, with the bytes inside the
/// message. Returns the answer to the message, or `None` when Discord has no such way to offer (the endpoint is
/// missing, or answers something that is not slots) and the old one is to be used.
async fn upload_in_steps(
state: &AppState,
token: &str,
channel_id: &str,
uploads: &[Upload],
payload: &mut Value,
meter: &std::sync::Arc<Meter>,
) -> Result<Option<reqwest::Response>> {
let _ = state;
let request = json!({
"files": uploads.iter().enumerate().map(|(i, u)| json!({ "filename": u.name, "file_size": u.len, "id": i.to_string() })).collect::<Vec<_>>(),
});
let resp = send_write(
http_client_for(token).post(format!("{API_BASE}/channels/{channel_id}/attachments")).header("Authorization", token).json(&request),
)
.await
.context("asking Discord where to put the files")?;
let status = resp.status();
if matches!(status.as_u16(), 404 | 405 | 501) {
return Ok(None);
}
if status.as_u16() == 413 {
bail!("Discord says a file is too big to send here");
}
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
bail!("{}", discord_error_text(status, &text, "asking where to put the files"));
}
let body: Value = resp.json().await.context("reading where Discord said to put the files")?;
let Some(slots) = parse_slots(&body, uploads.len()) else { return Ok(None) };
// The bytes go to the address Discord gave, which is another host: none of Discord's headers go with them.
let storage = bare_client_for(token);
for (upload, (url, _)) in uploads.iter().zip(&slots) {
let put = storage
.put(url)
.header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
.header(reqwest::header::CONTENT_LENGTH, upload.len)
.body(counted_body(&upload.path, meter.clone())?)
.send()
.await
.with_context(|| format!("uploading {}", upload.name))?;
if !put.status().is_success() {
bail!("Discord's storage refused {} ({})", upload.name, put.status());
}
}
payload["attachments"] = attachments_named(uploads, &slots);
payload["type"] = json!(0);
payload["sticker_ids"] = json!([]);
payload["channel_id"] = json!(channel_id);
// A message that names its files is not sent as a bare one.
if let Some(object) = payload.as_object_mut() {
object.remove("tts");
if object.get("flags") == Some(&json!(0)) {
object.remove("flags");
}
}
let sent = send_write(
http_client_for(token).post(format!("{API_BASE}/channels/{channel_id}/messages")).header("Authorization", token).json(&*payload),
)
.await
.context("sending the message with the files")?;
Ok(Some(sent))
}
/// Sending a recording as a voice message rather than as a sound file.
///
/// Three things make Discord treat it as one, and all three are required: the
@@ -472,7 +652,7 @@ pub async fn send_voice_message(
.runtime
.get_discord_channel(buffer_id)
.ok_or_else(|| anyhow!("no known Discord channel for this buffer"))?;
super::send_guard::ensure_may_speak(state, buffer_id)?;
super::send_guard::ensure_may_speak(state, buffer_id).await?;
let bytes = tokio::fs::read(file_path)
.await
.with_context(|| format!("reading {}", file_path.display()))?;
@@ -491,28 +671,53 @@ pub async fn send_voice_message(
}],
});
super::send_guard::stamp(&mut payload);
let form = reqwest::multipart::Form::new()
.text("payload_json", payload.to_string())
.part(
"files[0]",
reqwest::multipart::Part::bytes(bytes)
.file_name(file_name)
.mime_str("audio/ogg")
.context("audio/ogg is a valid mime type")?,
);
let resp = send_write(
http_client_for(token)
.post(format!("{API_BASE}/channels/{channel_id}/messages"))
.header("Authorization", token)
.multipart(form),
)
.await
.context("uploading the voice message")?;
let _turn = super::send_guard::channel_turn(&channel_id).await;
let account_id = state.runtime.get_buffer(buffer_id).map(|b| b.account_id).unwrap_or_default();
super::send_guard::take_turn(&account_id, true).await;
// Sent the way Discord's own apps send a recording, with the old way (the bytes inside the message) where that is
// not there.
let meter = std::sync::Arc::new(Meter {
sent: std::sync::atomic::AtomicU64::new(0),
total: bytes.len() as u64,
last: std::sync::Mutex::new((std::time::Instant::now(), 0)),
progress: None,
gap: Meter::GAP,
});
let upload = Upload { path: file_path.to_string_lossy().into_owned(), name: file_name.to_string(), len: bytes.len() as u64, voice: Some((duration_secs, waveform.to_string())) };
let resp = match upload_in_steps(state, token, &channel_id, std::slice::from_ref(&upload), &mut payload, &meter).await? {
Some(resp) => resp,
None => {
for key in ["type", "sticker_ids", "channel_id"] {
if let Some(object) = payload.as_object_mut() {
object.remove(key);
}
}
payload["attachments"] = json!([{ "id": "0", "filename": file_name, "duration_secs": duration_secs, "waveform": waveform }]);
let form = reqwest::multipart::Form::new()
.text("payload_json", payload.to_string())
.part(
"files[0]",
reqwest::multipart::Part::bytes(bytes)
.file_name(file_name)
.mime_str("audio/ogg")
.context("audio/ogg is a valid mime type")?,
);
send_write(
http_client_for(token)
.post(format!("{API_BASE}/channels/{channel_id}/messages"))
.header("Authorization", token)
.multipart(form),
)
.await
.context("uploading the voice message")?
}
};
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
bail!("{}", refusal_text(status, &text));
bail!("{}", refused(state, buffer_id, status, &text));
}
super::send_guard::record_own_send(buffer_id);
super::send_guard::note_sent(resp).await;
Ok(())
}
@@ -884,7 +1089,7 @@ pub async fn ack_read(state: &AppState, buffer_id: &str, token: &str) -> Result<
http_client_for(token)
.post(format!("{API_BASE}/channels/{channel_id}/messages/{msg_id}/ack"))
.header("Authorization", token)
.json(&json!({ "token": serde_json::Value::Null }))
.json(&json!({ "token": serde_json::Value::Null, "last_viewed": last_viewed_day() }))
)
.await
.inspect_err(|_| forget_ack(&channel_id))
@@ -898,6 +1103,17 @@ pub async fn ack_read(state: &AppState, buffer_id: &str, token: &str) -> Result<
Ok(())
}
/// The day, counted from Discord's epoch (2015-01-01) and rounded up, that a read mark says the channel was last
/// looked at: Discord's own clients send it with every ack.
pub(super) fn last_viewed_day() -> i64 {
last_viewed_day_at(chrono::Utc::now().timestamp())
}
pub(super) fn last_viewed_day_at(unix_secs: i64) -> i64 {
const DISCORD_EPOCH_SECS: i64 = 1_420_070_400;
(unix_secs - DISCORD_EPOCH_SECS + 86_399) / 86_400
}
/// The newest message each channel was last acked up to, this run.
static ACKED: std::sync::Mutex<Option<HashMap<String, String>>> = std::sync::Mutex::new(None);
@@ -1061,3 +1277,64 @@ mod ack_tests {
assert!(!ack_due(channel, "300"), "an older ack from elsewhere does not undo what was told");
}
}
#[cfg(test)]
mod wire_tests {
use super::*;
#[test]
fn a_reply_says_where_the_message_it_answers_is() {
let mut payload = json!({ "content": "hi" });
put_reply(&mut payload, Some("42"), true);
put_reply_place(&mut payload, "7", Some("9"));
assert_eq!(payload["message_reference"], json!({ "message_id": "42", "channel_id": "7", "guild_id": "9" }));
let mut dm = json!({ "content": "hi" });
put_reply(&mut dm, Some("42"), true);
put_reply_place(&mut dm, "7", None);
assert_eq!(dm["message_reference"], json!({ "message_id": "42", "channel_id": "7" }));
// Not a reply: nothing is added.
let mut plain = json!({ "content": "hi" });
put_reply_place(&mut plain, "7", Some("9"));
assert!(plain.get("message_reference").is_none());
}
#[test]
fn a_read_mark_counts_days_from_discords_epoch_rounded_up() {
assert_eq!(last_viewed_day_at(1_420_070_400), 0);
assert_eq!(last_viewed_day_at(1_420_070_401), 1);
// 2026-10-22 00:00 UTC is 4,191 days after 2015-01-01.
assert_eq!(last_viewed_day_at(1_420_070_400 + 4_191 * 86_400), 4_191);
}
#[test]
fn where_to_put_the_files_is_read_from_the_answer() {
let body = json!({ "attachments": [
{ "id": 1, "upload_url": "https://storage.example/b?sig=2", "upload_filename": "u/b.png" },
{ "id": "0", "upload_url": "https://storage.example/a?sig=1", "upload_filename": "u/a.png" }
] });
let slots = parse_slots(&body, 2).unwrap();
assert_eq!(slots[0], ("https://storage.example/a?sig=1".to_string(), "u/a.png".to_string()));
assert_eq!(slots[1].1, "u/b.png");
// Anything that is not a slot for every file is not an answer to use.
assert!(parse_slots(&body, 3).is_none(), "one missing");
assert!(parse_slots(&json!({ "attachments": [{ "id": 0, "upload_url": "http://insecure/x", "upload_filename": "n" }] }), 1).is_none(), "plain http");
assert!(parse_slots(&json!({ "message": "no" }), 1).is_none());
assert!(parse_slots(&json!({ "attachments": [{ "id": 5, "upload_url": "https://x/y", "upload_filename": "n" }] }), 1).is_none(), "an id out of range");
}
#[test]
fn the_message_names_each_file_and_a_voice_message_says_how_long_it_is() {
let uploads = vec![
Upload { path: "/a".into(), name: "a.png".into(), len: 3, voice: None },
Upload { path: "/v".into(), name: "voice-message.ogg".into(), len: 9, voice: Some((2.5, "AAAA".into())) },
];
let slots = vec![("https://s/1".to_string(), "u/a.png".to_string()), ("https://s/2".to_string(), "u/v.ogg".to_string())];
let named = attachments_named(&uploads, &slots);
assert_eq!(named[0], json!({ "id": "0", "filename": "a.png", "uploaded_filename": "u/a.png" }));
assert_eq!(named[1]["id"], "1");
assert_eq!(named[1]["uploaded_filename"], "u/v.ogg");
assert_eq!(named[1]["duration_secs"], json!(2.5));
assert_eq!(named[1]["waveform"], "AAAA");
}
}
+568 -14
View File
@@ -29,12 +29,30 @@ pub const FIRST_WORD: &str =
"Send your first message in this server from Discord's own app or website. Discord flags accounts that start talking in a server from another client.";
/// Shown while that is being found out.
pub const CHECKING: &str = "Checking whether you have spoken in this server before…";
pub const CHECKING: &str = "Checking whether you have spoken here before…";
/// Shown in a conversation with no messages in it.
pub const EMPTY_DM: &str =
"Start this conversation from Discord's own app or website. Discord flags accounts that send the first message to somebody from another client.";
/// Shown where Discord has asked for a captcha, which this client does not answer.
pub const CAPTCHA: &str =
"Discord asked for a captcha here, and moho does not answer those - doing so is what gets an account flagged. Say something here from Discord's own app; this opens again then, or in half an hour.";
/// Shown in a server channel that nobody has ever written in.
pub const EMPTY_CHANNEL: &str = "Nobody has said anything in this channel yet. Send the first message from Discord's own app.";
/// Shown in a conversation that has few messages of the account's own.
pub const NEW_CONVERSATION: &str =
"This conversation is new. Send at least 5 messages in it from Discord's own app first - Discord flags accounts that start talking to somebody from another client.";
pub const MESSAGE_REQUEST: &str = "This is a message request, not yet a conversation. Accept it in Discord's own app first.";
pub const SPAM_DM: &str = "Discord has marked this conversation as spam.";
/// How long a captcha closes the place it came from, unless the account speaks there from Discord's own app.
const CAPTCHA_HOLD: Duration = Duration::from_secs(30 * 60);
/// Why a place is closed to this client, and nothing else.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Lock {
@@ -42,6 +60,13 @@ pub enum Lock {
Checking,
FirstWord,
EmptyDm,
NewConversation,
MessageRequest,
SpamDm,
EmptyChannel,
Captcha,
/// The server asks something of the account that it has not done, and will not do by waiting.
Participation(&'static str),
}
impl Lock {
@@ -51,10 +76,36 @@ impl Lock {
Lock::Checking => Some(CHECKING),
Lock::FirstWord => Some(FIRST_WORD),
Lock::EmptyDm => Some(EMPTY_DM),
Lock::NewConversation => Some(NEW_CONVERSATION),
Lock::MessageRequest => Some(MESSAGE_REQUEST),
Lock::SpamDm => Some(SPAM_DM),
Lock::EmptyChannel => Some(EMPTY_CHANNEL),
Lock::Captcha => Some(CAPTCHA),
Lock::Participation(r) => Some(r),
}
}
}
/// What is known of a channel, by its buffer.
#[derive(Debug, Clone, Default)]
pub struct ChannelFacts {
/// Seconds between messages, the channel's slowmode.
pub slowmode: u64,
/// Whether the account's permissions are not held to it.
pub bypass_slowmode: bool,
/// Every permission: the owner, an administrator.
pub full: bool,
}
/// What is known of a direct message.
#[derive(Debug, Clone, Default)]
pub struct DmFacts {
pub request: bool,
pub spam: bool,
/// The account's own messages in it that were not sent from here.
pub own: usize,
}
/// What is known, per process. Plain data and plain methods so it can be tested without a connection.
#[derive(Default)]
pub struct Guard {
@@ -78,11 +129,35 @@ pub struct Guard {
/// it wants a first word from Discord's own client.
sent: HashSet<String>,
sent_order: std::collections::VecDeque<String>,
/// The nonces of messages sent from here: the echo of one is not the account speaking from elsewhere.
nonces: HashSet<String>,
nonce_order: std::collections::VecDeque<String>,
/// Where a captcha closed a place: (account, server or conversation) -> until when.
captcha: HashMap<(String, String), Instant>,
/// Each server's rules and our membership of it.
guilds: HashMap<(String, String), (rules::GuildFacts, Option<rules::MemberFacts>)>,
/// What the server's onboarding said of itself, where it was asked: enabled or not, and when it was asked.
onboarding: HashMap<(String, String), (Option<bool>, Instant)>,
accounts: HashMap<String, rules::AccountFacts>,
channels: HashMap<String, ChannelFacts>,
/// Server channels nobody has written in.
empty_channels: HashSet<String>,
dms: HashMap<String, DmFacts>,
/// Conversations that have enough of the account's own messages in them, kept once reached.
established: HashSet<String>,
/// Conversations being counted now, and counted this run.
dm_checking: HashSet<String>,
dm_checked: HashSet<String>,
/// When the account last spoke in each channel, from here or from anywhere.
last_own: HashMap<String, Instant>,
}
#[derive(Serialize, Deserialize, Default)]
struct Saved {
accounts: HashMap<String, Vec<String>>,
/// Conversations that have been begun for good.
#[serde(default)]
established: Vec<String>,
/// Ids of messages this client sent, oldest first.
#[serde(default)]
sent: Vec<String>,
@@ -97,14 +172,68 @@ impl Guard {
self.spoken.get(account).is_some_and(|g| g.contains(guild))
}
fn captcha_held(&self, account: &str, scope: &str, now: Instant) -> bool {
self.captcha.get(&(account.to_string(), scope.to_string())).is_some_and(|until| *until > now)
}
/// Whether the server's rules hold the account back, as of `now` (Unix seconds).
pub fn restriction(&self, account: &str, guild: &str, buffer: &str, now: i64) -> Option<rules::Restriction> {
let key = (account.to_string(), guild.to_string());
let (facts, member) = self.guilds.get(&key)?;
let account_facts = self.accounts.get(account)?;
let full = self.channels.get(buffer).is_some_and(|c| c.full);
let onboarding = self.onboarding.get(&key).and_then(|(enabled, _)| *enabled);
// Discord's green new-member badge, for the first week: what the server asks comes first, as it is the more
// actionable thing to be told.
rules::participation(facts, member.as_ref(), account_facts, full, onboarding, now).or_else(|| rules::new_member(facts, member.as_ref(), self.joined.get(&key).copied(), now))
}
/// What closes a place: `guild` is None for a conversation.
pub fn lock_of(&self, account: &str, guild: Option<&str>, buffer: &str) -> Lock {
let now = Instant::now();
match guild {
Some(guild) if self.is_spoken(account, guild) => Lock::None,
Some(guild) if self.checking.contains(&(account.to_string(), guild.to_string())) => Lock::Checking,
Some(_) => Lock::FirstWord,
None if self.empty_dms.contains(buffer) => Lock::EmptyDm,
None => Lock::None,
Some(guild) => {
let secs = chrono::Utc::now().timestamp();
if let Some(reason) = self.restriction(account, guild, buffer, secs).and_then(|r| r.banner()) {
return Lock::Participation(reason);
}
if self.captcha_held(account, guild, now) {
return Lock::Captcha;
}
if !self.is_spoken(account, guild) {
return if self.checking.contains(&(account.to_string(), guild.to_string())) { Lock::Checking } else { Lock::FirstWord };
}
if self.empty_channels.contains(buffer) {
return Lock::EmptyChannel;
}
Lock::None
}
None => {
if let Some(dm) = self.dms.get(buffer) {
if dm.spam {
return Lock::SpamDm;
}
if dm.request {
return Lock::MessageRequest;
}
}
if self.captcha_held(account, buffer, now) {
return Lock::Captcha;
}
if self.established.contains(buffer) {
return Lock::None;
}
if self.dm_checking.contains(buffer) {
return Lock::Checking;
}
if self.empty_dms.contains(buffer) {
return Lock::EmptyDm;
}
match self.dms.get(buffer) {
Some(dm) if dm.own < rules::ESTABLISHED_DM_MESSAGES => Lock::NewConversation,
_ => Lock::None,
}
}
}
}
@@ -203,6 +332,7 @@ fn with<R>(f: impl FnOnce(&mut Guard) -> R) -> R {
spoken: saved.accounts.into_iter().map(|(a, g)| (a, g.into_iter().collect())).collect(),
sent: saved.sent.iter().cloned().collect(),
sent_order: saved.sent.into_iter().collect(),
established: saved.established.into_iter().collect(),
..Default::default()
}
});
@@ -213,6 +343,7 @@ fn save(guard: &Guard) {
let saved = Saved {
accounts: guard.spoken.iter().map(|(a, g)| (a.clone(), g.iter().cloned().collect())).collect(),
sent: guard.sent_order.iter().cloned().collect(),
established: guard.established.iter().cloned().collect(),
};
if let Ok(text) = serde_json::to_string(&saved) {
if let Some(dir) = file().parent() {
@@ -240,6 +371,31 @@ fn reapply_guild(state: &AppState, account: &str, guild: &str) {
}
}
/// Servers whose new-member badge has a task waiting for its end.
static BADGE_WAITS: Mutex<Option<HashSet<(String, String)>>> = Mutex::new(None);
/// Opens the places the green new-member badge closed when the week is up. Re-evaluating is all it does: the lock is
/// worked out from the clock each time, so what is needed is only a moment at which to look again.
fn watch_badge_end(state: &AppState, account: &str, guild: &str, joined: i64) {
let left = joined + rules::NEW_MEMBER_SECS - chrono::Utc::now().timestamp();
if left <= 0 {
return;
}
let key = (account.to_string(), guild.to_string());
let fresh = BADGE_WAITS.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).insert(key.clone());
if !fresh {
return;
}
// Said so that the week can be checked against the day the badge actually goes.
tracing::info!("discord[{account}]: joined server {guild} {} days ago: the new-member lock lasts until {}", (chrono::Utc::now().timestamp() - joined) / 86_400, chrono::DateTime::from_timestamp(joined + rules::NEW_MEMBER_SECS, 0).map(|t| t.to_rfc3339()).unwrap_or_default());
let state = state.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(left as u64 + 5)).await;
BADGE_WAITS.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).remove(&key);
reapply_guild(&state, &key.0, &key.1);
});
}
/// A server is being registered: note what is wanted of it, and start finding out whether the account has spoken in
/// it. Called before its channels are registered, so they begin locked rather than open.
pub(super) fn open_guild(state: &AppState, config: &DiscordAccountConfig, guild: &Value) {
@@ -247,6 +403,9 @@ pub(super) fn open_guild(state: &AppState, config: &DiscordAccountConfig, guild:
let account = config.account_id();
let key = (account.clone(), guild_id.to_string());
let joined = guild["joined_at"].as_str().and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok()).map(|t| t.timestamp());
if let Some(at) = joined {
watch_badge_end(state, &account, guild_id, at);
}
let start = with(|g| {
if let Some(at) = joined {
g.joined.insert(key.clone(), at);
@@ -313,15 +472,39 @@ pub fn forget_account(account: &str) {
pub(super) fn note_dm(state: &AppState, account: &str, buffer: &str, channel: &Value) {
// Only a channel that says it has no messages: one that does not say is not known to be empty.
let empty = channel.get("last_message_id").is_some_and(Value::is_null) && state.store.newest_msg_id(buffer).ok().flatten().is_none();
// How many of the account's own messages are in it that were not sent from here.
let kept = state.store.own_message_ids_since(&[buffer.to_string()], 0, 50).unwrap_or_default();
with(|g| {
let own = kept.iter().filter(|id| !g.sent.contains(id.as_str())).count();
g.dms.insert(
buffer.to_string(),
DmFacts { request: channel["is_message_request"].as_bool() == Some(true), spam: channel["is_spam"].as_bool() == Some(true), own },
);
if own >= rules::ESTABLISHED_DM_MESSAGES && g.established.insert(buffer.to_string()) {
save(g);
}
if empty {
g.empty_dms.insert(buffer.to_string());
} else {
g.empty_dms.remove(buffer);
}
});
// Not enough of the account's own messages here to know it has talked in it: asked of Discord, once a run.
let start = with(|g| {
let counted = g.dms.get(buffer).is_some_and(|f| f.own >= rules::ESTABLISHED_DM_MESSAGES);
if counted || g.established.contains(buffer) || empty || g.dm_checked.contains(buffer) || g.dm_checking.contains(buffer) {
return false;
}
g.dm_checking.insert(buffer.to_string());
true
});
let effective = with(|g| g.effective(account, None, buffer));
state.runtime.set_buffer_read_only(state, buffer, effective);
if start {
if let (Some(config), Some(channel)) = (state.accounts.get_discord(account), channel["id"].as_str()) {
spawn_dm_count(state.clone(), config, buffer.to_string(), channel.to_string());
}
}
}
/// A message has arrived in a conversation, so it is not empty any more.
@@ -333,26 +516,225 @@ pub(super) fn dm_has_messages(state: &AppState, account: &str, buffer: &str) {
}
}
/// The account itself, from READY's `user`: what it can say of itself to a server's rules.
pub(super) fn note_account(account: &str, user: &Value) {
let id = user["id"].as_str().and_then(|i| i.parse::<u64>().ok()).unwrap_or(0);
let created_at = if id == 0 { 0 } else { (((id >> 22) + 1_420_070_400_000) / 1000) as i64 };
with(|g| {
g.accounts.insert(
account.to_string(),
rules::AccountFacts {
email_verified: user["verified"].as_bool(),
// A phone is a verified one: Discord does not keep an unverified number on an account.
phone_verified: Some(user["phone"].as_str().is_some_and(|p| !p.is_empty())),
created_at,
},
);
});
}
/// A server's rules, from its payload.
pub(super) fn note_guild(account: &str, guild: &Value, user_id: &str) {
let Some(id) = guild["id"].as_str() else { return };
// Some payloads keep the server's own fields under `properties`.
let own = guild.get("properties").filter(|p| p.is_object()).unwrap_or(guild);
let facts = rules::GuildFacts {
verification: own["verification_level"].as_u64().unwrap_or(0).min(4) as u8,
community: own["features"].as_array().is_some_and(|f| f.iter().any(|x| x.as_str() == Some("COMMUNITY"))),
owner: own["owner_id"].as_str() == Some(user_id),
};
with(|g| {
let key = (account.to_string(), id.to_string());
let member = g.guilds.remove(&key).and_then(|(_, m)| m);
g.guilds.insert(key, (facts, member));
});
}
/// The account's own membership of a server, as its member object says (at login, and when it changes).
pub(super) fn note_member(state: &AppState, account: &str, guild_id: &str, member: Option<&Value>) {
let Some(member) = member else { return };
let when = |v: &Value| v.as_str().and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok()).map(|t| t.timestamp());
let facts = rules::MemberFacts {
pending: member["pending"].as_bool(),
flags: member["flags"].as_u64(),
has_roles: member["roles"].as_array().is_some_and(|r| !r.is_empty()),
joined_at: when(&member["joined_at"]),
timed_out_until: when(&member["communication_disabled_until"]),
};
if let Some(at) = facts.joined_at {
watch_badge_end(state, account, guild_id, at);
}
let changed = with(|g| {
let key = (account.to_string(), guild_id.to_string());
match g.guilds.get_mut(&key) {
Some((_, slot)) => {
let changed = slot.as_ref().map(|m| (m.pending, m.flags, m.has_roles, m.timed_out_until)) != Some((facts.pending, facts.flags, facts.has_roles, facts.timed_out_until));
*slot = Some(facts);
changed
}
None => false,
}
});
if changed {
reapply_guild(state, account, guild_id);
}
}
/// A server channel, as it is registered: its slowmode, whether the account's permissions are held to it, whether it
/// has ever been written in.
pub(super) fn note_channel(state: &AppState, buffer: &str, channel: &Value, permissions: u64, is_owner: bool) {
const ADMINISTRATOR: u64 = 1 << 3;
const MANAGE_CHANNELS: u64 = 1 << 4;
const MANAGE_MESSAGES: u64 = 1 << 13;
let empty = channel.get("last_message_id").is_some_and(Value::is_null) && state.store.newest_msg_id(buffer).ok().flatten().is_none();
with(|g| {
g.channels.insert(
buffer.to_string(),
ChannelFacts {
slowmode: channel["rate_limit_per_user"].as_u64().unwrap_or(0),
bypass_slowmode: is_owner || permissions & (ADMINISTRATOR | MANAGE_CHANNELS | MANAGE_MESSAGES) != 0,
full: is_owner || permissions & ADMINISTRATOR != 0,
},
);
if empty {
g.empty_channels.insert(buffer.to_string());
} else {
g.empty_channels.remove(buffer);
}
});
}
/// A channel's slowmode changed.
pub(super) fn set_slowmode(buffer: &str, seconds: u64) {
with(|g| {
if let Some(facts) = g.channels.get_mut(buffer) {
facts.slowmode = seconds;
}
});
}
/// Whether a message is somebody speaking, and not Discord saying something about them. A system message carries its
/// subject as its author - "the account joined this server" (type 7) is authored by the account - and is no word of
/// theirs: counting it unlocked a server the moment it was joined. Only an ordinary message (0) and a reply (19) are
/// what a person wrote.
pub fn is_speech(message: &Value) -> bool {
matches!(message["type"].as_u64().unwrap_or(0), 0 | 19)
}
/// A dispatch for a message was received in a place this connection knows: whatever it means for the locks.
pub(super) fn heard(state: &AppState, config: &DiscordAccountConfig, d: &Value, buffer: &str, is_dm: bool) {
let account = config.account_id();
let own = d["author"]["id"].as_str() == Some(config.user_id.as_str()) && is_speech(d);
// The echo of a message sent from here carries the nonce it was sent with: it is not the account speaking from
// another client, whatever else it is.
let from_here = d["nonce"].as_str().is_some_and(|n| with(|g| g.nonces.contains(n)));
if own {
with(|g| g.last_own.insert(buffer.to_string(), Instant::now()));
}
// Something has been said in a channel that nobody had written in.
let was_empty = with(|g| g.empty_channels.remove(buffer));
if was_empty {
let effective = with(|g| g.effective(&account, state.runtime.get_discord_guild(buffer).as_deref(), buffer));
state.runtime.set_buffer_read_only(state, buffer, effective);
}
if is_dm {
dm_has_messages(state, &account, buffer);
} else if d["author"]["id"].as_str() == Some(config.user_id.as_str()) {
if own && !from_here {
let reached = with(|g| {
let own = {
let facts = g.dms.entry(buffer.to_string()).or_default();
facts.own += 1;
facts.own
};
let reached = own >= rules::ESTABLISHED_DM_MESSAGES && g.established.insert(buffer.to_string());
if reached {
save(g);
}
// Said from another client, here: a captcha that held this conversation is over.
g.captcha.remove(&(account.clone(), buffer.to_string()));
reached
});
if reached {
let effective = with(|g| g.effective(&account, None, buffer));
state.runtime.set_buffer_read_only(state, buffer, effective);
}
}
} else if own && !from_here {
if let Some(guild) = d["guild_id"].as_str() {
with(|g| g.captcha.remove(&(account.clone(), guild.to_string())));
spoke_in_guild(state, &account, guild);
}
}
}
/// Refuses a send to a place the account has not spoken in.
pub(super) fn ensure_may_speak(state: &AppState, buffer: &str) -> Result<()> {
/// Discord asked for a captcha where this was sent: that place is closed to this client for a while.
pub(super) fn hold_captcha(state: &AppState, buffer: &str) {
let account = state.runtime.get_buffer(buffer).map(|b| b.account_id).unwrap_or_default();
let scope = state.runtime.get_discord_guild(buffer).unwrap_or_else(|| buffer.to_string());
with(|g| g.captcha.insert((account.clone(), scope.clone()), Instant::now() + CAPTCHA_HOLD));
match state.runtime.get_discord_guild(buffer) {
Some(guild) => reapply_guild(state, &account, &guild),
None => {
let effective = with(|g| g.effective(&account, None, buffer));
state.runtime.set_buffer_read_only(state, buffer, effective);
}
}
}
/// A message was sent from here: the slowmode counts from now, and its echo is told from another client's.
pub(super) fn record_own_send(buffer: &str) {
with(|g| g.last_own.insert(buffer.to_string(), Instant::now()));
}
/// Refuses a send to a place this client does not speak first in, or where the server's own rules (a level, a
/// timeout, a slowmode) say the account may not speak yet. Asked of every send; it makes no request unless a server
/// has never been asked whether it has onboarding.
pub(super) async fn ensure_may_speak(state: &AppState, buffer: &str) -> Result<()> {
let account = state.runtime.get_buffer(buffer).map(|b| b.account_id).unwrap_or_default();
let guild = state.runtime.get_discord_guild(buffer);
match with(|g| g.lock_of(&account, guild.as_deref(), buffer)).reason() {
Some(reason) => bail!("{reason}"),
None => Ok(()),
if let Some(reason) = with(|g| g.lock_of(&account, guild.as_deref(), buffer)).reason() {
bail!("{reason}");
}
if let Some(guild) = guild.as_deref() {
learn_onboarding(state, &account, guild, buffer).await;
let now = chrono::Utc::now().timestamp();
if let Some(restriction) = with(|g| g.restriction(&account, guild, buffer, now)) {
bail!("{}", restriction.sentence());
}
}
let slowmode = with(|g| g.channels.get(buffer).map(|c| rules::slowmode_remaining(c.slowmode, g.last_own.get(buffer).copied(), c.bypass_slowmode, Instant::now())));
if let Some(Some(wait)) = slowmode {
bail!("This channel has slowmode on: wait {} more seconds.", wait.as_secs().max(1) + 1);
}
Ok(())
}
/// Asks a server whether it has onboarding on, once, but only where it could matter: a community server whose
/// onboarding this member has not completed. Everywhere else the answer is no request at all.
async fn learn_onboarding(state: &AppState, account: &str, guild: &str, buffer: &str) {
const COMPLETED_ONBOARDING: u64 = 1 << 1;
let key = (account.to_string(), guild.to_string());
let needed = with(|g| {
let Some((facts, member)) = g.guilds.get(&key) else { return false };
let completed = member.as_ref().and_then(|m| m.flags).is_some_and(|f| f & COMPLETED_ONBOARDING != 0);
let full = g.channels.get(buffer).is_some_and(|c| c.full);
let recent = g.onboarding.get(&key).is_some_and(|(_, at)| at.elapsed() < Duration::from_secs(3600));
facts.community && !facts.owner && !completed && !full && !recent
});
if !needed {
return;
}
let Some(config) = state.accounts.get_discord(account) else { return };
let enabled = async {
let resp = http_client_for(&config.token).get(format!("{API_BASE}/guilds/{guild}/onboarding")).header("Authorization", &config.token).send_paced().await.ok()?;
if !resp.status().is_success() {
return None;
}
resp.json::<Value>().await.ok()?["enabled"].as_bool()
}
.await;
with(|g| g.onboarding.insert(key, (enabled, Instant::now())));
reapply_guild(state, account, guild);
}
/// Refuses a file Discord would refuse, before any of it is sent. What Discord does with one that is too big is cut
@@ -402,6 +784,19 @@ pub(super) async fn take_turn(account: &str, attachment: bool) {
}
}
type ChannelLocks = HashMap<String, std::sync::Arc<tokio::sync::Mutex<()>>>;
static CHANNEL_LOCKS: Mutex<Option<ChannelLocks>> = Mutex::new(None);
/// Waits until nothing else is being sent to this channel, and holds the channel until it is dropped: two messages
/// to one channel do not overlap, as they cannot from a person at a keyboard.
pub(super) async fn channel_turn(channel_id: &str) -> tokio::sync::OwnedMutexGuard<()> {
let lock = {
let mut slot = CHANNEL_LOCKS.lock().unwrap_or_else(|e| e.into_inner());
slot.get_or_insert_with(HashMap::new).entry(channel_id.to_string()).or_default().clone()
};
lock.lock_owned().await
}
/// A fresh message id, as Discord's clients make one: a snowflake from now, so it sorts with the messages around it
/// and cannot repeat.
pub fn nonce() -> String {
@@ -414,7 +809,24 @@ pub fn nonce() -> String {
/// What every message carries beyond its words: the nonce, and who may be mentioned, said outright.
pub(super) fn stamp(payload: &mut Value) {
if payload.get("nonce").is_none() {
payload["nonce"] = Value::from(nonce());
let nonce = nonce();
with(|g| {
g.nonces.insert(nonce.clone());
g.nonce_order.push_back(nonce.clone());
while g.nonce_order.len() > 500 {
if let Some(old) = g.nonce_order.pop_front() {
g.nonces.remove(&old);
}
}
});
payload["nonce"] = Value::from(nonce);
}
// Said outright, as Discord's own clients say them.
if payload.get("tts").is_none() {
payload["tts"] = Value::Bool(false);
}
if payload.get("flags").is_none() {
payload["flags"] = Value::from(0);
}
if payload.get("allowed_mentions").is_none() {
let mut allowed = json!({ "parse": ["users", "roles", "everyone"] });
@@ -454,10 +866,16 @@ pub fn read_search(status: u16, retry_after: Option<f64>, body: &Value, sent_her
.into_iter()
.flatten()
.filter_map(|group| group.as_array()?.iter().find(|m| m["hit"].as_bool() == Some(true)).or_else(|| group.as_array()?.first()))
.filter(|m| is_speech(m))
.filter_map(|m| m["id"].as_str())
.collect();
let elsewhere = hits.iter().any(|id| !sent_here.contains(*id));
Ok(if total > 0 && (elsewhere || total as usize > hits.len()) { Searched::Spoke } else { Searched::Never })
// A hit that is not speech (a join message) is not a word of theirs, and does not count among the results
// either: only when more were found than the hits shown, and the shown ones are all speech, can the rest
// be taken for speech too.
let shown = body["messages"].as_array().map(|g| g.len()).unwrap_or(0);
let all_speech = hits.len() == shown;
Ok(if total > 0 && (elsewhere || (all_speech && total as usize > hits.len())) { Searched::Spoke } else { Searched::Never })
}
202 | 429 => {
let seconds = retry_after.or_else(|| body["retry_after"].as_f64()).unwrap_or(5.0).clamp(1.0, 60.0);
@@ -518,6 +936,67 @@ fn spawn_check(state: AppState, config: DiscordAccountConfig, guild_id: String)
});
}
/// Counts the account's own messages in a conversation by asking Discord's search - the store holds only what has been
/// read here - so a conversation of years is not taken for a new one. One request, queued behind the others.
fn spawn_dm_count(state: AppState, config: DiscordAccountConfig, buffer: String, channel_id: String) {
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(2)).await;
let _turn = TURN.lock().await;
let account = config.account_id();
let mut own = None;
for _ in 0..INDEXING_TRIES {
let resp = http_client_for(&config.token)
.get(format!("{API_BASE}/channels/{channel_id}/messages/search"))
.query(&[("author_id", config.user_id.as_str())])
.header("Authorization", &config.token)
.send_paced()
.await;
let Ok(resp) = resp else { break };
let status = resp.status().as_u16();
let retry = resp.headers().get("retry-after").and_then(|v| v.to_str().ok()).and_then(|v| v.parse::<f64>().ok());
let body: Value = resp.json().await.unwrap_or(Value::Null);
match status {
200 => {
own = Some(count_own(&body, &with(|g| g.sent.clone())));
break;
}
202 | 429 => {
let wait = retry.or_else(|| body["retry_after"].as_f64()).unwrap_or(5.0).clamp(1.0, 60.0);
tokio::time::sleep(Duration::from_secs_f64(wait)).await;
}
_ => break,
}
}
with(|g| {
g.dm_checking.remove(&buffer);
g.dm_checked.insert(buffer.clone());
if let Some(own) = own {
g.dms.entry(buffer.clone()).or_default().own = own;
if own >= rules::ESTABLISHED_DM_MESSAGES && g.established.insert(buffer.clone()) {
save(g);
}
}
});
let effective = with(|g| g.effective(&account, None, &buffer));
state.runtime.set_buffer_read_only(&state, &buffer, effective);
tokio::time::sleep(SPACING).await;
});
}
/// How many of the account's own messages a conversation's search answer says there are, not counting any that this
/// client sent (of those shown): the total is Discord's, the sent ones are known by id.
pub fn count_own(body: &Value, sent_here: &HashSet<String>) -> usize {
let total = body["total_results"].as_u64().unwrap_or(0) as usize;
let ours = body["messages"]
.as_array()
.into_iter()
.flatten()
.filter_map(|group| group.as_array()?.iter().find(|m| m["hit"].as_bool() == Some(true)).or_else(|| group.as_array()?.first()))
.filter(|m| m["id"].as_str().is_some_and(|id| sent_here.contains(id)))
.count();
total.saturating_sub(ours)
}
/// Asks Discord whether the account has any message in a server since it joined.
async fn search_own(config: &DiscordAccountConfig, guild_id: &str, since: Option<i64>) -> Result<Searched> {
let mut query = vec![("author_id".to_string(), config.user_id.clone()), ("include_nsfw".to_string(), "true".to_string())];
@@ -635,6 +1114,8 @@ mod tests {
let mut plain = json!({ "content": "hi" });
stamp(&mut plain);
assert!(plain["nonce"].as_str().is_some());
assert_eq!(plain["tts"], json!(false));
assert_eq!(plain["flags"], json!(0));
assert_eq!(plain["allowed_mentions"]["parse"], json!(["users", "roles", "everyone"]));
assert!(plain["allowed_mentions"].get("replied_user").is_none());
@@ -679,6 +1160,79 @@ mod tests {
assert_eq!(read_search(200, None, &many, &sent).unwrap(), Searched::Spoke);
}
#[test]
fn a_system_message_about_the_account_is_not_the_account_speaking() {
assert!(is_speech(&json!({ "type": 0 })));
assert!(is_speech(&json!({ "type": 19 })), "a reply is");
assert!(is_speech(&json!({})), "no type is the ordinary one");
// User join (7), boosts (8-11), pins (6), thread created (18), automod (24): Discord's, not theirs.
for t in [1, 3, 4, 6, 7, 8, 18, 20, 21, 24] {
assert!(!is_speech(&json!({ "type": t })), "type {t}");
}
// The same in a search: the join message of the account is a hit and is not a word of theirs.
let sent = HashSet::new();
let joined = json!({ "total_results": 1, "messages": [[{ "id": "5", "type": 7, "hit": true }]] });
assert_eq!(read_search(200, None, &joined, &sent).unwrap(), Searched::Never);
let spoke = json!({ "total_results": 2, "messages": [[{ "id": "5", "type": 7, "hit": true }], [{ "id": "6", "type": 0, "hit": true }]] });
assert_eq!(read_search(200, None, &spoke, &sent).unwrap(), Searched::Spoke);
}
#[test]
fn a_conversations_own_messages_are_counted_without_the_ones_sent_here() {
let sent: HashSet<String> = ["2".to_string()].into();
let body = json!({ "total_results": 7, "messages": [[{ "id": "1", "hit": true }], [{ "id": "2", "hit": true }]] });
assert_eq!(count_own(&body, &sent), 6);
assert_eq!(count_own(&json!({ "total_results": 0, "messages": [] }), &sent), 0);
assert_eq!(count_own(&Value::Null, &sent), 0);
}
#[test]
fn a_new_conversation_is_locked_until_enough_is_the_accounts_own_and_then_for_good() {
let mut g = guard();
assert_eq!(g.lock_of("a", None, "dm"), Lock::None, "nothing known of it");
g.dms.insert("dm".into(), DmFacts { own: 2, ..Default::default() });
assert_eq!(g.lock_of("a", None, "dm"), Lock::NewConversation);
g.dm_checking.insert("dm".into());
assert_eq!(g.lock_of("a", None, "dm"), Lock::Checking);
g.dm_checking.clear();
g.dms.insert("dm".into(), DmFacts { own: 5, ..Default::default() });
g.established.insert("dm".into());
assert_eq!(g.lock_of("a", None, "dm"), Lock::None);
// Established is for good, but a message request or spam mark is Discord's word on it.
g.dms.insert("dm".into(), DmFacts { own: 9, request: true, ..Default::default() });
assert_eq!(g.lock_of("a", None, "dm"), Lock::MessageRequest);
g.dms.insert("dm".into(), DmFacts { own: 9, spam: true, ..Default::default() });
assert_eq!(g.lock_of("a", None, "dm"), Lock::SpamDm);
}
#[test]
fn an_empty_channel_and_a_captcha_close_a_server_the_account_has_spoken_in() {
let mut g = guard();
g.spoken.entry("a".into()).or_default().insert("g".into());
assert_eq!(g.lock_of("a", Some("g"), "b"), Lock::None);
g.empty_channels.insert("b".into());
assert_eq!(g.lock_of("a", Some("g"), "b"), Lock::EmptyChannel);
g.empty_channels.clear();
g.captcha.insert(("a".into(), "g".into()), Instant::now() + Duration::from_secs(60));
assert_eq!(g.lock_of("a", Some("g"), "b"), Lock::Captcha);
g.captcha.insert(("a".into(), "g".into()), Instant::now() - Duration::from_secs(1));
assert_eq!(g.lock_of("a", Some("g"), "b"), Lock::None, "it ends");
}
#[test]
fn a_server_that_asks_for_a_phone_closes_its_channels_until_there_is_one() {
let mut g = guard();
g.spoken.entry("a".into()).or_default().insert("g".into());
g.accounts.insert("a".into(), rules::AccountFacts { email_verified: Some(true), phone_verified: Some(false), created_at: 1 });
g.guilds.insert(
("a".into(), "g".into()),
(rules::GuildFacts { verification: 4, ..Default::default() }, Some(rules::MemberFacts { pending: Some(false), flags: Some(0), ..Default::default() })),
);
assert!(matches!(g.lock_of("a", Some("g"), "b"), Lock::Participation(r) if r.contains("phone")));
g.accounts.get_mut("a").unwrap().phone_verified = Some(true);
assert_eq!(g.lock_of("a", Some("g"), "b"), Lock::None);
}
#[test]
fn sent_messages_are_remembered_a_bounded_number() {
let mut g = guard();
+4 -2
View File
@@ -241,7 +241,8 @@ fn guild_list(state: &AppState, account_id: &str, buffer_id: &str) -> Vec<Value>
/// Sends a sticker as a message of its own.
pub async fn send(state: &AppState, buffer_id: &str, token: &str, sticker_id: &str) -> Result<()> {
let channel_id = state.runtime.get_discord_channel(buffer_id).ok_or_else(|| anyhow!("no known Discord channel for this buffer"))?;
super::send_guard::ensure_may_speak(state, buffer_id)?;
super::send_guard::ensure_may_speak(state, buffer_id).await?;
let _turn = super::send_guard::channel_turn(&channel_id).await;
let resp = send_write(
http_client_for(token)
.post(format!("{API_BASE}/channels/{channel_id}/messages"))
@@ -257,8 +258,9 @@ pub async fn send(state: &AppState, buffer_id: &str, token: &str, sticker_id: &s
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
bail!("Discord API error {status}: {text}");
bail!("{}", super::send::refused(state, buffer_id, status, &text));
}
super::send_guard::record_own_send(buffer_id);
super::send_guard::note_sent(resp).await;
Ok(())
}
+1
View File
@@ -16,5 +16,6 @@ pub mod kick;
pub mod matrix;
pub mod sneedchat;
pub mod steam;
pub mod stoat;
pub mod telegram;
pub mod twitch;
+255
View File
@@ -0,0 +1,255 @@
//! 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
@@ -0,0 +1,235 @@
//! 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
@@ -0,0 +1,854 @@
//! 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
@@ -0,0 +1,121 @@
//! 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
@@ -0,0 +1,245 @@
//! 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
@@ -0,0 +1,446 @@
//! 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
@@ -0,0 +1,122 @@
//! 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
@@ -0,0 +1,158 @@
//! 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
@@ -0,0 +1,491 @@
//! 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
@@ -0,0 +1,92 @@
//! 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
@@ -0,0 +1,105 @@
//! 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
@@ -0,0 +1,145 @@
//! 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
@@ -0,0 +1,179 @@
//! 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
@@ -0,0 +1,130 @@
//! 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
@@ -0,0 +1,161 @@
//! 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
@@ -0,0 +1,278 @@
//! 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
@@ -0,0 +1,161 @@
//! 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
@@ -0,0 +1,222 @@
//! 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
@@ -0,0 +1,51 @@
//! 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
@@ -0,0 +1,736 @@
//! 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));
}
}
+125 -11
View File
@@ -3,7 +3,13 @@
//! Telegram keeps a bot's list of commands (`/help`, `/weblink`, ...) with the bot, and its own apps show it as a
//! Menu button beside the message box and as suggestions while typing a slash. It is read once from the bot's profile
//! and kept for a while, since it changes when the bot's author changes it and not otherwise.
//!
//! In a group it is the commands of every bot that is in the group, which Telegram lists with the group itself (and
//! scopes: a bot can offer other commands to a group's administrators than to its members). Two bots can both have a
//! `/help`, and a group says which one is meant by writing the bot's name after it, `/help@somebot`: a command that
//! more than one bot has is offered once for each bot, in that form.
use super::peers;
use super::send::{explain, peer_for};
use crate::model::Buffer;
use crate::state::AppState;
@@ -20,6 +26,8 @@ const KEEP: Duration = Duration::from_secs(15 * 60);
pub struct Command {
pub name: String,
pub description: String,
/// Whose it is, where that is not the conversation itself: a bot in a group, as `@name`.
pub source: Option<String>,
}
/// When each conversation's list was read, and what it said.
@@ -38,31 +46,90 @@ fn from_info(info: &tl::enums::BotInfo) -> Vec<Command> {
.flatten()
.map(|c| {
let tl::enums::BotCommand::Command(c) = c;
Command { name: c.command.trim_start_matches('/').to_string(), description: c.description.clone() }
Command { name: c.command.trim_start_matches('/').to_string(), description: c.description.clone(), source: None }
})
.filter(|c| !c.name.is_empty())
.collect()
}
/// The commands of the bot a conversation is with; nothing for a conversation that is not with a bot.
/// The commands of a bot's conversation, or of the bots in a group; nothing for any other conversation.
pub async fn commands(state: &AppState, buffer: &Buffer) -> Result<Vec<Command>, String> {
if buffer.kind != "dm" || buffer.name_icon.as_deref() != Some("smart_toy") {
let with_bot = buffer.kind == "dm" && buffer.name_icon.as_deref() == Some("smart_toy");
// A channel's posts are not commands, and a person is not a bot. A group (or a topic of one) has bots.
let in_group = buffer.kind != "dm" && buffer.category.as_deref() != Some(peers::CHANNELS);
if !with_bot && !in_group {
return Ok(Vec::new());
}
if let Some((when, list)) = kept().lock().unwrap_or_else(|e| e.into_inner()).get(&buffer.id) {
// A group's topics are one group: they share a list.
let key = format!("{}|{}", buffer.account_id, buffer.remote_id.as_deref().map(|r| r.split(':').next().unwrap_or(r)).unwrap_or(&buffer.id));
if let Some((when, list)) = kept().lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
if when.elapsed() < KEEP {
return Ok(list.clone());
}
}
let (handle, peer) = peer_for(state, buffer).await?;
let answer = handle.client.invoke(&tl::functions::users::GetFullUser { id: peer.into() }).await.map_err(|e| explain(&e))?;
let tl::enums::users::UserFull::Full(full) = answer;
let tl::enums::UserFull::Full(user) = full.full_user;
let list = user.bot_info.as_ref().map(from_info).unwrap_or_default();
kept().lock().unwrap_or_else(|e| e.into_inner()).insert(buffer.id.clone(), (Instant::now(), list.clone()));
let list = if with_bot {
let answer = handle.client.invoke(&tl::functions::users::GetFullUser { id: peer.into() }).await.map_err(|e| explain(&e))?;
let tl::enums::users::UserFull::Full(full) = answer;
let tl::enums::UserFull::Full(user) = full.full_user;
user.bot_info.as_ref().map(from_info).unwrap_or_default()
} else {
let (infos, users) = if peer.id.kind() == grammers_session::types::PeerKind::Channel {
let answer = handle.client.invoke(&tl::functions::channels::GetFullChannel { channel: peer.into() }).await.map_err(|e| explain(&e))?;
let tl::enums::messages::ChatFull::Full(full) = answer;
let tl::enums::ChatFull::ChannelFull(group) = full.full_chat else { return Ok(Vec::new()) };
(group.bot_info, full.users)
} else {
let answer = handle.client.invoke(&tl::functions::messages::GetFullChat { chat_id: peer.id.bare_id_unchecked() }).await.map_err(|e| explain(&e))?;
let tl::enums::messages::ChatFull::Full(full) = answer;
let tl::enums::ChatFull::Full(group) = full.full_chat else { return Ok(Vec::new()) };
(group.bot_info.unwrap_or_default(), full.users)
};
merge(&infos, &users)
};
kept().lock().unwrap_or_else(|e| e.into_inner()).insert(key, (Instant::now(), list.clone()));
Ok(list)
}
/// The @name a user is reached by.
fn username_of(user: &tl::enums::User) -> Option<String> {
let tl::enums::User::User(user) = user else { return None };
user.username.clone().or_else(|| user.usernames.as_ref()?.iter().find_map(|u| {
let tl::enums::Username::Username(u) = u;
u.active.then(|| u.username.clone())
}))
}
/// The commands of the bots in a group, each named for its bot when more than one bot has it.
fn merge(infos: &[tl::enums::BotInfo], users: &[tl::enums::User]) -> Vec<Command> {
let mut per_bot: Vec<(String, Vec<Command>)> = Vec::new();
for info in infos {
let tl::enums::BotInfo::Info(raw) = info;
let Some(id) = raw.user_id else { continue };
let Some(name) = users.iter().find(|u| matches!(u, tl::enums::User::User(u) if u.id == id)).and_then(username_of) else { continue };
per_bot.push((name, from_info(info)));
}
label(&per_bot)
}
/// Each bot's commands as the group's menu, a name that two bots share written with the bot's after it.
fn label(per_bot: &[(String, Vec<Command>)]) -> Vec<Command> {
let mut taken: HashMap<&str, usize> = HashMap::new();
for (_, list) in per_bot {
for c in list {
*taken.entry(c.name.as_str()).or_default() += 1;
}
}
let mut out = Vec::new();
for (bot, list) in per_bot {
for c in list {
let name = if taken.get(c.name.as_str()).copied().unwrap_or(0) > 1 { format!("{}@{bot}", c.name) } else { c.name.clone() };
out.push(Command { name, description: c.description.clone(), source: Some(format!("@{bot}")) });
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
@@ -88,9 +155,56 @@ mod tests {
assert_eq!(
from_info(&info),
vec![
Command { name: "help".into(), description: "Show help for bot commands".into() },
Command { name: "weblink".into(), description: "Get available link to ZLibrary".into() },
Command { name: "help".into(), description: "Show help for bot commands".into(), source: None },
Command { name: "weblink".into(), description: "Get available link to ZLibrary".into(), source: None },
]
);
}
fn cmd(name: &str, description: &str) -> Command {
Command { name: name.into(), description: description.into(), source: None }
}
#[test]
fn a_group_offers_each_bots_commands_and_names_the_bot_only_where_two_share_one() {
let per_bot = vec![
("rose_bot".to_string(), vec![cmd("help", "Rose help"), cmd("warn", "Warn a user")]),
("music_bot".to_string(), vec![cmd("help", "Music help"), cmd("play", "Play a song")]),
];
let got: Vec<(String, Option<String>)> = label(&per_bot).into_iter().map(|c| (c.name, c.source)).collect();
assert_eq!(
got,
vec![
("help@rose_bot".to_string(), Some("@rose_bot".to_string())),
("warn".to_string(), Some("@rose_bot".to_string())),
("help@music_bot".to_string(), Some("@music_bot".to_string())),
("play".to_string(), Some("@music_bot".to_string())),
]
);
}
#[test]
fn a_lone_bots_commands_are_not_named_for_it() {
let got = label(&[("only_bot".to_string(), vec![cmd("start", "Begin")])]);
assert_eq!(got.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), vec!["start"]);
assert!(label(&[]).is_empty());
}
#[test]
fn a_bot_the_group_does_not_name_cannot_be_written_to_and_is_left_out() {
let info = tl::enums::BotInfo::Info(tl::types::BotInfo {
has_preview_medias: false,
user_id: Some(3),
description: None,
description_photo: None,
description_document: None,
commands: Some(vec![tl::enums::BotCommand::Command(tl::types::BotCommand { ephemeral: false, command: "ghost".into(), description: "Nobody".into() })]),
menu_button: None,
privacy_policy_url: None,
app_settings: None,
verifier_settings: None,
});
let users = vec![tl::enums::User::Empty(tl::types::UserEmpty { id: 3 })];
assert!(merge(&[info], &users).is_empty());
}
}
+4
View File
@@ -164,6 +164,10 @@ async fn stay(state: &AppState, config: &TelegramAccountConfig, account_id: &str
if let Err(e) = super::dialogs::sync(&state, &account_id, &handle).await {
tracing::warn!("telegram[{account_id}]: reading the conversations: {e:#}");
}
// After the conversations: what a folder holds by its rules is worked out from them.
if let Err(e) = super::folders::sync(&state, &account_id, &handle).await {
tracing::warn!("telegram[{account_id}]: reading the folders: {e:#}");
}
}
});
+23
View File
@@ -15,12 +15,35 @@ use grammers_client::tl;
/// How many conversations are read on connect. Telegram pages them a hundred at a time.
const LIMIT: usize = 200;
/// What the account's folders look at in a conversation, noted for working out what each folder holds.
fn note_for_folders(account_id: &str, dialog: &grammers_client::peer::Dialog) {
use super::folders::{Facts, Kind};
use grammers_client::peer::Peer;
let (kind, contact) = match dialog.peer() {
Peer::User(user) if user.is_bot() => (Kind::Bot, false),
Peer::User(user) => (Kind::Person, user.contact()),
Peer::Group(_) | Peer::Community(_) => (Kind::Group, false),
Peer::Channel(_) => (Kind::Broadcast, false),
};
let tl::enums::Dialog::Dialog(d) = &dialog.raw else { return };
let facts = Facts {
kind,
contact,
muted: super::notify::silent(&d.notify_settings, chrono::Utc::now().timestamp()),
unread: d.unread_count > 0 || d.unread_mark,
// Telegram keeps its archive as the folder numbered 1.
archived: d.folder_id == Some(1),
};
super::folders::note(account_id, peers::remote_id(dialog.peer().id()), facts);
}
/// Makes a buffer of each of the account's conversations, newest activity first, and records the last message of each.
pub async fn sync(state: &AppState, account_id: &str, handle: &Handle) -> Result<usize> {
let mut dialogs = handle.client.iter_dialogs();
let mut count = 0;
while count < LIMIT {
let Some(dialog) = dialogs.next().await.map_err(|e| anyhow::anyhow!("{e}"))? else { break };
note_for_folders(account_id, &dialog);
// A group with Topics is a conversation for each topic, found by asking the group.
if super::topics::is_forum(dialog.peer()) {
peers::rail_for(state, account_id, dialog.peer(), count as i64);
+734
View File
@@ -0,0 +1,734 @@
//! Chat folders: the tabs Telegram lets an account sort its chats into, kept in step with moho's categories.
//!
//! A folder is a name and a rule for what is in it: chats named one by one, and kinds of chat (contacts, groups,
//! channels, bots) with some taken back out (muted, read, archived, or named one by one). The window draws each folder
//! as a category on the account's page, and a change on either side is made on the other:
//!
//! - a folder made, renamed, deleted or moved in order on Telegram turns up as the same change to the category
//! (`telegramFolders`, said whenever the account's folders are read: on connect and on every
//! `updateDialogFilter*` Telegram sends);
//! - a category made, renamed, deleted or moved in order here is made on Telegram, and a conversation filed under a
//! category goes into that folder.
//!
//! A chat is in one category here and can be in several folders there. It is drawn under the first folder (in
//! Telegram's order) that holds it, and filing it somewhere else takes it out of that one and puts it in the new one;
//! the folders it is in besides are left as they are. Taking it out of a folder it is in by a rule - a chat in
//! "Groups" because it is a group - is done the way Telegram's own apps do it, by adding it to the folder's
//! exceptions.
//!
//! What Telegram does not allow is also not allowed here, and said in its words: a folder needs something in it, so
//! a category is made on Telegram when its first conversation is filed into it, and the last one cannot be taken
//! out. Groups are not conversations on the account's page (each is an entry on the rail), so they are not shown
//! under a category, but a folder that holds them still does, and an edit made here never touches them.
//!
//! Which chats a rule holds is worked out from what is known of each chat when the conversations are read - whether
//! it is a contact, muted, unread or archived - so a folder defined by "unread" is as current as that read.
use super::send::{explain, peer_for};
use super::{peers, Handle};
use crate::model::Buffer;
use crate::state::AppState;
use anyhow::{anyhow, Result};
use grammers_client::tl;
use grammers_session::types::PeerId;
use serde_json::{json, Value};
use std::collections::{HashMap, HashSet};
use std::sync::Mutex;
/// The first number a folder made by hand may have: 0 is the list of every chat.
const FIRST_ID: i32 = 2;
/// What kind of chat a conversation is, as a folder's rules tell them apart.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Kind {
Person,
Bot,
Group,
Broadcast,
}
/// What a folder's rules look at in a chat.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Facts {
pub kind: Kind,
pub contact: bool,
pub muted: bool,
pub unread: bool,
pub archived: bool,
}
/// A folder's rules, read from Telegram's description of it.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Rules {
contacts: bool,
non_contacts: bool,
groups: bool,
broadcasts: bool,
bots: bool,
exclude_muted: bool,
exclude_read: bool,
exclude_archived: bool,
/// Chats named one by one, pinned ones among them, as the ids this client calls them by.
include: Vec<String>,
exclude: Vec<String>,
}
impl Rules {
/// Whether a chat is in by the kinds of chat the folder takes (and not taken back out), leaving the chats named
/// one by one aside.
fn by_kind(&self, facts: &Facts) -> bool {
let wanted = match facts.kind {
Kind::Person => (self.contacts && facts.contact) || (self.non_contacts && !facts.contact),
Kind::Bot => self.bots,
Kind::Group => self.groups,
Kind::Broadcast => self.broadcasts,
};
wanted && !(self.exclude_muted && facts.muted) && !(self.exclude_read && !facts.unread) && !(self.exclude_archived && facts.archived)
}
/// Whether the folder holds a chat.
pub fn holds(&self, remote: &str, facts: Option<&Facts>) -> bool {
if self.include.iter().any(|r| r == remote) {
return true;
}
if self.exclude.iter().any(|r| r == remote) {
return false;
}
facts.is_some_and(|f| self.by_kind(f))
}
}
/// A folder as Telegram holds it, with what is needed to change it.
#[derive(Clone, Debug)]
pub struct Folder {
pub id: i32,
pub title: String,
pub emoticon: Option<String>,
pub rules: Rules,
/// Shared folders (a link anyone can join by) have no rules and no exceptions.
pub shared: bool,
raw: tl::enums::DialogFilter,
}
/// The chat an input peer names, as this client calls it; `None` for none.
pub fn remote_of_input(peer: &tl::enums::InputPeer, own: i64) -> Option<String> {
use tl::enums::InputPeer as P;
let id = match peer {
P::PeerSelf => PeerId::user_unchecked(own),
P::User(u) => PeerId::user_unchecked(u.user_id),
P::UserFromMessage(u) => PeerId::user_unchecked(u.user_id),
P::Chat(c) => PeerId::chat_unchecked(c.chat_id),
P::Channel(c) => PeerId::channel_unchecked(c.channel_id),
P::ChannelFromMessage(c) => PeerId::channel_unchecked(c.channel_id),
P::Empty => return None,
};
Some(peers::remote_id(id))
}
fn text_of(title: &tl::enums::TextWithEntities) -> String {
let tl::enums::TextWithEntities::Entities(t) = title;
t.text.clone()
}
fn remotes(list: &[tl::enums::InputPeer], own: i64) -> Vec<String> {
list.iter().filter_map(|p| remote_of_input(p, own)).collect()
}
/// A folder read from Telegram's description of it; `None` for the entry that stands for every chat.
pub fn folder_of(raw: &tl::enums::DialogFilter, own: i64) -> Option<Folder> {
match raw {
tl::enums::DialogFilter::Default => None,
tl::enums::DialogFilter::Filter(f) => {
let mut include = remotes(&f.include_peers, own);
for pinned in remotes(&f.pinned_peers, own) {
if !include.contains(&pinned) {
include.push(pinned);
}
}
Some(Folder {
id: f.id,
title: text_of(&f.title),
emoticon: f.emoticon.clone(),
rules: Rules {
contacts: f.contacts,
non_contacts: f.non_contacts,
groups: f.groups,
broadcasts: f.broadcasts,
bots: f.bots,
exclude_muted: f.exclude_muted,
exclude_read: f.exclude_read,
exclude_archived: f.exclude_archived,
include,
exclude: remotes(&f.exclude_peers, own),
},
shared: false,
raw: raw.clone(),
})
}
tl::enums::DialogFilter::Chatlist(f) => {
let mut include = remotes(&f.include_peers, own);
for pinned in remotes(&f.pinned_peers, own) {
if !include.contains(&pinned) {
include.push(pinned);
}
}
Some(Folder { id: f.id, title: text_of(&f.title), emoticon: f.emoticon.clone(), rules: Rules { include, ..Default::default() }, shared: true, raw: raw.clone() })
}
}
}
/// The conversations a folder holds, by the ids this client calls them by: every one it names, and every one of the
/// chats whose facts are known that its rules take.
pub fn members(folder: &Folder, facts: &HashMap<String, Facts>) -> Vec<String> {
let mut out: Vec<String> = folder.rules.include.clone();
let mut seen: HashSet<&str> = folder.rules.include.iter().map(String::as_str).collect();
let mut found: Vec<&String> = facts.keys().collect();
found.sort();
for remote in found {
if !seen.contains(remote.as_str()) && folder.rules.holds(remote, facts.get(remote)) {
seen.insert(remote);
out.push(remote.clone());
}
}
out
}
/// The first folder, in the order given, that holds a chat: the one it is drawn under.
pub fn home_of(folders: &[Folder], facts: &HashMap<String, Facts>, remote: &str) -> Option<i32> {
folders.iter().find(|f| f.rules.holds(remote, facts.get(remote))).map(|f| f.id)
}
/// The folder with a chat put in it.
pub fn with_peer(raw: &tl::enums::DialogFilter, peer: &tl::enums::InputPeer, own: i64) -> Option<tl::enums::DialogFilter> {
let remote = remote_of_input(peer, own)?;
let mut edited = raw.clone();
match &mut edited {
tl::enums::DialogFilter::Default => return None,
tl::enums::DialogFilter::Filter(f) => {
f.exclude_peers.retain(|p| remote_of_input(p, own).as_deref() != Some(remote.as_str()));
if !f.include_peers.iter().chain(&f.pinned_peers).any(|p| remote_of_input(p, own).as_deref() == Some(remote.as_str())) {
f.include_peers.push(peer.clone());
}
}
tl::enums::DialogFilter::Chatlist(f) => {
if !f.include_peers.iter().chain(&f.pinned_peers).any(|p| remote_of_input(p, own).as_deref() == Some(remote.as_str())) {
f.include_peers.push(peer.clone());
}
}
}
Some(edited)
}
/// The folder with a chat taken out: no longer named, and - if its rules would still take it - made an exception.
pub fn without_peer(raw: &tl::enums::DialogFilter, peer: &tl::enums::InputPeer, own: i64, facts: Option<&Facts>) -> Option<tl::enums::DialogFilter> {
let remote = remote_of_input(peer, own)?;
let named = |p: &tl::enums::InputPeer| remote_of_input(p, own).as_deref() == Some(remote.as_str());
let mut edited = raw.clone();
match &mut edited {
tl::enums::DialogFilter::Default => return None,
tl::enums::DialogFilter::Filter(f) => {
f.include_peers.retain(|p| !named(p));
f.pinned_peers.retain(|p| !named(p));
let kept = folder_of(&tl::enums::DialogFilter::Filter(f.clone()), own)?;
if facts.is_some_and(|facts| kept.rules.by_kind(facts)) && !f.exclude_peers.iter().any(named) {
f.exclude_peers.push(peer.clone());
}
}
tl::enums::DialogFilter::Chatlist(f) => {
f.include_peers.retain(|p| !named(p));
f.pinned_peers.retain(|p| !named(p));
}
}
Some(edited)
}
/// A folder with another name.
pub fn renamed(raw: &tl::enums::DialogFilter, name: &str) -> Option<tl::enums::DialogFilter> {
let title = tl::enums::TextWithEntities::Entities(tl::types::TextWithEntities { text: name.to_string(), entities: Vec::new() });
let mut edited = raw.clone();
match &mut edited {
tl::enums::DialogFilter::Default => return None,
tl::enums::DialogFilter::Filter(f) => f.title = title,
tl::enums::DialogFilter::Chatlist(f) => f.title = title,
}
Some(edited)
}
/// A folder that holds one chat and nothing else: how a category becomes a folder.
pub fn starting_with(id: i32, name: &str, peer: &tl::enums::InputPeer) -> tl::enums::DialogFilter {
tl::enums::DialogFilter::Filter(tl::types::DialogFilter {
contacts: false,
non_contacts: false,
groups: false,
broadcasts: false,
bots: false,
exclude_muted: false,
exclude_read: false,
exclude_archived: false,
title_noanimate: false,
id,
title: tl::enums::TextWithEntities::Entities(tl::types::TextWithEntities { text: name.to_string(), entities: Vec::new() }),
emoticon: None,
color: None,
pinned_peers: Vec::new(),
include_peers: vec![peer.clone()],
exclude_peers: Vec::new(),
})
}
/// The number for a folder made now: the next one after those in use.
pub fn next_id(folders: &[tl::enums::DialogFilter]) -> i32 {
folders
.iter()
.map(|f| match f {
tl::enums::DialogFilter::Default => 0,
tl::enums::DialogFilter::Filter(f) => f.id,
tl::enums::DialogFilter::Chatlist(f) => f.id,
})
.max()
.map_or(FIRST_ID, |m| (m + 1).max(FIRST_ID))
}
fn id_of(raw: &tl::enums::DialogFilter) -> i32 {
match raw {
tl::enums::DialogFilter::Default => 0,
tl::enums::DialogFilter::Filter(f) => f.id,
tl::enums::DialogFilter::Chatlist(f) => f.id,
}
}
// What is known of each account's chats and folders, kept for the window to ask for and for the edits below.
type FactsByAccount = Mutex<HashMap<String, HashMap<String, Facts>>>;
fn all_facts() -> &'static FactsByAccount {
static F: std::sync::OnceLock<FactsByAccount> = std::sync::OnceLock::new();
F.get_or_init(Default::default)
}
type Held = Mutex<HashMap<String, Vec<Value>>>;
fn held() -> &'static Held {
static H: std::sync::OnceLock<Held> = std::sync::OnceLock::new();
H.get_or_init(Default::default)
}
/// Notes what a folder's rules look at in a chat, as the conversations are read.
pub fn note(account_id: &str, remote: String, facts: Facts) {
all_facts().lock().unwrap_or_else(|e| e.into_inner()).entry(account_id.to_string()).or_default().insert(remote, facts);
}
fn facts_of(account_id: &str) -> HashMap<String, Facts> {
all_facts().lock().unwrap_or_else(|e| e.into_inner()).get(account_id).cloned().unwrap_or_default()
}
/// Forgets an account's folders when it goes.
pub fn forget(account_id: &str) {
all_facts().lock().unwrap_or_else(|e| e.into_inner()).remove(account_id);
held().lock().unwrap_or_else(|e| e.into_inner()).remove(account_id);
}
/// The folders as the window is told of them: in Telegram's order, each with the conversations it holds.
pub fn summary(folders: &[Folder], facts: &HashMap<String, Facts>) -> Vec<Value> {
folders
.iter()
.map(|f| json!({ "id": f.id, "name": f.title, "emoticon": f.emoticon, "members": members(f, facts), "shared": f.shared }))
.collect()
}
/// What the window last heard of an account's folders.
pub fn current(account_id: &str) -> Value {
Value::Array(held().lock().unwrap_or_else(|e| e.into_inner()).get(account_id).cloned().unwrap_or_default())
}
async fn read(handle: &Handle) -> Result<Vec<tl::enums::DialogFilter>> {
let answer = handle.client.invoke(&tl::functions::messages::GetDialogFilters {}).await.map_err(|e| anyhow!("{}", explain(&e)))?;
let tl::enums::messages::DialogFilters::Filters(found) = answer;
Ok(found.filters)
}
/// Reads the account's folders and tells the window, if they are not what it was last told.
pub async fn sync(state: &AppState, account_id: &str, handle: &Handle) -> Result<Vec<Value>> {
let raws = read(handle).await?;
let folders: Vec<Folder> = raws.iter().filter_map(|r| folder_of(r, handle.own.user_id)).collect();
let list = summary(&folders, &facts_of(account_id));
let changed = {
let mut all = held().lock().unwrap_or_else(|e| e.into_inner());
let before = all.insert(account_id.to_string(), list.clone());
before.as_ref() != Some(&list)
};
if changed {
state.events.emit("telegramFolders", json!({ "accountId": account_id, "folders": list }));
}
Ok(list)
}
/// Telegram says the account's folders changed, on this client or another: read them again.
pub fn changed(state: &AppState, account_id: &str, handle: &Handle) {
let (state, account_id, handle) = (state.clone(), account_id.to_string(), handle.clone());
tokio::spawn(async move {
if let Err(e) = sync(&state, &account_id, &handle).await {
tracing::debug!("telegram[{account_id}]: reading the folders: {e:#}");
}
});
}
fn handle_for(state: &AppState, account_id: &str) -> Result<Handle, String> {
state.runtime.telegram_handle(account_id).ok_or_else(|| "that Telegram account is not connected".to_string())
}
async fn save(handle: &Handle, id: i32, filter: Option<tl::enums::DialogFilter>) -> Result<(), String> {
handle.client.invoke(&tl::functions::messages::UpdateDialogFilter { id, filter }).await.map_err(|e| folder_words(&explain(&e)))?;
Ok(())
}
/// Telegram's refusals about folders, said so a person can act on them.
fn folder_words(raw: &str) -> String {
// Telegram's codes arrive as FILTER_INCLUDE_EMPTY or, once worded for a person, as "filter include empty".
let upper = raw.to_uppercase().replace(' ', "_");
for (code, words) in [
("FILTER_INCLUDE_EMPTY", "Telegram does not keep an empty folder: it needs at least one chat in it. Delete the folder instead"),
("FILTER_TITLE_EMPTY", "A folder needs a name"),
("DIALOG_FILTERS_TOO_MUCH", "This account already has as many folders as Telegram allows"),
("FILTER_NOT_SUPPORTED", "Telegram does not allow that change to this folder"),
("CHATLISTS_TOO_MUCH", "Telegram will not share any more folders from this account"),
("PEERS_LIST_EMPTY", "Telegram does not keep an empty folder: it needs at least one chat in it. Delete the folder instead"),
] {
if upper.contains(code) {
return words.to_string();
}
}
raw.to_string()
}
/// A folder by number, in the order Telegram has them.
fn find(folders: &[Folder], id: i32) -> Result<&Folder, String> {
folders.iter().find(|f| f.id == id).ok_or_else(|| "Telegram has no such folder any more".to_string())
}
/// Makes a folder from a conversation: how a category made here comes to exist on Telegram.
pub async fn create(state: &AppState, buffer: &Buffer, name: &str) -> Result<(), String> {
let name = name.trim();
if name.is_empty() {
return Err("A folder needs a name".to_string());
}
let (handle, peer) = peer_for(state, buffer).await?;
let raws = read(&handle).await.map_err(|e| e.to_string())?;
let filter = starting_with(next_id(&raws), name, &peer.into());
save(&handle, id_of(&filter), Some(filter)).await?;
sync(state, &buffer.account_id, &handle).await.map_err(|e| e.to_string())?;
Ok(())
}
/// Gives a folder another name.
pub async fn rename(state: &AppState, account_id: &str, id: i32, name: &str) -> Result<(), String> {
let name = name.trim();
if name.is_empty() {
return Err("A folder needs a name".to_string());
}
let handle = handle_for(state, account_id)?;
let raws = read(&handle).await.map_err(|e| e.to_string())?;
let raw = raws.iter().find(|r| id_of(r) == id && id != 0).ok_or("Telegram has no such folder any more")?;
save(&handle, id, renamed(raw, name)).await?;
sync(state, account_id, &handle).await.map_err(|e| e.to_string())?;
Ok(())
}
/// Deletes a folder. The chats in it stay where they are; only the tab goes.
pub async fn delete(state: &AppState, account_id: &str, id: i32) -> Result<(), String> {
let handle = handle_for(state, account_id)?;
if id == 0 {
return Err("The list of every chat cannot be deleted".to_string());
}
save(&handle, id, None).await?;
sync(state, account_id, &handle).await.map_err(|e| e.to_string())?;
Ok(())
}
/// Puts the account's folders in the order given. Folders not named keep their places after those that are.
pub async fn reorder(state: &AppState, account_id: &str, order: &[i32]) -> Result<(), String> {
let handle = handle_for(state, account_id)?;
let raws = read(&handle).await.map_err(|e| e.to_string())?;
let all: Vec<i32> = raws.iter().map(id_of).collect();
let wanted = ordered(&all, order);
if wanted == all {
return Ok(());
}
handle.client.invoke(&tl::functions::messages::UpdateDialogFiltersOrder { order: wanted }).await.map_err(|e| folder_words(&explain(&e)))?;
sync(state, account_id, &handle).await.map_err(|e| e.to_string())?;
Ok(())
}
/// The folders' numbers in the order asked for. Those named take the places of those named, in the order named; any
/// not named - the entry for every chat, which is not drawn and so cannot have been moved - stay where they were.
pub fn ordered(all: &[i32], asked: &[i32]) -> Vec<i32> {
let mut named: Vec<i32> = Vec::new();
for id in asked {
if all.contains(id) && !named.contains(id) {
named.push(*id);
}
}
let mut fill = named.clone().into_iter();
let mut out = all.to_vec();
for slot in out.iter_mut().filter(|id| named.contains(id)) {
*slot = fill.next().unwrap_or(*slot);
}
out
}
/// Files a conversation under a folder (`to`), or under none: out of the one it is drawn under and into the new one.
pub async fn file(state: &AppState, buffer: &Buffer, to: Option<i32>) -> Result<(), String> {
let (handle, peer) = peer_for(state, buffer).await?;
let own = handle.own.user_id;
let input: tl::enums::InputPeer = peer.into();
let remote = remote_of_input(&input, own).ok_or("that is not a chat Telegram can file")?;
let raws = read(&handle).await.map_err(|e| e.to_string())?;
let folders: Vec<Folder> = raws.iter().filter_map(|r| folder_of(r, own)).collect();
let facts = facts_of(&buffer.account_id);
let from = home_of(&folders, &facts, &remote);
if from == to {
return Ok(());
}
// Put in first: if Telegram refuses to take it out of the old one, it is at least where it was asked to go.
if let Some(id) = to {
let folder = find(&folders, id)?;
let edited = with_peer(&folder.raw, &input, own).ok_or("Telegram has no such folder any more")?;
save(&handle, id, Some(edited)).await?;
}
if let Some(id) = from {
let folder = find(&folders, id)?;
let edited = without_peer(&folder.raw, &input, own, facts.get(&remote)).ok_or("Telegram has no such folder any more")?;
if let Err(e) = save(&handle, id, Some(edited)).await {
let _ = sync(state, &buffer.account_id, &handle).await;
return Err(format!("It is in the new folder, but could not be taken out of \"{}\": {e}", folder.title));
}
}
sync(state, &buffer.account_id, &handle).await.map_err(|e| e.to_string())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const OWN: i64 = 1;
fn user(id: i64) -> tl::enums::InputPeer {
tl::enums::InputPeer::User(tl::types::InputPeerUser { user_id: id, access_hash: 7 })
}
fn channel(id: i64) -> tl::enums::InputPeer {
tl::enums::InputPeer::Channel(tl::types::InputPeerChannel { channel_id: id, access_hash: 7 })
}
fn filter(id: i32, name: &str, edit: impl FnOnce(&mut tl::types::DialogFilter)) -> tl::enums::DialogFilter {
let tl::enums::DialogFilter::Filter(mut f) = starting_with(id, name, &user(100)) else { unreachable!() };
f.include_peers.clear();
edit(&mut f);
tl::enums::DialogFilter::Filter(f)
}
/// A channel's id as this client writes it.
fn ch(id: i64) -> String {
peers::remote_id(PeerId::channel_unchecked(id))
}
fn facts(kind: Kind) -> Facts {
Facts { kind, contact: false, muted: false, unread: true, archived: false }
}
#[test]
fn a_chat_is_called_by_the_id_this_client_gives_it() {
assert_eq!(remote_of_input(&user(100), OWN).as_deref(), Some("100"));
assert_eq!(remote_of_input(&channel(555), OWN), Some(ch(555)));
assert!(ch(555).starts_with("-100") && ch(555).ends_with("555"));
assert_eq!(remote_of_input(&tl::enums::InputPeer::Chat(tl::types::InputPeerChat { chat_id: 9 }), OWN).as_deref(), Some("-9"));
assert_eq!(remote_of_input(&tl::enums::InputPeer::PeerSelf, OWN).as_deref(), Some("1"), "saved messages are the account's own chat");
assert_eq!(remote_of_input(&tl::enums::InputPeer::Empty, OWN), None);
}
#[test]
fn a_folder_holds_the_chats_it_names_and_the_kinds_it_takes_less_the_exceptions() {
let raw = filter(2, "Work", |f| {
f.bots = true;
f.groups = true;
f.exclude_muted = true;
f.include_peers = vec![user(100)];
f.pinned_peers = vec![user(101)];
f.exclude_peers = vec![user(200)];
});
let folder = folder_of(&raw, OWN).unwrap();
assert_eq!((folder.id, folder.title.as_str(), folder.shared), (2, "Work", false));
let mut known = HashMap::new();
known.insert("100".to_string(), facts(Kind::Person));
known.insert("200".to_string(), facts(Kind::Bot));
known.insert("201".to_string(), facts(Kind::Bot));
known.insert(ch(300), facts(Kind::Group));
known.insert(ch(301), Facts { muted: true, ..facts(Kind::Group) });
known.insert(ch(302), facts(Kind::Broadcast));
known.insert("102".to_string(), facts(Kind::Person));
// Named ones first (pinned among them, though Telegram keeps them apart), then the kinds, in a steady order.
assert_eq!(members(&folder, &known), vec!["100".to_string(), "101".to_string(), ch(300), "201".to_string()]);
}
#[test]
fn contacts_and_everyone_else_are_told_apart() {
let raw = filter(2, "People", |f| f.contacts = true);
let folder = folder_of(&raw, OWN).unwrap();
let friend = Facts { contact: true, ..facts(Kind::Person) };
assert!(folder.rules.holds("5", Some(&friend)));
assert!(!folder.rules.holds("6", Some(&facts(Kind::Person))));
assert!(!folder.rules.holds("7", None), "a chat nothing is known of is held only if it is named");
let others = folder_of(&filter(3, "Strangers", |f| f.non_contacts = true), OWN).unwrap();
assert!(others.rules.holds("6", Some(&facts(Kind::Person))));
assert!(!others.rules.holds("5", Some(&friend)));
}
#[test]
fn read_and_archived_chats_can_be_left_out_of_a_folder() {
let raw = filter(2, "Unread", |f| {
f.groups = true;
f.exclude_read = true;
f.exclude_archived = true;
});
let folder = folder_of(&raw, OWN).unwrap();
assert!(folder.rules.holds("-1", Some(&facts(Kind::Group))));
assert!(!folder.rules.holds("-1", Some(&Facts { unread: false, ..facts(Kind::Group) })));
assert!(!folder.rules.holds("-1", Some(&Facts { archived: true, ..facts(Kind::Group) })));
}
#[test]
fn the_entry_for_every_chat_is_not_a_folder() {
assert!(folder_of(&tl::enums::DialogFilter::Default, OWN).is_none());
}
#[test]
fn a_chat_is_drawn_under_the_first_folder_that_holds_it() {
let a = folder_of(&filter(2, "Groups", |f| f.groups = true), OWN).unwrap();
let b = folder_of(&filter(3, "Mine", |f| f.include_peers = vec![channel(300)]), OWN).unwrap();
let mut known = HashMap::new();
known.insert(ch(300), facts(Kind::Group));
known.insert("5".to_string(), facts(Kind::Person));
assert_eq!(home_of(&[a.clone(), b.clone()], &known, &ch(300)), Some(2));
assert_eq!(home_of(&[b, a], &known, &ch(300)), Some(3));
assert_eq!(home_of(&[], &known, "5"), None);
}
#[test]
fn putting_a_chat_in_names_it_once_and_lifts_an_exception() {
let raw = filter(2, "Work", |f| f.exclude_peers = vec![user(100)]);
let put = with_peer(&raw, &user(100), OWN).unwrap();
let tl::enums::DialogFilter::Filter(f) = &put else { unreachable!() };
assert_eq!(f.include_peers.len(), 1);
assert!(f.exclude_peers.is_empty());
let again = with_peer(&put, &user(100), OWN).unwrap();
let tl::enums::DialogFilter::Filter(f) = &again else { unreachable!() };
assert_eq!(f.include_peers.len(), 1, "a chat already named is not named twice");
}
#[test]
fn taking_a_chat_out_of_a_named_list_is_taking_it_off_the_list() {
let raw = filter(2, "Work", |f| {
f.include_peers = vec![user(100), user(101)];
f.pinned_peers = vec![user(100)];
});
let out = without_peer(&raw, &user(100), OWN, Some(&facts(Kind::Person))).unwrap();
let tl::enums::DialogFilter::Filter(f) = &out else { unreachable!() };
assert_eq!(remotes(&f.include_peers, OWN), vec!["101"]);
assert!(f.pinned_peers.is_empty());
assert!(f.exclude_peers.is_empty(), "nothing else held it, so there is no exception to make");
}
#[test]
fn taking_a_chat_out_of_a_folder_that_holds_it_by_a_rule_makes_it_an_exception() {
let raw = filter(2, "Groups", |f| f.groups = true);
let out = without_peer(&raw, &channel(300), OWN, Some(&facts(Kind::Group))).unwrap();
let tl::enums::DialogFilter::Filter(f) = &out else { unreachable!() };
assert_eq!(remotes(&f.exclude_peers, OWN), vec![ch(300)]);
let folder = folder_of(&out, OWN).unwrap();
assert!(!folder.rules.holds(&ch(300), Some(&facts(Kind::Group))));
assert!(folder.rules.holds(&ch(301), Some(&facts(Kind::Group))), "the others are still held");
// And not twice.
let again = without_peer(&out, &channel(300), OWN, Some(&facts(Kind::Group))).unwrap();
let tl::enums::DialogFilter::Filter(f) = &again else { unreachable!() };
assert_eq!(f.exclude_peers.len(), 1);
}
#[test]
fn a_shared_folder_has_no_exceptions_to_make() {
let raw = tl::enums::DialogFilter::Chatlist(tl::types::DialogFilterChatlist {
has_my_invites: false,
title_noanimate: false,
id: 4,
title: tl::enums::TextWithEntities::Entities(tl::types::TextWithEntities { text: "Shared".into(), entities: vec![] }),
emoticon: None,
color: None,
pinned_peers: vec![],
include_peers: vec![user(100), user(101)],
});
let folder = folder_of(&raw, OWN).unwrap();
assert!(folder.shared);
let out = without_peer(&raw, &user(100), OWN, Some(&facts(Kind::Person))).unwrap();
assert_eq!(folder_of(&out, OWN).unwrap().rules.include, vec!["101"]);
assert_eq!(folder_of(&renamed(&out, "Team").unwrap(), OWN).unwrap().title, "Team");
}
#[test]
fn renaming_keeps_everything_but_the_name() {
let raw = filter(2, "Work", |f| {
f.bots = true;
f.include_peers = vec![user(100)];
f.emoticon = Some("💼".into());
});
let renamed = renamed(&raw, "Job").unwrap();
let (before, after) = (folder_of(&raw, OWN).unwrap(), folder_of(&renamed, OWN).unwrap());
assert_eq!(after.title, "Job");
assert_eq!((before.rules, before.emoticon, before.id), (after.rules, after.emoticon, after.id));
assert!(super::renamed(&tl::enums::DialogFilter::Default, "x").is_none());
}
#[test]
fn a_new_folder_has_the_next_number_after_those_in_use_and_never_below_two() {
assert_eq!(next_id(&[]), 2);
assert_eq!(next_id(&[tl::enums::DialogFilter::Default]), 2);
assert_eq!(next_id(&[tl::enums::DialogFilter::Default, filter(2, "A", |_| {}), filter(7, "B", |_| {})]), 8);
}
#[test]
fn a_new_folder_starts_with_the_one_chat_it_was_made_for() {
let made = folder_of(&starting_with(3, "Friends", &user(100)), OWN).unwrap();
assert_eq!((made.id, made.title.as_str()), (3, "Friends"));
assert_eq!(made.rules.include, vec!["100"]);
assert!(!made.rules.holds("101", Some(&facts(Kind::Person))), "no kind of chat is taken until asked for");
}
#[test]
fn folders_are_put_in_the_order_asked_for_and_the_entry_for_every_chat_keeps_its_place() {
assert_eq!(ordered(&[0, 2, 3, 4], &[4, 2, 3]), vec![0, 4, 2, 3]);
assert_eq!(ordered(&[2, 3, 0, 4], &[4, 3]), vec![2, 4, 0, 3], "the two named swap places, the rest stay");
assert_eq!(ordered(&[0, 2, 3], &[9, 3, 3, 2]), vec![0, 3, 2], "numbers that are not folders, and repeats, are dropped");
assert_eq!(ordered(&[0, 2, 3], &[]), vec![0, 2, 3]);
}
#[test]
fn the_summary_is_in_telegrams_order_with_each_folders_conversations() {
let a = folder_of(&filter(3, "Second", |f| f.include_peers = vec![user(100)]), OWN).unwrap();
let b = folder_of(&filter(2, "First", |f| f.bots = true), OWN).unwrap();
let mut known = HashMap::new();
known.insert("200".to_string(), facts(Kind::Bot));
let out = summary(&[a, b], &known);
assert_eq!(out[0]["id"], 3);
assert_eq!(out[0]["members"], json!(["100"]));
assert_eq!(out[1]["name"], "First");
assert_eq!(out[1]["members"], json!(["200"]));
}
#[test]
fn telegrams_refusals_about_folders_are_said_in_words() {
assert!(folder_words("rpc error 400: FILTER_INCLUDE_EMPTY").contains("empty folder"));
assert!(folder_words("DIALOG_FILTERS_TOO_MUCH").contains("as many folders"));
// As the library words it after `explain`: seen from the real account.
assert!(folder_words("Telegram refused: filter include empty").contains("empty folder"));
assert_eq!(folder_words("SOMETHING_ELSE"), "SOMETHING_ELSE");
}
}
+1 -1
View File
@@ -73,7 +73,7 @@ fn title_of(doc: &tl::types::Document) -> String {
.unwrap_or_else(|| "GIF".to_string())
}
fn wrapper(doc: &tl::types::Document) -> Document {
pub(super) fn wrapper(doc: &tl::types::Document) -> Document {
Document::from_raw_media(tl::types::MessageMediaDocument {
nopremium: false,
spoiler: false,
+271
View File
@@ -0,0 +1,271 @@
//! Telegram links: what a `t.me` address or a `tg://` link points at, and taking the window there.
//!
//! A link names a public chat (`t.me/disclosetv`, `tg://resolve?domain=disclosetv`), a message in one
//! (`t.me/disclosetv/21953`, `...&post=21953`), a message in a chat the account is already in
//! (`t.me/c/1234567/89`, `tg://privatepost?channel=1234567&post=89`) or a private invite (`t.me/+hash`,
//! `tg://join?invite=hash`); in a group with topics the topic comes before the message
//! (`t.me/group/5/21953`). Opening one makes the conversation if the account has none (a chat it is not in is
//! joined, which is what a link to a channel asks for) and answers where it is, and which message to go to.
//!
//! The web page Telegram shows for a post has a "View in Telegram" button that opens `tg://resolve?domain=..&post=..`,
//! which is how a click in a browser comes here once moho is the handler for `tg:` links.
use super::peers;
use super::send::explain;
use super::{dialogs, topics, Handle};
use crate::model::Buffer;
use crate::state::AppState;
use grammers_client::peer::Peer;
use grammers_client::tl;
use grammers_session::types::PeerId;
use grammers_session::Session;
use serde_json::{json, Value};
/// What a link points at.
#[derive(Debug, PartialEq)]
pub enum Link {
/// A chat named by its public @name.
Public { name: String, topic: Option<i32>, post: Option<i32> },
/// A chat named by its number, which only an account that is in it can open.
Private { channel: i64, topic: Option<i32>, post: Option<i32> },
/// A private invite.
Invite(String),
}
fn number(text: &str) -> Option<i32> {
text.parse::<i32>().ok().filter(|n| *n > 0)
}
fn name_ok(name: &str) -> bool {
(5..=32).contains(&name.len()) && name.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn invite_ok(hash: &str) -> bool {
!hash.is_empty() && hash.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
/// Reads a `t.me` / `telegram.me` / `telegram.dog` address or a `tg://` link; `None` for anything else, including the
/// pages on those hosts that are not a chat (sticker packs, proxies, the web preview).
pub fn parse(input: &str) -> Option<Link> {
let url = url::Url::parse(input.trim()).ok()?;
let query = |key: &str| url.query_pairs().find(|(k, _)| k == key).map(|(_, v)| v.into_owned());
match url.scheme() {
"tg" => match url.host_str()? {
"resolve" => {
let name = query("domain")?;
if name == "telegrampassport" || !name_ok(&name) {
return None;
}
let topic = query("thread").or_else(|| query("topic")).and_then(|t| number(&t));
Some(Link::Public { name, topic, post: query("post").and_then(|p| number(&p)) })
}
"privatepost" => {
let channel: i64 = query("channel")?.parse().ok().filter(|c| *c > 0)?;
Some(Link::Private { channel, topic: query("thread").or_else(|| query("topic")).and_then(|t| number(&t)), post: query("post").and_then(|p| number(&p)) })
}
"join" => Some(Link::Invite(query("invite").filter(|h| invite_ok(h))?)),
_ => None,
},
"http" | "https" => {
if !matches!(url.host_str()?.trim_start_matches("www."), "t.me" | "telegram.me" | "telegram.dog") {
return None;
}
let parts: Vec<&str> = url.path_segments()?.filter(|p| !p.is_empty()).collect();
let first = *parts.first()?;
if let Some(hash) = first.strip_prefix('+') {
return invite_ok(hash).then(|| Link::Invite(hash.to_string()));
}
match first {
"joinchat" => parts.get(1).filter(|h| invite_ok(h)).map(|h| Link::Invite((*h).to_string())),
"c" => {
let channel: i64 = parts.get(1)?.parse().ok().filter(|c| *c > 0)?;
let (topic, post) = match (parts.get(2).and_then(|p| number(p)), parts.get(3).and_then(|p| number(p))) {
(Some(topic), Some(post)) => (Some(topic), Some(post)),
(Some(post), None) => (None, Some(post)),
_ => (None, None),
};
Some(Link::Private { channel, topic, post })
}
// Pages that are about something other than a chat.
"s" | "addstickers" | "addemoji" | "setlanguage" | "proxy" | "socks" | "share" | "login" | "iv" | "addtheme" | "bg" | "boost" | "m" | "addlist" | "invoice" | "giftcode" | "contact" => None,
name if name_ok(name) => {
let (topic, post) = match (parts.get(1).and_then(|p| number(p)), parts.get(2).and_then(|p| number(p))) {
(Some(topic), Some(post)) => (Some(topic), Some(post)),
(Some(post), None) => (None, Some(post)),
_ => (None, None),
};
Some(Link::Public { name: name.to_string(), topic, post })
}
_ => None,
}
}
_ => None,
}
}
/// Where a link led.
pub struct Opened {
pub account_id: String,
pub buffer: Buffer,
pub message_id: Option<String>,
}
/// The first connected Telegram account, when the window did not say which.
fn account_for(state: &AppState, wanted: Option<&str>) -> Result<(String, Handle), String> {
if let Some(id) = wanted {
return state.runtime.telegram_handle(id).map(|h| (id.to_string(), h)).ok_or_else(|| "that Telegram account is not connected".to_string());
}
state
.accounts
.all_telegram()
.into_iter()
.find_map(|a| {
let id = a.account_id();
state.runtime.telegram_handle(&id).map(|h| (id, h))
})
.ok_or_else(|| "Add a Telegram account, and wait for it to connect, to open that link".to_string())
}
/// Whether the chat is one this account has a conversation for.
fn known(state: &AppState, account_id: &str, peer: &Peer) -> bool {
let remote = peers::remote_id(peer.id());
state.runtime.list_buffers().iter().any(|b| b.account_id == account_id && b.remote_id.as_deref().is_some_and(|r| r == remote || r.strip_prefix(remote.as_str()).is_some_and(|rest| rest.starts_with(':'))))
}
fn chats_of(updates: &tl::enums::Updates) -> Vec<tl::enums::Chat> {
match updates {
tl::enums::Updates::Updates(u) => u.chats.clone(),
tl::enums::Updates::Combined(u) => u.chats.clone(),
_ => Vec::new(),
}
}
/// Joins a chat by its invite, and says which chat it was.
async fn join_invite(state: &AppState, account_id: &str, handle: &Handle, hash: &str) -> Result<Peer, String> {
let checked = handle.client.invoke(&tl::functions::messages::CheckChatInvite { hash: hash.to_string() }).await.map_err(|e| invite_words(&e))?;
// Already in it: the invite names a chat the account has.
if let tl::enums::ChatInvite::Already(already) = checked {
return Ok(Peer::from_raw(&handle.client, already.chat));
}
let answer = handle.client.invoke(&tl::functions::messages::ImportChatInvite { hash: hash.to_string() }).await.map_err(|e| invite_words(&e))?;
let tl::enums::messages::ChatInviteJoinResult::Ok(joined) = answer else {
return Err("That chat has to be joined from Telegram's own app first".to_string());
};
let chat = chats_of(&joined.updates).into_iter().next().ok_or("Telegram did not say which chat that was")?;
let peer = Peer::from_raw(&handle.client, chat);
let _ = dialogs::sync(state, account_id, handle).await;
Ok(peer)
}
fn invite_words(e: &grammers_client::InvocationError) -> String {
match e {
grammers_client::InvocationError::Rpc(r) if r.name == "INVITE_HASH_EXPIRED" || r.name == "INVITE_HASH_INVALID" => "That invite link is no longer valid".to_string(),
grammers_client::InvocationError::Rpc(r) if r.name == "INVITE_REQUEST_SENT" => "Your request to join was sent: an administrator has to accept it".to_string(),
other => explain(other),
}
}
/// The conversation a message of a chat is in: the chat's own, or in a group with topics the topic's.
fn buffer_in(state: &AppState, account_id: &str, peer: &Peer, topic: Option<i32>, post: Option<i32>) -> Buffer {
if topics::is_forum(peer) {
let remote = peers::remote_id(peer.id());
return match (topic, post) {
(Some(topic), _) => topics::buffer_for_topic(state, account_id, peer, topic, None),
(None, Some(post)) => topics::holding(state, account_id, &remote, post).unwrap_or_else(|| topics::buffer_for_topic(state, account_id, peer, topics::GENERAL, None)),
(None, None) => topics::buffer_for_topic(state, account_id, peer, topics::GENERAL, None),
};
}
peers::buffer_for(state, account_id, peer)
}
/// Opens a link: finds or makes the conversation it points into, joining the chat if the account is not in it.
pub async fn open(state: &AppState, account: Option<&str>, input: &str) -> Result<Opened, String> {
let link = parse(input).ok_or_else(|| "That is not a Telegram link moho can open".to_string())?;
let (account_id, handle) = account_for(state, account)?;
let (peer, topic, post) = match link {
Link::Invite(hash) => (join_invite(state, &account_id, &handle, &hash).await?, None, None),
Link::Public { name, topic, post } => {
let peer = handle.client.resolve_username(&name).await.map_err(|e| explain(&e))?.ok_or_else(|| format!("Nobody on Telegram is called @{name}"))?;
if !matches!(peer, Peer::User(_)) && !known(state, &account_id, &peer) {
// A link to a channel asks to be in it.
let peer_ref = peer.to_ref().await.ok().flatten().ok_or("Telegram did not say how to reach that chat")?;
handle.client.join_chat(peer_ref).await.map_err(|e| invite_words(&e))?;
let _ = dialogs::sync(state, &account_id, &handle).await;
}
(peer, topic, post)
}
Link::Private { channel, topic, post } => {
let peer_ref = handle.session.peer_ref(PeerId::channel_unchecked(channel)).await.ok().flatten().ok_or("This account is not in that chat, so a link by its number cannot open it")?;
(handle.client.resolve_peer(peer_ref).await.map_err(|e| explain(&e))?, topic, post)
}
};
let buffer = buffer_in(state, &account_id, &peer, topic, post);
Ok(Opened { account_id, buffer, message_id: post.map(|p| p.to_string()) })
}
/// What the window is told.
pub fn answer(opened: &Opened) -> Value {
json!({ "accountId": opened.account_id, "bufferId": opened.buffer.id, "messageId": opened.message_id })
}
#[cfg(test)]
mod tests {
use super::*;
fn public(name: &str, topic: Option<i32>, post: Option<i32>) -> Option<Link> {
Some(Link::Public { name: name.into(), topic, post })
}
#[test]
fn a_web_link_to_a_post_names_the_channel_and_the_post() {
assert_eq!(parse("https://t.me/disclosetv/21953"), public("disclosetv", None, Some(21953)));
assert_eq!(parse("https://t.me/disclosetv/21953?single"), public("disclosetv", None, Some(21953)));
assert_eq!(parse("http://telegram.me/disclosetv"), public("disclosetv", None, None));
assert_eq!(parse("https://www.t.me/disclosetv/5"), public("disclosetv", None, Some(5)));
}
#[test]
fn in_a_group_with_topics_the_topic_comes_before_the_message() {
assert_eq!(parse("https://t.me/somegroup/7/21953"), public("somegroup", Some(7), Some(21953)));
assert_eq!(parse("tg://resolve?domain=somegroup&post=21953&thread=7"), public("somegroup", Some(7), Some(21953)));
}
#[test]
fn the_app_link_a_browsers_view_in_telegram_button_opens_is_read() {
assert_eq!(parse("tg://resolve?domain=disclosetv&post=21953"), public("disclosetv", None, Some(21953)));
assert_eq!(parse("tg://resolve?domain=disclosetv"), public("disclosetv", None, None));
// A bot started with a parameter is the bot's chat.
assert_eq!(parse("tg://resolve?domain=zlibrary_bot&start=abc"), public("zlibrary_bot", None, None));
assert_eq!(parse("tg://resolve?domain=telegrampassport&bot_id=1"), None);
}
#[test]
fn a_chat_named_by_its_number_is_private() {
assert_eq!(parse("https://t.me/c/1234567/89"), Some(Link::Private { channel: 1234567, topic: None, post: Some(89) }));
assert_eq!(parse("https://t.me/c/1234567/5/89"), Some(Link::Private { channel: 1234567, topic: Some(5), post: Some(89) }));
assert_eq!(parse("tg://privatepost?channel=1234567&post=89"), Some(Link::Private { channel: 1234567, topic: None, post: Some(89) }));
assert_eq!(parse("https://t.me/c/notanumber/89"), None);
}
#[test]
fn invites_are_read_in_every_form() {
let invite = |h: &str| Some(Link::Invite(h.to_string()));
assert_eq!(parse("https://t.me/+AbC_d-123"), invite("AbC_d-123"));
assert_eq!(parse("https://t.me/joinchat/AbC_d-123"), invite("AbC_d-123"));
assert_eq!(parse("tg://join?invite=AbC_d-123"), invite("AbC_d-123"));
assert_eq!(parse("tg://join?invite="), None);
}
#[test]
fn pages_that_are_not_a_chat_and_other_hosts_are_left_alone() {
assert_eq!(parse("https://t.me/addstickers/SomePack"), None);
assert_eq!(parse("https://t.me/s/disclosetv"), None);
assert_eq!(parse("https://t.me/proxy?server=x&port=1"), None);
assert_eq!(parse("https://t.me/"), None);
assert_eq!(parse("https://t.me/ab"), None, "too short to be a name");
assert_eq!(parse("https://example.com/disclosetv/1"), None);
assert_eq!(parse("https://t.me.evil.example/disclosetv/1"), None);
assert_eq!(parse("irc://irc.libera.chat/moho"), None);
assert_eq!(parse("not a link"), None);
}
}
+223 -7
View File
@@ -10,9 +10,20 @@
//! - `telegramLoginStatus` - what it is doing, for a line of text
//! - `telegramLoginNeeds` - what Telegram is asking for: `step` is `code` or `password` (with the password's hint),
//! and `error` says why the last answer was refused
//! - `telegramLoginQr` - a picture to scan, for a sign-in by QR code (below)
//! - `telegramLoginResult` - done, with the account, or the reason it failed
//!
//! and the window answers with `submitTelegramCode` and `submitTelegramPassword`.
//!
//! # By QR code
//!
//! The other way in is to scan a code with Telegram on a phone that is already signed in (Settings, Devices, Link
//! Desktop Device): no number is typed and no code is sent. Telegram gives a login token that lasts about half a
//! minute; it is shown as a QR code and asked for again when it runs out, and Telegram says when it was scanned
//! (`updateLoginToken`; the token is asked for again every so often too, in case that update does not reach a
//! connection that has no account yet). The scanned token comes back as the account, or - when the account lives
//! in another datacenter - as a pointer to it, where the token is handed in and the key made there becomes the
//! session's home. An account with a cloud password still asks for it, after the scan.
use super::link::{self, Link};
use super::session::{self, FileSession};
@@ -20,6 +31,8 @@ use crate::accounts::TelegramAccountConfig;
use crate::state::AppState;
use anyhow::{anyhow, bail, Context, Result};
use grammers_client::SignInError;
use grammers_client::tl;
use grammers_session::Session;
use serde_json::json;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
@@ -77,12 +90,29 @@ pub fn normalize_phone(typed: &str) -> Option<String> {
(7..=15).contains(&digits.len()).then(|| format!("+{digits}"))
}
/// Starts a sign-in. Answers at once; the rest is events.
/// How the account is being signed in.
enum Method {
/// A phone number, then the code sent to it.
Phone(String),
/// A QR code scanned from a phone that is already signed in.
Qr,
}
/// Starts a sign-in by phone number. Answers at once; the rest is events.
pub fn start(state: AppState, login_id: String, api_id: i32, api_hash: String, phone: String) {
begin(state, login_id, api_id, api_hash, Method::Phone(phone));
}
/// Starts a sign-in by QR code. Answers at once; the rest is events.
pub fn start_qr(state: AppState, login_id: String, api_id: i32, api_hash: String) {
begin(state, login_id, api_id, api_hash, Method::Qr);
}
fn begin(state: AppState, login_id: String, api_id: i32, api_hash: 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, api_id, &api_hash, &phone, rx).await;
let outcome = flow(&state, &login_id, api_id, &api_hash, method, rx).await;
waiting().lock().unwrap().remove(&login_id);
match outcome {
Ok(Some(saved)) => {
@@ -114,15 +144,21 @@ 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, api_id: i32, api_hash: &str, phone: &str, mut rx: mpsc::UnboundedReceiver<Answer>) -> Result<Option<TelegramAccountConfig>> {
async fn flow(state: &AppState, login_id: &str, api_id: i32, api_hash: &str, method: Method, mut rx: mpsc::UnboundedReceiver<Answer>) -> Result<Option<TelegramAccountConfig>> {
if api_id <= 0 || api_hash.trim().is_empty() {
bail!("an api_id and an api_hash are needed: get them at my.telegram.org/apps");
}
let phone = normalize_phone(phone).ok_or_else(|| anyhow!("that does not look like a phone number; type it with its country code"))?;
let typed_phone = match &method {
Method::Phone(phone) => Some(normalize_phone(phone).ok_or_else(|| anyhow!("that does not look like a phone number; type it with its country code"))?),
Method::Qr => None,
};
status(state, login_id, "Connecting to Telegram");
let session = Arc::new(FileSession::fresh(session::path_for_login(login_id)));
let link = link::open(state, &crate::net::route::pending_key("telegram"), api_id, session.clone()).await?;
let outcome = signed_in(state, login_id, &link, api_hash, &phone, &mut rx).await;
let mut link = link::open(state, &crate::net::route::pending_key("telegram"), api_id, session.clone()).await?;
let outcome = match &typed_phone {
Some(phone) => signed_in(state, login_id, &link, api_hash, phone, &mut rx).await,
None => scanned(state, login_id, &mut link, api_id, api_hash, &mut rx).await,
};
let user = match outcome {
Ok(Some(user)) => user,
other => {
@@ -134,7 +170,8 @@ async fn flow(state: &AppState, login_id: &str, api_id: i32, api_hash: &str, pho
let config = TelegramAccountConfig {
api_id,
api_hash: api_hash.trim().to_string(),
phone,
// What was typed, or - signed in by QR code, where nothing was - what Telegram says the number is.
phone: typed_phone.or_else(|| user.phone().map(|p| format!("+{}", p.trim_start_matches('+')))).unwrap_or_default(),
user_id: user.id().bare_id_unchecked(),
username: user.username().map(str::to_string),
name: user.full_name(),
@@ -176,6 +213,159 @@ async fn signed_in(state: &AppState, login_id: &str, link: &Link, api_hash: &str
}
}
/// How long the QR code is offered before the sign-in gives up: a form left open should not keep a task.
const QR_PATIENCE: Duration = Duration::from_secs(10 * 60);
/// How often the token is asked for again while nothing has been heard of a scan.
const QR_POLL: Duration = Duration::from_secs(15);
/// What asking for the login token came to.
#[derive(Debug)]
enum Exported {
/// A token to show, and when it stops being good (seconds since the epoch, Telegram's clock).
Token { token: Vec<u8>, expires: i32 },
/// Scanned and approved: the account.
Signed(Box<tl::types::auth::Authorization>),
/// Scanned, but the account has a cloud password.
NeedsPassword,
}
/// The link a phone's camera opens in Telegram.
pub fn qr_address(token: &[u8]) -> String {
use base64::Engine;
format!("tg://login?token={}", base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(token))
}
fn qr_path(login_id: &str) -> std::path::PathBuf {
crate::media_cache::transient_dir().join(format!("moho-telegram-qr-{}.png", login_id.replace(|c: char| !c.is_ascii_alphanumeric(), "-")))
}
/// Asks Telegram for the login token, and follows it to the account's datacenter if it was scanned.
async fn export_token(client: &grammers_client::Client, session: &FileSession, api_id: i32, api_hash: &str) -> Result<Exported> {
let asked = client.invoke(&tl::functions::auth::ExportLoginToken { api_id, api_hash: api_hash.to_string(), except_ids: Vec::new() }).await;
let answer = match asked {
Ok(answer) => answer,
Err(e) if e.is("SESSION_PASSWORD_NEEDED") => return Ok(Exported::NeedsPassword),
Err(e) => bail!("{}", explain(&e.to_string())),
};
match answer {
tl::enums::auth::LoginToken::Token(t) => Ok(Exported::Token { token: t.token, expires: t.expires }),
tl::enums::auth::LoginToken::Success(s) => signed(s.authorization),
// The account is kept in another datacenter: the token is handed in there, and the key made there is the
// session's from then on.
tl::enums::auth::LoginToken::MigrateTo(m) => {
let imported = client.invoke_in_dc(m.dc_id, &tl::functions::auth::ImportLoginToken { token: m.token }).await;
match imported {
Ok(tl::enums::auth::LoginToken::Success(s)) => {
session.set_home_dc_id(m.dc_id).await.map_err(|e| anyhow!("{e}"))?;
signed(s.authorization)
}
Ok(_) => bail!("Telegram did not finish the sign-in in the account's datacenter"),
Err(e) if e.is("SESSION_PASSWORD_NEEDED") => {
session.set_home_dc_id(m.dc_id).await.map_err(|e| anyhow!("{e}"))?;
Ok(Exported::NeedsPassword)
}
Err(e) => bail!("{}", explain(&e.to_string())),
}
}
}
}
fn signed(authorization: tl::enums::auth::Authorization) -> Result<Exported> {
match authorization {
tl::enums::auth::Authorization::Authorization(a) => Ok(Exported::Signed(Box::new(a))),
tl::enums::auth::Authorization::SignUpRequired(_) => bail!("this number has no Telegram account. Make one with Telegram's own app first"),
}
}
/// What the library does when it signs in by code, which it does not do for a login token: the account's own entry
/// in the session, and where its updates stand.
async fn settle(client: &grammers_client::Client, session: &FileSession, authorization: tl::types::auth::Authorization) -> Result<grammers_client::peer::User> {
use grammers_session::types::{PeerInfo, UpdateState, UpdatesState};
let update_state = client.invoke(&tl::functions::updates::GetState {}).await.ok();
let user = grammers_client::peer::User::from_raw(client, authorization.user);
let auth = user.to_ref().await.map_err(|e| anyhow!("{e}"))?.context("Telegram did not say who signed in")?.auth;
session
.cache_peer(PeerInfo::User { id: user.id().bare_id_unchecked(), auth: Some(auth), bot: Some(user.is_bot()), is_self: Some(true) })
.await
.map_err(|e| anyhow!("{e}"))?;
if let Some(tl::enums::updates::State::State(state)) = update_state {
session
.set_update_state(UpdateState::All(UpdatesState { pts: state.pts, qts: state.qts, date: state.date, seq: state.seq, channels: Vec::new() }))
.await
.map_err(|e| anyhow!("{e}"))?;
}
Ok(user)
}
/// The conversation with Telegram, up to being signed in by QR code.
async fn scanned(state: &AppState, login_id: &str, link: &mut Link, api_id: i32, api_hash: &str, rx: &mut mpsc::UnboundedReceiver<Answer>) -> Result<Option<grammers_client::peer::User>> {
let client = link.client.clone();
let session = link.session.clone();
if client.is_authorized().await.context("asking Telegram whether this session is signed in")? {
bail!("this session is already signed in");
}
// Telegram says a token was scanned with an update; a connection that cannot read it is covered by asking again.
let mut stream = match link.updates.take() {
Some(updates) => client.stream_updates(updates, grammers_mtsender::UpdatesConfiguration { catch_up: false }).await.ok(),
None => None,
};
let began = tokio::time::Instant::now();
let mut shown: Vec<u8> = Vec::new();
status(state, login_id, "Asking Telegram for a code to scan");
loop {
if began.elapsed() > QR_PATIENCE {
bail!("The code was not scanned in time. Start again to get a new one");
}
match export_token(&client, &session, api_id, api_hash).await? {
Exported::Signed(authorization) => return Ok(Some(settle(&client, &session, *authorization).await?)),
Exported::NeedsPassword => return ask_cloud_password(state, login_id, &client, rx).await,
Exported::Token { token, expires } => {
if token != shown {
let path = qr_path(login_id);
super::super::steam::login::write_qr(&qr_address(&token), &path)?;
state.events.emit("telegramLoginQr", json!({ "loginId": login_id, "path": path.to_string_lossy(), "expires": expires }));
shown = token;
}
// Until it is scanned, it runs out, or it is time to ask again.
let now = chrono::Utc::now().timestamp();
let until = Duration::from_secs((i64::from(expires) - now - 2).clamp(1, QR_POLL.as_secs() as i64) as u64);
let heard = async {
match stream.as_mut() {
Some(stream) => loop {
match stream.next().await {
Ok(grammers_client::update::Update::Raw(raw)) if matches!(raw.raw, tl::enums::Update::LoginToken) => return,
Ok(_) => {}
// Not readable here: the poll is what notices a scan.
Err(_) => return std::future::pending().await,
}
},
None => std::future::pending().await,
}
};
tokio::select! {
_ = tokio::time::sleep(until) => {}
_ = heard => {}
given = rx.recv() => {
// The form was closed.
if given.is_none() {
return Ok(None);
}
}
}
}
}
}
}
/// Asks for the cloud password of an account that was scanned in, and signs it in.
async fn ask_cloud_password(state: &AppState, login_id: &str, client: &grammers_client::Client, rx: &mut mpsc::UnboundedReceiver<Answer>) -> Result<Option<grammers_client::peer::User>> {
let password: tl::types::account::Password = client.invoke(&tl::functions::account::GetPassword {}).await.map_err(|e| anyhow!("{}", explain(&e.to_string())))?.into();
let token = grammers_client::client::PasswordToken::new(password);
needs(state, login_id, "password", token.hint(), None);
ask_password(state, login_id, client, token, rx).await
}
async fn ask_password(state: &AppState, login_id: &str, client: &grammers_client::Client, mut token: grammers_client::client::PasswordToken, rx: &mut mpsc::UnboundedReceiver<Answer>) -> Result<Option<grammers_client::peer::User>> {
loop {
let Some(given) = answer(rx).await else { return Ok(None) };
@@ -239,6 +429,32 @@ mod tests {
assert!(submit_code("telegram-t", "12345").is_err());
}
#[test]
fn a_login_token_is_offered_as_telegrams_link_with_the_token_in_base64url() {
// The alphabet is the URL-safe one, and there is no padding: a phone reads the link as it is written.
assert_eq!(qr_address(&[0xfb, 0xff]), "tg://login?token=-_8");
assert_eq!(qr_address(&[1, 2, 3, 4]), "tg://login?token=AQIDBA");
assert!(qr_address(&[0; 32]).starts_with("tg://login?token="));
}
#[test]
fn a_scanned_codes_picture_is_written_where_the_window_can_read_it() {
let path = std::env::temp_dir().join(format!("moho-telegram-qr-test-{}.png", std::process::id()));
super::super::super::steam::login::write_qr(&qr_address(&[7; 32]), &path).unwrap();
let bytes = std::fs::read(&path).unwrap();
std::fs::remove_file(&path).ok();
assert_eq!(&bytes[..4], &[0x89, b'P', b'N', b'G']);
}
#[test]
fn a_sign_in_by_qr_is_waited_on_like_one_by_phone_and_given_up_by_closing_the_form() {
let (tx, mut rx) = mpsc::unbounded_channel();
waiting().lock().unwrap().insert("telegram-qr".into(), tx);
cancel("telegram-qr");
assert!(rx.try_recv().is_err());
assert!(submit_password("telegram-qr", "x").is_err(), "nothing is waiting for a password once the form is closed");
}
#[test]
fn telegrams_refusals_are_said_so_a_person_can_act_on_them() {
assert!(explain("rpc error 400: API_ID_INVALID").contains("my.telegram.org"));
+4
View File
@@ -410,6 +410,10 @@ pub async fn restore_cached(state: &AppState, path: &Path) -> Result<PathBuf> {
if name.starts_with("tgif_") {
return super::gifs::restore(state, name, path).await?.context("not a cached Telegram GIF");
}
// So is a sticker's picture.
if name.starts_with("tstk_") {
return super::stickers::restore(state, name, path).await?.context("not a cached Telegram sticker");
}
let (account, remote, message, variant) = parse_media_name(name).context("not a cached Telegram file")?;
// A preview already on its way is waited for, not fetched a second time.
for _ in 0..120 {
+6
View File
@@ -7,6 +7,7 @@
//! - this file - the identity moho gives Telegram, and what every other file shares
//! - `session` - what the library remembers between runs, in a private file of its own per account
//! - `link` - one connection to Telegram: the sender pool, the client and the stream of updates
//! - `links` - what a t.me or tg:// link points at, and opening it
//! - `login` - adding an account: phone, code, cloud password
//! - `connect` - an account's connection, kept up and reconnected
//! - `dialogs` - the account's conversations, read when it connects
@@ -29,7 +30,9 @@
//! - `keyboards` - a bot's inline buttons
//! - `bots` - a bot's command menu
//! - `topics` - a group with Topics as several conversations
//! - `folders` - the account's chat folders, kept in step with moho's categories
//! - `gifs` - saved GIFs and the GIF search
//! - `stickers` - the account's sticker packs, and sending one
//! - `peers` - a conversation as a buffer: its name, its kind, its heading in the list
//! - `messages` - a message as moho's, with its entities as html
//!
@@ -50,11 +53,13 @@ pub mod admin;
pub mod bots;
pub mod connect;
pub mod dialogs;
pub mod folders;
pub mod gifs;
pub mod history;
pub mod instant;
pub mod keyboards;
pub mod link;
pub mod links;
pub mod login;
pub mod markup;
pub mod media;
@@ -69,6 +74,7 @@ pub mod profile;
pub mod reactions;
pub mod search;
pub mod send;
pub mod stickers;
pub mod threads;
pub mod topics;
pub mod updates;
+3 -1
View File
@@ -191,10 +191,12 @@ pub async fn mark_read(state: &AppState, buffer: &Buffer) -> Result<(), String>
let (handle, peer) = peer_for(state, buffer).await?;
// A topic is read on its own: to the newest message of it that is here.
if let Some(topic) = super::topics::topic_of_buffer(buffer) {
let newest = state.store.newest_message_id(&buffer.id).ok().flatten().and_then(|id| id.parse::<i32>().ok()).unwrap_or(0);
// Or to the newest Telegram said it has, which may be a message this client never kept.
let newest = state.store.newest_message_id(&buffer.id).ok().flatten().and_then(|id| id.parse::<i32>().ok()).unwrap_or(0).max(super::topics::top_of(&buffer.id).unwrap_or(0));
if newest > 0 {
handle.client.invoke(&tl::functions::messages::ReadDiscussion { peer: peer.into(), msg_id: topic, read_max_id: newest }).await.map_err(|e| explain(&e))?;
}
state.runtime.set_buffer_unread(state, &buffer.id, 0, 0);
return Ok(());
}
handle.client.mark_as_read(peer).await.map_err(|e| explain(&e))
+260
View File
@@ -0,0 +1,260 @@
//! Stickers: the packs an account has installed, the ones it uses most, and sending one.
//!
//! A sticker is a document that lives on Telegram, so sending one is sending that file, the way a GIF is. The account
//! keeps three lists the picker draws: the stickers it sent lately, the ones it marked as favourites, and its
//! installed packs. A pack is asked for on its own, which is a request each, so they are read a few at a time and
//! kept for a while. Each sticker is drawn from the still picture Telegram sends with it (an animated or video
//! sticker has one too), named in the cache and fetched when the picker draws it, from the document remembered
//! here: a file is fetched with its access key and a reference that goes stale, so what was listed is what can be
//! fetched.
use super::gifs::wrapper;
use super::media::{download_thumb, download_to, MEDIA};
use super::messages;
use super::send::{explain, peer_for};
use super::Handle;
use crate::model::Buffer;
use crate::state::AppState;
use anyhow::{Context, Result};
use futures::StreamExt;
use grammers_client::message::InputMessage;
use grammers_client::tl;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{Duration, Instant};
/// How many of the most recent stickers are listed.
const RECENT: usize = 40;
/// How many installed packs are read. An account can have hundreds; the picker is for the ones in daily use, which
/// Telegram lists first.
const PACKS: usize = 40;
/// How many packs are asked for at once.
const AT_ONCE: usize = 4;
/// How long a pack's stickers are kept before it is asked for again.
const KEEP: Duration = Duration::from_secs(15 * 60);
/// The documents that were listed, by (account, id): what the picker names is what can be fetched or sent.
type Known = Mutex<HashMap<(i64, i64), tl::types::Document>>;
fn known() -> &'static Known {
static K: std::sync::OnceLock<Known> = std::sync::OnceLock::new();
K.get_or_init(Default::default)
}
/// A pack's stickers, when they were read.
type Packs = Mutex<HashMap<(i64, i64), (Instant, Vec<tl::types::Document>)>>;
fn packs() -> &'static Packs {
static P: std::sync::OnceLock<Packs> = std::sync::OnceLock::new();
P.get_or_init(Default::default)
}
/// A sticker's cached picture: `tstk_<account>_<id>.webp`.
fn name(account: i64, id: i64) -> String {
format!("tstk_{account}_{id}.webp")
}
/// What a `tstk_` name says: the account and the document.
fn parse_name(file: &str) -> Option<(i64, i64)> {
let stem = file.strip_prefix("tstk_")?.strip_suffix(".webp")?;
let (account, id) = stem.split_once('_')?;
Some((account.parse().ok()?, id.parse().ok()?))
}
/// The emoji a sticker stands for, which is also what it is called.
fn emoji_of(doc: &tl::types::Document) -> String {
doc.attributes
.iter()
.find_map(|a| match a {
tl::enums::DocumentAttribute::Sticker(s) => Some(s.alt.clone()),
_ => None,
})
.unwrap_or_default()
}
/// A listed document as the picker's cell, its picture named in the cache and fetched when drawn.
fn entry(account: i64, pack: &str, doc: &tl::types::Document) -> Value {
let emoji = emoji_of(doc);
let url = format!("file://{}", crate::media_cache::dir(MEDIA).join(name(account, doc.id)).display());
json!({ "id": doc.id.to_string(), "name": if emoji.is_empty() { "Sticker".to_string() } else { emoji.clone() }, "pack": pack, "body": emoji, "url": url })
}
fn documents(list: Vec<tl::enums::Document>) -> Vec<tl::types::Document> {
list.into_iter()
.filter_map(|d| match d {
tl::enums::Document::Document(d) => Some(d),
_ => None,
})
.collect()
}
/// Adds a heading's stickers to the list and remembers the documents.
fn push(out: &mut Vec<Value>, account: i64, pack: &str, docs: Vec<tl::types::Document>) {
let mut held = known().lock().unwrap_or_else(|e| e.into_inner());
for doc in docs {
// A sticker can be in the recent list and in its pack; the picker shows it under each heading, since the
// heading is how it is found, but it is remembered and fetched once.
out.push(entry(account, pack, &doc));
held.insert((account, doc.id), doc);
}
}
/// One installed pack's stickers, from what was kept or from Telegram.
async fn pack_documents(handle: Handle, account: i64, id: i64, access_hash: i64) -> Result<Vec<tl::types::Document>> {
let key = (account, id);
if let Some((when, docs)) = packs().lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
if when.elapsed() < KEEP {
return Ok(docs.clone());
}
}
let answer = handle
.client
.invoke(&tl::functions::messages::GetStickerSet { stickerset: tl::enums::InputStickerSet::Id(tl::types::InputStickerSetId { id, access_hash }), hash: 0 })
.await
.map_err(|e| anyhow::anyhow!("{}", explain(&e)))?;
let docs = match answer {
tl::enums::messages::StickerSet::Set(s) => documents(s.documents),
tl::enums::messages::StickerSet::NotModified => Vec::new(),
};
packs().lock().unwrap_or_else(|e| e.into_inner()).insert(key, (Instant::now(), docs.clone()));
Ok(docs)
}
/// What the picker shows: the recent stickers, the favourites, then the installed packs in the account's order.
pub async fn list(state: &AppState, buffer: &Buffer) -> Result<Value, String> {
let (handle, _) = peer_for(state, buffer).await?;
let account = handle.own.user_id;
let mut out = Vec::new();
// The two short lists are a nicety: a failure reading them leaves the packs to be listed.
match handle.client.invoke(&tl::functions::messages::GetRecentStickers { attached: false, hash: 0 }).await {
Ok(tl::enums::messages::RecentStickers::Stickers(r)) => {
let docs: Vec<_> = documents(r.stickers).into_iter().take(RECENT).collect();
push(&mut out, account, "Recent", docs);
}
Ok(_) => {}
Err(e) => tracing::debug!("telegram: recent stickers: {}", explain(&e)),
}
match handle.client.invoke(&tl::functions::messages::GetFavedStickers { hash: 0 }).await {
Ok(tl::enums::messages::FavedStickers::Stickers(f)) => push(&mut out, account, "Favourites", documents(f.stickers)),
Ok(_) => {}
Err(e) => tracing::debug!("telegram: favourite stickers: {}", explain(&e)),
}
let sets = match handle.client.invoke(&tl::functions::messages::GetAllStickers { hash: 0 }).await.map_err(|e| explain(&e))? {
tl::enums::messages::AllStickers::Stickers(s) => s.sets,
tl::enums::messages::AllStickers::NotModified => Vec::new(),
};
let sets: Vec<tl::types::StickerSet> = sets
.into_iter()
.map(|s| {
let tl::enums::StickerSet::Set(s) = s;
s
})
// Masks are for putting on photos and custom emoji are typed into text: neither is sent as a sticker.
.filter(|s| !s.masks && !s.emojis && !s.archived)
.take(PACKS)
.collect();
let fetched: Vec<_> = futures::stream::iter(sets.into_iter().map(|set| {
let handle = handle.clone();
async move { (set.title, pack_documents(handle, account, set.id, set.access_hash).await) }
}))
.buffered(AT_ONCE)
.collect()
.await;
for (title, result) in fetched {
match result {
Ok(docs) => push(&mut out, account, &title, docs),
Err(e) => tracing::debug!("telegram: sticker pack {title}: {e}"),
}
}
Ok(Value::Array(out))
}
/// Sends a sticker that was listed, to a conversation.
pub async fn send(state: &AppState, buffer: &Buffer, id: &str) -> Result<(), String> {
let id: i64 = id.parse().map_err(|_| "that is not a sticker".to_string())?;
let (handle, peer) = peer_for(state, buffer).await?;
let doc = known().lock().unwrap_or_else(|e| e.into_inner()).get(&(handle.own.user_id, id)).cloned().ok_or_else(|| "That sticker is no longer listed: open the picker again".to_string())?;
let media = wrapper(&doc).to_raw_input_media();
// In a topic of a group the sticker answers the topic's first message, which is how it is sent there.
let reply = super::topics::topic_of_buffer(buffer);
let sent = handle.client.send_message(peer, InputMessage::new().media(tl::enums::InputMedia::Document(media)).reply_to(reply)).await.map_err(|e| explain(&e))?;
messages::record(state, &buffer.account_id, &handle.own, &sent);
Ok(())
}
/// Fetches a listed sticker's picture again, for the window asking for it.
pub async fn restore(state: &AppState, file: &str, path: &Path) -> Result<Option<PathBuf>> {
let Some((account, id)) = parse_name(file) else { return Ok(None) };
let handle = state.runtime.telegram_handle(&format!("telegram:{account}")).context("that Telegram account is not connected")?;
let doc = known().lock().unwrap_or_else(|e| e.into_inner()).get(&(account, id)).cloned().context("that sticker is no longer listed")?;
let wrapped = wrapper(&doc);
// A still sticker is its own picture; everything else is drawn from the preview that comes with it.
if wrapped.thumbs().is_empty() && doc.mime_type == "image/webp" {
download_to(&handle.client, &wrapped, path, false).await?;
} else {
download_thumb(&handle.client, &wrapped, path).await?;
}
Ok(Some(path.to_path_buf()))
}
#[cfg(test)]
mod tests {
use super::*;
fn sticker(id: i64, alt: &str) -> tl::types::Document {
tl::types::Document {
id,
access_hash: 9,
file_reference: vec![1],
date: 0,
mime_type: "image/webp".into(),
size: 10,
thumbs: None,
video_thumbs: None,
dc_id: 2,
attributes: vec![tl::enums::DocumentAttribute::Sticker(tl::types::DocumentAttributeSticker {
mask: false,
alt: alt.into(),
stickerset: tl::enums::InputStickerSet::Empty,
mask_coords: None,
})],
}
}
#[test]
fn a_stickers_picture_is_named_and_read_back() {
let file = name(1234567890, 5_000_000_000_001);
assert_eq!(file, "tstk_1234567890_5000000000001.webp");
assert_eq!(parse_name(&file), Some((1234567890, 5_000_000_000_001)));
assert_eq!(parse_name("tgif_1_2_full.mp4"), None);
assert_eq!(parse_name("tstk_1_x.webp"), None);
}
#[test]
fn a_sticker_is_called_by_its_emoji() {
let cell = entry(5, "Cats", &sticker(77, "😺"));
assert_eq!(cell["id"], "77");
assert_eq!(cell["name"], "😺");
assert_eq!(cell["body"], "😺");
assert_eq!(cell["pack"], "Cats");
assert!(cell["url"].as_str().unwrap().ends_with("tstk_5_77.webp"));
// One without an emoji is still a cell that can be told from a missing one.
assert_eq!(entry(5, "Cats", &sticker(78, ""))["name"], "Sticker");
}
#[test]
fn a_sticker_listed_twice_is_remembered_once_and_shown_under_each_heading() {
let mut out = Vec::new();
push(&mut out, 424242, "Recent", vec![sticker(1, "a")]);
push(&mut out, 424242, "Cats", vec![sticker(1, "a"), sticker(2, "b")]);
assert_eq!(out.len(), 3);
assert!(known().lock().unwrap().contains_key(&(424242, 2)));
}
}
+77
View File
@@ -131,6 +131,28 @@ pub fn holding(state: &AppState, account_id: &str, remote: &str, msg_id: i32) ->
topics.iter().find(|b| state.store.get_message(&b.id, &msg_id.to_string()).ok().flatten().is_some()).or(topics.first()).map(|b| (*b).clone())
}
/// The newest message of each topic as Telegram last said, by the topic's conversation: what reading it reads up to,
/// since this client may not have kept the messages that are unread.
fn tops() -> &'static std::sync::Mutex<std::collections::HashMap<String, i32>> {
static T: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, i32>>> = std::sync::OnceLock::new();
T.get_or_init(Default::default)
}
/// The newest message Telegram has told of in a topic's conversation, if it has.
pub fn top_of(buffer_id: &str) -> Option<i32> {
tops().lock().unwrap_or_else(|e| e.into_inner()).get(buffer_id).copied()
}
/// How many messages in a topic are unread, and how many of those mention this account.
fn counts(topic: &tl::types::ForumTopic) -> (u32, u32) {
(topic.unread_count.max(0) as u32, topic.unread_mentions_count.max(0) as u32)
}
/// Whether a read position reaches everything the topic is known to hold.
pub fn read_all(read_max: i32, newest_here: i32, top: Option<i32>) -> bool {
read_max >= newest_here.max(top.unwrap_or(0))
}
/// Reads the group's topics and makes a conversation of each, named as the group names it. The one conversation the
/// group had before it had topics is removed: its messages are in the topics now.
pub async fn sync(state: &AppState, account_id: &str, handle: &Handle, peer: &Peer) -> Result<usize> {
@@ -156,6 +178,10 @@ pub async fn sync(state: &AppState, account_id: &str, handle: &Handle, peer: &Pe
let now = chrono::Utc::now().timestamp();
let reason = if topic.closed { Some("This topic is closed.") } else { peers::read_only_for(peer, now) };
state.runtime.set_buffer_read_only(state, &buffer.id, reason);
// The number Telegram shows beside the topic: a topic never opened here has no messages to count.
let (unread, mentions) = counts(topic);
state.runtime.set_buffer_unread(state, &buffer.id, unread, mentions);
tops().lock().unwrap_or_else(|e| e.into_inner()).insert(buffer.id.clone(), topic.top_message);
count += 1;
}
Ok(count)
@@ -197,4 +223,55 @@ mod tests {
assert_eq!(display_name("Chasm", " Support "), "Chasm/#Support");
assert_eq!(peers::peer_id_of("-1001234:45"), peers::peer_id_of("-1001234"));
}
#[test]
fn a_topics_unread_numbers_are_what_telegram_counts() {
let mut topic = tl::types::ForumTopic {
my: false,
closed: false,
pinned: false,
short: false,
hidden: false,
title_missing: false,
id: 45,
date: 0,
peer: tl::enums::Peer::Channel(tl::types::PeerChannel { channel_id: 1 }),
title: "Support".into(),
icon_color: 0,
icon_emoji_id: None,
top_message: 90,
read_inbox_max_id: 80,
read_outbox_max_id: 0,
unread_count: 7,
unread_mentions_count: 2,
unread_reactions_count: 0,
unread_poll_votes_count: 0,
from_id: tl::enums::Peer::Channel(tl::types::PeerChannel { channel_id: 1 }),
notify_settings: tl::enums::PeerNotifySettings::Settings(tl::types::PeerNotifySettings {
show_previews: None,
silent: None,
mute_until: None,
ios_sound: None,
android_sound: None,
other_sound: None,
stories_muted: None,
stories_hide_sender: None,
stories_ios_sound: None,
stories_android_sound: None,
stories_other_sound: None,
}),
draft: None,
};
assert_eq!(counts(&topic), (7, 2));
topic.unread_count = -1;
assert_eq!(counts(&topic).0, 0, "a count below nothing is nothing");
}
#[test]
fn a_topic_is_read_when_the_position_reaches_what_is_known_of_it() {
assert!(read_all(90, 85, Some(90)));
assert!(!read_all(80, 85, Some(90)), "the position is short of the newest Telegram has told of");
assert!(!read_all(80, 85, None), "and of the newest kept here");
assert!(read_all(0, 0, None), "a topic holding nothing is read");
}
}
+13
View File
@@ -42,6 +42,16 @@ fn read_up_to(state: &AppState, account_id: &str, remote: &str, max_id: i32) {
}
}
/// A topic was read on another of the account's apps: up to the message it names.
fn topic_read(state: &AppState, account_id: &str, group: &str, topic: i32, max_id: i32) {
let Some(buffer) = buffer_of(state, account_id, &super::topics::remote_of(group, topic)) else { return };
let newest = state.store.newest_message_id(&buffer.id).ok().flatten().and_then(|id| id.parse::<i32>().ok()).unwrap_or(0);
if super::topics::read_all(max_id, newest, super::topics::top_of(&buffer.id)) {
state.runtime.set_buffer_unread(state, &buffer.id, 0, 0);
state.events.emit("bufferRead", serde_json::json!({ "accountId": account_id, "bufferId": buffer.id }));
}
}
/// The pins of a conversation changed: read them again, which says so to every window.
fn pins_changed(state: &AppState, account_id: &str, _handle: &Handle, remote: &str) {
let Some(buffer) = buffer_of(state, account_id, remote) else { return };
@@ -81,6 +91,9 @@ pub fn raw(state: &AppState, account_id: &str, handle: &Handle, update: &tl::enu
U::PinnedMessages(u) => pins_changed(state, account_id, handle, &peers::remote_id(PeerId::from(u.peer.clone()))),
U::PinnedChannelMessages(u) => pins_changed(state, account_id, handle, &peers::remote_id(PeerId::channel_unchecked(u.channel_id))),
U::ReadHistoryInbox(u) => read_up_to(state, account_id, &peers::remote_id(PeerId::from(u.peer.clone())), u.max_id),
// The account's folders were made, changed, deleted or put in another order, on this client or another.
U::DialogFilter(_) | U::DialogFilters | U::DialogFilterOrder(_) => super::folders::changed(state, account_id, handle),
U::ReadChannelDiscussionInbox(u) => topic_read(state, account_id, &peers::remote_id(PeerId::channel_unchecked(u.channel_id)), u.top_msg_id, u.read_max_id),
U::ReadChannelInbox(u) => read_up_to(state, account_id, &peers::remote_id(PeerId::channel_unchecked(u.channel_id)), u.max_id),
_ => {}
}
+3
View File
@@ -346,6 +346,9 @@ 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,6 +40,8 @@ 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 },
@@ -218,6 +220,7 @@ 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()))
+16 -1
View File
@@ -395,6 +395,18 @@ pub struct Buffer {
/// posts, each of which is a thread. Opened as a pane of its own.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub forum: bool,
/// How many messages the service itself counts as unread here, where it keeps a count a client could not work
/// out from the messages it has: a Telegram topic of a group that was never opened here. A starting point for
/// the window's own count, which then follows live messages; absent when there is none.
#[serde(rename = "unreadSeed", default, skip_serializing_if = "is_zero")]
pub unread_seed: u32,
/// How many of those mention this account.
#[serde(rename = "mentionsSeed", default, skip_serializing_if = "is_zero")]
pub mentions_seed: u32,
}
fn is_zero(n: &u32) -> bool {
*n == 0
}
/// The rail entry a buffer belongs to when its protocol has no grouping of
@@ -921,6 +933,7 @@ 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",
@@ -1048,6 +1061,8 @@ mod tests {
topic: None,
read_only: None,
forum: false,
unread_seed: 0,
mentions_seed: 0,
}
}
@@ -1087,7 +1102,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:"),
"invisible" => account_id.starts_with("discord:") || account_id.starts_with("matrix:") || account_id.starts_with("steam:") || account_id.starts_with("stoat:"),
_ => false,
}
}
+401 -1
View File
@@ -53,6 +53,14 @@ 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")
@@ -113,6 +121,7 @@ fn adding_service(method: &str) -> Option<&'static str> {
"addSteamAccount" | "addSteamAccountQr" => Some("steam"),
"addTwitchAccount" | "startTwitchLogin" => Some("twitch"),
"addTelegramAccount" => Some("telegram"),
"addStoatAccount" | "addStoatAccountToken" => Some("stoat"),
_ => None,
}
}
@@ -428,6 +437,7 @@ 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,
),
@@ -506,6 +516,16 @@ 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
@@ -803,9 +823,20 @@ 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();
tokio::spawn(async move { backend::telegram::connect::sign_out(&state, &config).await });
}
@@ -1038,6 +1069,16 @@ 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),
@@ -1112,6 +1153,15 @@ 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 {
@@ -1225,6 +1275,20 @@ 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()));
@@ -1295,6 +1359,7 @@ 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 => {
@@ -1376,6 +1441,13 @@ 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 {
@@ -1809,6 +1881,8 @@ 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 {
@@ -1858,6 +1932,11 @@ 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()
@@ -1899,6 +1978,18 @@ 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 {
@@ -2044,6 +2135,28 @@ 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
@@ -2300,7 +2413,7 @@ pub async fn dispatch(
"name": c.name,
"usage": "",
"description": c.description,
"source": buffer.name,
"source": c.source.clone().unwrap_or_else(|| buffer.name.clone()),
"kind": "bot",
}));
}
@@ -2413,6 +2526,8 @@ 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"))
};
@@ -2455,6 +2570,8 @@ 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"))
};
@@ -2482,6 +2599,8 @@ 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"))
};
@@ -2533,6 +2652,8 @@ 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"))
};
@@ -2542,6 +2663,27 @@ 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")) {
@@ -2674,6 +2816,7 @@ 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)
}
@@ -3456,6 +3599,11 @@ 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();
@@ -3574,6 +3722,24 @@ 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),
@@ -3794,6 +3960,10 @@ 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)),
@@ -3838,6 +4008,10 @@ 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)),
@@ -3868,6 +4042,10 @@ 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);
@@ -4633,6 +4811,154 @@ 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 {
return (None, Some("addTelegramAccountQr requires \"apiId\" and \"apiHash\"".to_string()));
};
let Ok(api_id) = i32::try_from(api_id) else {
return (None, Some("that api_id is not a number Telegram would have given".to_string()));
};
let login_id = format!("telegram-{}", crate::model::next_message_id());
backend::telegram::login::start_qr(state.clone(), login_id.clone(), api_id, api_hash.trim().to_string());
(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 {
@@ -5798,6 +6124,75 @@ pub async fn dispatch(
}
}
// A t.me or tg:// link followed: the conversation it points into (joined if need be) and the message to go to.
"openTelegramLink" => {
let Some(url) = p_str_opt(params, "url") else {
return (None, Some("openTelegramLink requires \"url\"".to_string()));
};
match backend::telegram::links::open(state, p_str_opt(params, "accountId"), url).await {
Ok(opened) => (Some(backend::telegram::links::answer(&opened)), None),
Err(e) => (None, Some(e)),
}
}
// The stickers a Telegram account can send, and sending one.
"listTelegramStickers" | "sendTelegramSticker" => {
let Some(buffer) = p_str_opt(params, "bufferId").and_then(|b| state.runtime.get_buffer(b)).filter(|b| b.account_id.starts_with("telegram:")) else {
return (None, Some(format!("{method} requires a Telegram \"bufferId\"")));
};
if method == "listTelegramStickers" {
return match backend::telegram::stickers::list(state, &buffer).await {
Ok(list) => (Some(list), None),
Err(e) => (None, Some(e)),
};
}
match backend::telegram::stickers::send(state, &buffer, p_str(params, "id", "")).await {
Ok(()) => (Some(ok_node()), None),
Err(e) => (None, Some(e)),
}
}
// An account's chat folders, which the window shows as categories on the account's page, and the changes it
// makes to them: see backend/telegram/folders.rs.
"telegramFolders" => match p_str_opt(params, "accountId") {
Some(account_id) if account_id.starts_with("telegram:") => (Some(backend::telegram::folders::current(account_id)), None),
_ => (None, Some("telegramFolders requires a Telegram \"accountId\"".to_string())),
},
"createTelegramFolder" | "fileTelegramChat" => {
let Some(buffer) = p_str_opt(params, "bufferId").and_then(|b| state.runtime.get_buffer(b)).filter(|b| b.account_id.starts_with("telegram:")) else {
return (None, Some(format!("{method} requires a Telegram \"bufferId\"")));
};
let done = if method == "createTelegramFolder" {
backend::telegram::folders::create(state, &buffer, p_str(params, "name", "")).await
} else {
let to = params.get("folderId").and_then(|v| v.as_i64()).and_then(|n| i32::try_from(n).ok());
backend::telegram::folders::file(state, &buffer, to).await
};
match done {
Ok(()) => (Some(ok_node()), None),
Err(e) => (None, Some(e)),
}
}
"renameTelegramFolder" | "deleteTelegramFolder" | "reorderTelegramFolders" => {
let Some(account_id) = p_str_opt(params, "accountId").filter(|a| a.starts_with("telegram:")) else {
return (None, Some(format!("{method} requires a Telegram \"accountId\"")));
};
let folder = params.get("folderId").and_then(|v| v.as_i64()).and_then(|n| i32::try_from(n).ok());
let done = match (method, folder) {
("renameTelegramFolder", Some(id)) => backend::telegram::folders::rename(state, account_id, id, p_str(params, "name", "")).await,
("deleteTelegramFolder", Some(id)) => backend::telegram::folders::delete(state, account_id, id).await,
("reorderTelegramFolders", _) => {
let order: Vec<i32> = params.get("order").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|n| n.as_i64().and_then(|n| i32::try_from(n).ok())).collect()).unwrap_or_default();
backend::telegram::folders::reorder(state, account_id, &order).await
}
_ => Err(format!("{method} requires a \"folderId\"")),
};
match done {
Ok(()) => (Some(ok_node()), None),
Err(e) => (None, Some(e)),
}
}
// Lets a Discord connection that is being held for having logged in too often through now.
"connectDiscordAnyway" => {
let Some(account_id) = p_str_opt(params, "accountId").filter(|a| a.starts_with("discord:")) else {
@@ -5976,6 +6371,11 @@ 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 {
+47 -1
View File
@@ -545,6 +545,8 @@ 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.
@@ -1113,6 +1115,7 @@ 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()),
@@ -1268,6 +1271,11 @@ 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
}
@@ -1729,6 +1737,8 @@ impl Runtime {
topic: None,
read_only: None,
forum: false,
unread_seed: 0,
mentions_seed: 0,
};
buffers.insert(id, buffer.clone());
state.events.emit("bufferListChange", serde_json::to_value(&buffer).unwrap());
@@ -3297,6 +3307,25 @@ impl Runtime {
}
}
/// What the service counts as unread in a conversation, and how many of those mention this account: see
/// `Buffer::unread_seed`. Said to the windows when it changes, which set their own count to it.
pub fn set_buffer_unread(&self, state: &AppState, buffer_id: &str, unread: u32, mentions: u32) {
let updated = {
let mut buffers = self.buffers.lock().unwrap();
match buffers.get_mut(buffer_id) {
Some(b) if (b.unread_seed, b.mentions_seed) != (unread, mentions) => {
b.unread_seed = unread;
b.mentions_seed = mentions;
Some(b.clone())
}
_ => None,
}
};
if let Some(b) = updated {
state.events.emit("bufferListChange", serde_json::to_value(&b).unwrap());
}
}
/// A Matrix room's topic, kept against the room so a buffer made after it
/// was read still gets it.
pub fn set_matrix_room_topic(&self, state: &AppState, account_id: &str, room_id: &str, topic: &str) {
@@ -4407,6 +4436,23 @@ 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);
}
@@ -5690,7 +5736,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, steam_requests, kick_channels,
sneedchat_senders, kick_senders, twitch_senders, twitch_rights, steam_links, telegram_handles, stoat_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,
+5 -3
View File
@@ -1361,10 +1361,12 @@ 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 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.
// 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.
let path = std::path::Path::new(rest);
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) {
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)) {
return false;
}
!path.exists()