Compare commits
15
Commits
v1.3.2
...
protocol/stoat
No files matched your search
Generated
+1
-1
@@ -4091,7 +4091,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nobilis"
|
||||
version = "1.3.2"
|
||||
version = "1.3.3"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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: ®ex::Captures| format!("@{}", world.name_of(&c[1])));
|
||||
let out = channel.replace_all(&out, |c: ®ex::Captures| world.channels.get(&c[1]).map(|ch| format!("#{}", ch.name)).unwrap_or_else(|| "#channel".to_string()));
|
||||
let out = role.replace_all(&out, |c: ®ex::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: ®ex::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: ®ex::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: ®ex::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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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:#}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user