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

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

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

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

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

No files matched your search

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