Compare commits
147
Commits
Generated
+682
-31
File diff suppressed because it is too large
Load Diff
+25
-2
@@ -1,10 +1,11 @@
|
||||
[package]
|
||||
name = "nobilis"
|
||||
version = "1.0.0-rc.4"
|
||||
version = "1.2.1"
|
||||
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"
|
||||
repository = "https://git.salastil.com/Salastil/nobilis"
|
||||
repository = "https://github.com/Moho-Chat/nobilis"
|
||||
authors = ["Salastil <[email protected]>"]
|
||||
|
||||
[[bin]]
|
||||
name = "nobilis"
|
||||
@@ -32,6 +33,7 @@ tokio-util = { version = "0.7", features = ["io"] }
|
||||
|
||||
# Discord backend (QR remote-auth login + gateway client)
|
||||
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
|
||||
regex = "1"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "multipart", "http2", "socks", "stream"] }
|
||||
# Pulled in transitively by both of the above, but with conflicting default
|
||||
# crypto-provider features (ring vs aws-lc-rs) - direct so main.rs can pin
|
||||
@@ -57,6 +59,12 @@ qrcode = { version = "0.14", features = ["image"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "gif"] }
|
||||
chrono = { version = "0.4", default-features = false, features = ["std"] }
|
||||
|
||||
# Telegram backend (MTProto, pure Rust). `grammers-session` without its default `sqlite-storage` (libsql would sit
|
||||
# beside rusqlite and matrix-sdk-sqlite): the session is serialized into the account instead - see backend/telegram.
|
||||
grammers-client = { version = "=0.11.0", default-features = false, features = ["proxy", "fs"] }
|
||||
grammers-mtsender = { version = "=0.11.0", default-features = false, features = ["proxy"] }
|
||||
grammers-session = { version = "=0.11.0", default-features = false, features = ["serde"] }
|
||||
|
||||
# Sneedchat backend (SneedChat over embedded Tor)
|
||||
arti-client = { version = "0.47", default-features = false, features = ["tokio", "rustls", "onion-service-client", "compression"] }
|
||||
tor-rtcompat = { version = "0.47", features = ["tokio", "rustls"] }
|
||||
@@ -73,6 +81,14 @@ url = "2"
|
||||
webpki-roots = "0.26"
|
||||
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] }
|
||||
|
||||
# Steam backend: its connection managers speak protobuf inside a WebSocket,
|
||||
# and compress a run of packets together as gzip - see backend/steam. The
|
||||
# messages are written out by hand from Valve's own definitions, so no
|
||||
# protoc is needed to build, and each is checked against bytes the real
|
||||
# definitions produced.
|
||||
prost = { version = "0.14", default-features = false, features = ["derive"] }
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
|
||||
# Matrix backend (Client-Server API over plain reqwest + full E2EE via
|
||||
# matrix-sdk-crypto, the standalone vodozemac-backed Olm/Megolm state
|
||||
# machine matrix-sdk itself uses - see backend/matrix/crypto.rs)
|
||||
@@ -110,6 +126,7 @@ opus2 = "0.4"
|
||||
sonora = "0.2"
|
||||
# Soundboard sounds are MP3 or Ogg Vorbis, decoded here to be mixed into a call.
|
||||
symphonia = { version = "0.5", default-features = false, features = ["mp3", "ogg", "vorbis"] }
|
||||
imagesize = "0.13"
|
||||
|
||||
# Two calls, both in main.rs: see keep_the_heap_honest() there for why a chat
|
||||
# daemon has anything to say to its allocator.
|
||||
@@ -130,3 +147,9 @@ libc = "0.2"
|
||||
# which it has. Reported upstream; drop this once a release carries the fix.
|
||||
[patch.crates-io]
|
||||
saturating-time = { path = "vendor/saturating-time" }
|
||||
|
||||
# Local debug builds keep line numbers for backtraces and not the full debug
|
||||
# information, which made each one several times the size of the program. A
|
||||
# release build, which is what ships, is not affected.
|
||||
[profile.dev]
|
||||
debug = "line-tables-only"
|
||||
|
||||
@@ -4,34 +4,15 @@ A chat daemon. It speaks IRC, Discord, Matrix, Kick and Sneedchat (SneedChat, th
|
||||
into Kiwi Farms), and translates all of them into one unified JSON model — accounts, buffers, messages
|
||||
— served over a Unix socket.
|
||||
|
||||
Frontends talk to that model and never to a protocol. Two exist today: [moho](https://git.salastil.com/Salastil/moho),
|
||||
Frontends talk to that model and never to a protocol. Two exist today: [moho](https://github.com/Moho-Chat/moho),
|
||||
an Electron desktop client, and dms-chat, a Quickshell/QML shell plugin.
|
||||
|
||||
## Why a daemon
|
||||
|
||||
Connections, Tor circuits and Matrix sync are expensive to establish and unpleasant to lose.
|
||||
Keeping them in a long-lived process means a frontend can close, restart, crash or be replaced
|
||||
mid-session without dropping off IRC or resyncing Matrix from scratch. It also means more than
|
||||
one frontend can attach at once — the socket accepts multiple clients, each with its own
|
||||
subscriptions.
|
||||
|
||||
```
|
||||
src/backend/ one module per protocol (irc, discord, sneedchat, matrix, kick)
|
||||
src/rpc/ the Unix-socket JSON-RPC server
|
||||
src/runtime.rs protocol-agnostic connection/buffer/message state
|
||||
src/store.rs SQLite-backed scrollback persistence
|
||||
src/accounts.rs account and credential persistence
|
||||
resources/ assets the protocols need (Sneedchat's smiley table)
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Takes no required arguments. `--data-dir` and `--socket-path` override the defaults below.
|
||||
|
||||
## Wire protocol
|
||||
|
||||
Newline-delimited JSON over a Unix socket at `$XDG_RUNTIME_DIR/nobilis/nobilis.sock`:
|
||||
@@ -42,75 +23,16 @@ response: {"id": 1, "result": {...}} or {"id": 1, "error": "..."}
|
||||
push: {"event": "message", "data": {...}}
|
||||
```
|
||||
|
||||
Core methods: `listAccounts`, `listBuffers`, `listProtocols`, `addAccount`, `removeAccount`,
|
||||
`setAccountConnected`, `joinBuffer`, `partBuffer`, `sendMessage`, `editMessage`, `deleteMessage`,
|
||||
`toggleReaction`, `subscribe`/`unsubscribe`, `getBacklog`. Each protocol also has its own
|
||||
account-creation method (`addAccount` for IRC, `addDiscordAccount`, `addSneedChatAccount`,
|
||||
`addMatrixAccount`).
|
||||
|
||||
Push events: `message`, `messageUpdated`, `messageDeleted`, `reactionsChanged`, `presenceChange`,
|
||||
`bufferListChange`, `connectionState`, `notification`, plus per-protocol login-flow events
|
||||
(`discordLoginQr`/`discordLoginScanned`/`discordLoginResult`, `sneedChatLoginStatus`/
|
||||
`sneedChatLoginResult`, `matrixLoginStatus`/`matrixLoginResult`) and Matrix verification events
|
||||
(`matrixVerificationStatus`/`matrixVerificationEmoji`/`matrixVerificationResult`).
|
||||
The calls and events are whatever `src/rpc/methods.rs` matches on and the runtime emits - that
|
||||
is the one list that cannot go stale, and moho's
|
||||
[ARCHITECTURE.md](https://github.com/Moho-Chat/moho/blob/master/ARCHITECTURE.md) describes the
|
||||
shape of it and what each backend implements. The ones every service shares are `listAccounts`,
|
||||
`listBuffers`, `sendMessage`, `editMessage`, `deleteMessage`, `toggleReaction`, `subscribe` and
|
||||
`getBacklog`; everything a service has that the others do not is a call of its own.
|
||||
|
||||
`message`, `presenceChange`, `messageUpdated`, `messageDeleted` and `reactionsChanged` are only
|
||||
delivered to clients that called `subscribe` for that buffer; everything else broadcasts.
|
||||
|
||||
### Notes for frontend authors
|
||||
|
||||
- An ordinary message arrives with `kind` `"chat"` from IRC, Discord and Sneedchat, but
|
||||
`"message"` from Matrix. Treat both as "something a person said"; every other kind
|
||||
(`system`, `join`, `part`, `nick`, `topic`, `matrixJoin`, `matrixInvite`, `matrixKick`,
|
||||
`matrixQuit`) is a log line, and which of those to render is the frontend's choice — nobilis
|
||||
always records them.
|
||||
- Media that nobilis fetched on the client's behalf (Sneedchat avatars and attachments, Matrix
|
||||
media, shrunk Kick emotes, Discord thumbnails and stickers, the Discord login QR) is handed over as a local `file://` path or
|
||||
filesystem path, not a remote URL. This assumes the frontend runs on the same machine.
|
||||
- `listSneedchatSmilies` returns a bare filename per smiley, resolved against `resources/sneedchat-smilies/`
|
||||
in this repository. A frontend that renders them needs its own copy of that directory.
|
||||
|
||||
## Protocol backends
|
||||
|
||||
A summary; moho's [ARCHITECTURE.md](https://github.com/Moho-Chat/moho/blob/master/ARCHITECTURE.md)
|
||||
has each one in full.
|
||||
|
||||
- **IRC** — TLS with SASL (PLAIN, EXTERNAL, SCRAM-SHA-256), STS, twenty-seven IRCv3
|
||||
capabilities including chathistory, multiline and message redaction, NickServ, DCC, and image
|
||||
uploads to catbox, postimg, ibb.co or imgur.
|
||||
- **Discord** — QR login or Discord's own sign-in page, the user gateway, messages with
|
||||
everything the official client does to them, threads, forums, slash commands, polls,
|
||||
stickers, events and AutoMod. Voice, camera and Go Live streams are its own connections, with
|
||||
DAVE end-to-end encryption, echo cancellation and the soundboard. Nothing Discord puts behind
|
||||
a captcha is attempted.
|
||||
- **Matrix** — Client-Server API with full end-to-end encryption (vodozemac-backed Olm/Megolm via
|
||||
`matrix-sdk-crypto`), device verification by emoji or QR, cross-signing, key backup, sliding
|
||||
sync, spaces, polls, forwarding, room moderation, and calls including Element Call.
|
||||
- **Kick** — chat over its Pusher socket with 7TV and BTTV emotes, moderation, polls,
|
||||
predictions and redemptions, and the stream's own HLS playlist, VODs and clips.
|
||||
- **Sneedchat (SneedChat)** — on the open internet by default, or over an embedded Tor client (or
|
||||
an external SOCKS5 proxy) at the onion address. Solves the site's proof-of-work anti-bot gate
|
||||
and its login captcha, and holds one persistent websocket per configured room behind a single
|
||||
login. Avatars and attachments are fetched through the same route and cached locally.
|
||||
|
||||
Any account on any of the five can be routed through Tor or a SOCKS5 proxy.
|
||||
|
||||
## On-disk state
|
||||
|
||||
| Path | Contents |
|
||||
|---|---|
|
||||
| `~/.config/nobilis/accounts.toml` | account config and credentials |
|
||||
| `~/.config/nobilis/scrollback.db` | SQLite scrollback |
|
||||
| `~/.config/nobilis/matrix-crypto/` | Matrix E2EE device keys and Olm sessions |
|
||||
| `~/.cache/nobilis/` | re-derivable media caches |
|
||||
| `$XDG_RUNTIME_DIR/nobilis/nobilis.sock` | the control socket |
|
||||
|
||||
An `flock` on the data directory keeps a second instance from racing the first; a second process
|
||||
notices and exits rather than competing for the socket.
|
||||
|
||||
State from when this daemon was called `chatd` and lived in the moho repository (`~/.config/moho`,
|
||||
`~/.cache/moho`) is moved across automatically on first run.
|
||||
|
||||
## Licence
|
||||
|
||||
GPL-3.0-or-later. See [LICENSE](LICENSE).
|
||||
|
||||
+771
-4
@@ -366,6 +366,177 @@ impl KickAccountConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// A Steam account.
|
||||
///
|
||||
/// What is kept is a **refresh token**, never the password: the password is
|
||||
/// held for the length of the sign-in (see backend/steam/auth.rs) and then
|
||||
/// forgotten, and the token is what signs this account in from then on. It is
|
||||
/// revoked from Steam's own list of signed-in devices, and signing in again
|
||||
/// replaces it.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||
pub struct SteamAccountConfig {
|
||||
/// The name the account signs in with. Lower-case: Steam does not tell
|
||||
/// "Someone" and "someone" apart, and neither can the id built from it.
|
||||
pub username: String,
|
||||
/// The 64-bit id Steam gave the account on sign-in.
|
||||
#[serde(default)]
|
||||
pub steamid: u64,
|
||||
/// What to call this account here. Local: Steam is never told about it.
|
||||
#[serde(default)]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub refresh_token: String,
|
||||
/// Steam's note that this machine was approved, sent with the next
|
||||
/// sign-in so Steam Guard does not ask for a code every time.
|
||||
#[serde(default)]
|
||||
pub guard_data: Option<String>,
|
||||
/// Which kind of session this account signed in as. Unused now: moho signs in as a desktop client of its own
|
||||
/// (see backend/steam/auth.rs); kept so files written when it was chosen read the same.
|
||||
#[serde(default)]
|
||||
pub session: String,
|
||||
/// Whether this account's connections go through Tor or the SOCKS5 proxy
|
||||
/// set in the network settings. Off by default.
|
||||
#[serde(default)]
|
||||
pub use_tor: bool,
|
||||
/// Strict routing: everything the window loads for this account's
|
||||
/// conversations goes through the route too.
|
||||
#[serde(default)]
|
||||
pub strict_route: bool,
|
||||
}
|
||||
|
||||
impl SteamAccountConfig {
|
||||
pub fn account_id(&self) -> String {
|
||||
format!("steam:{}", self.username)
|
||||
}
|
||||
}
|
||||
|
||||
/// A Twitch account: a signed-in person, or an anonymous reader.
|
||||
///
|
||||
/// What is kept is the pair of tokens the device flow handed back (see
|
||||
/// backend/twitch/auth.rs), never a password - Twitch's sign-in happens on
|
||||
/// twitch.tv, in the person's own browser, and moho only ever sees the result.
|
||||
/// The access token lasts four hours and is renewed with the refresh token,
|
||||
/// which Twitch lets go of after thirty days unused.
|
||||
///
|
||||
/// An anonymous reader has neither and reads every channel, as a signed-out
|
||||
/// Kick account does; sending needs the sign-in.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||
pub struct TwitchAccountConfig {
|
||||
/// The login, lower-case: Twitch does not tell "Someone" and "someone"
|
||||
/// apart, and neither can the id built from it. Empty when anonymous.
|
||||
#[serde(default)]
|
||||
pub login: String,
|
||||
/// Twitch's own number for the account, which is what its API is keyed by
|
||||
/// and which survives the login being renamed.
|
||||
#[serde(default)]
|
||||
pub user_id: String,
|
||||
/// What to call this account here. Local: Twitch is never told about it.
|
||||
#[serde(default)]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub access_token: String,
|
||||
#[serde(default)]
|
||||
pub refresh_token: String,
|
||||
/// When the access token stops working, in Unix seconds.
|
||||
#[serde(default)]
|
||||
pub expires_at: i64,
|
||||
/// What the person agreed to when they signed in.
|
||||
#[serde(default)]
|
||||
pub scopes: Vec<String>,
|
||||
/// The channels this account has open, by login, so they come back.
|
||||
#[serde(default)]
|
||||
pub channels: Vec<String>,
|
||||
/// The `auth-token` of a twitch.tv session this person signed in to for
|
||||
/// video, if they did. A credential of the same standing as the tokens
|
||||
/// above and kept the same way; it is what makes their Turbo or their
|
||||
/// subscriptions count when a stream is played (see backend/twitch/playback.rs).
|
||||
#[serde(default)]
|
||||
pub web_token: String,
|
||||
/// Whether the channels this account follows have been opened already. Once, at first connection: a list
|
||||
/// read again every time would put back every channel the person had closed.
|
||||
#[serde(default)]
|
||||
pub followed_synced: bool,
|
||||
/// An account with no sign-in, which reads and does not send.
|
||||
#[serde(default)]
|
||||
pub anonymous: bool,
|
||||
/// Whether this account's connections go through Tor or the SOCKS5 proxy
|
||||
/// set in the network settings. Off by default.
|
||||
#[serde(default)]
|
||||
pub use_tor: bool,
|
||||
/// Strict routing: everything the window loads for this account's
|
||||
/// conversations goes through the route too.
|
||||
#[serde(default)]
|
||||
pub strict_route: bool,
|
||||
}
|
||||
|
||||
/// The id of the one anonymous Twitch account. A `~` cannot be in a Twitch
|
||||
/// login, so no signed-in account can ever have it.
|
||||
pub const TWITCH_ANONYMOUS_ID: &str = "twitch:~anonymous";
|
||||
|
||||
impl TwitchAccountConfig {
|
||||
pub fn account_id(&self) -> String {
|
||||
if self.anonymous {
|
||||
TWITCH_ANONYMOUS_ID.to_string()
|
||||
} else {
|
||||
format!("twitch:{}", self.login)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether there is a sign-in behind this account: what "signed in" means
|
||||
/// for one that never keeps a password.
|
||||
pub fn is_signed_in(&self) -> bool {
|
||||
!self.anonymous && !self.access_token.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// A Telegram account: a phone number's, signed in over MTProto (see backend/telegram).
|
||||
///
|
||||
/// Telegram gives the owner of a phone number one `api_id` and `api_hash`, and a client sends them with every
|
||||
/// connection. Moho carries none of its own: the ones the person typed into the sign-in form are kept here, with
|
||||
/// the account, and are as private as the session is. The session itself (the authorization key Telegram issued,
|
||||
/// and what a client caches to catch up on updates) is large and changes often, so it is a file of its own in the
|
||||
/// daemon's data directory (see backend/telegram/session.rs), not a field of this one.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||
pub struct TelegramAccountConfig {
|
||||
#[serde(default)]
|
||||
pub api_id: i32,
|
||||
#[serde(default)]
|
||||
pub api_hash: String,
|
||||
/// The number the account was signed in with, in international form (`+15551234567`).
|
||||
#[serde(default)]
|
||||
pub phone: String,
|
||||
/// Telegram's number for the account, which survives the number or the name being changed. The account's id here
|
||||
/// is built from it.
|
||||
#[serde(default)]
|
||||
pub user_id: i64,
|
||||
/// The `@name`, if the account has one. Telegram's, not ours.
|
||||
#[serde(default)]
|
||||
pub username: Option<String>,
|
||||
/// The account's own name in Telegram (first and last), for the sidebar.
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
/// What to call this account here. Local: Telegram is never told about it.
|
||||
#[serde(default)]
|
||||
pub display_name: Option<String>,
|
||||
/// Whether this account's connections go through Tor or the SOCKS5 proxy set in the network settings.
|
||||
#[serde(default)]
|
||||
pub use_tor: bool,
|
||||
/// Strict routing: everything the window loads for this account's conversations goes through the route too.
|
||||
#[serde(default)]
|
||||
pub strict_route: bool,
|
||||
}
|
||||
|
||||
impl TelegramAccountConfig {
|
||||
pub fn account_id(&self) -> String {
|
||||
format!("telegram:{}", self.user_id)
|
||||
}
|
||||
|
||||
/// Whether there is a sign-in behind this account: one that has been through is what has an id.
|
||||
pub fn is_signed_in(&self) -> bool {
|
||||
self.user_id != 0 && self.api_id != 0 && !self.api_hash.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||
struct AccountsFile {
|
||||
#[serde(default, rename = "account")]
|
||||
@@ -382,6 +553,12 @@ struct AccountsFile {
|
||||
matrix: Vec<MatrixAccountConfig>,
|
||||
#[serde(default, rename = "kick_account")]
|
||||
kick: Vec<KickAccountConfig>,
|
||||
#[serde(default, rename = "steam_account")]
|
||||
steam: Vec<SteamAccountConfig>,
|
||||
#[serde(default, rename = "twitch_account")]
|
||||
twitch: Vec<TwitchAccountConfig>,
|
||||
#[serde(default, rename = "telegram_account")]
|
||||
telegram: Vec<TelegramAccountConfig>,
|
||||
}
|
||||
|
||||
/// Account/credential persistence at ~/.config/nobilis/accounts.toml,
|
||||
@@ -395,11 +572,25 @@ pub struct AccountStore {
|
||||
sneedchat: Mutex<HashMap<String, SneedChatAccountConfig>>,
|
||||
matrix: Mutex<HashMap<String, MatrixAccountConfig>>,
|
||||
kick: Mutex<HashMap<String, KickAccountConfig>>,
|
||||
/// Read by `persist` itself rather than handed to it, so the methods that
|
||||
/// predate Steam did not each need a sixth argument. Anything that
|
||||
/// changes this map must let go of its lock before it calls `persist`.
|
||||
steam: Mutex<HashMap<String, SteamAccountConfig>>,
|
||||
/// Read by `persist` the same way, for the same reason.
|
||||
twitch: Mutex<HashMap<String, TwitchAccountConfig>>,
|
||||
/// And this one.
|
||||
telegram: Mutex<HashMap<String, TelegramAccountConfig>>,
|
||||
}
|
||||
|
||||
impl AccountStore {
|
||||
/// Where the file is, for a test that reads it back.
|
||||
#[cfg(test)]
|
||||
pub fn path_for_tests(&self) -> &std::path::Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub fn open(path: PathBuf) -> Result<Self> {
|
||||
let (irc, discord, sneedchat, matrix, kick) = if path.exists() {
|
||||
let (irc, discord, sneedchat, matrix, kick, steam, twitch, telegram) = if path.exists() {
|
||||
let text = std::fs::read_to_string(&path)
|
||||
.with_context(|| format!("reading {}", path.display()))?;
|
||||
let file: AccountsFile = toml::from_str(&text)
|
||||
@@ -409,11 +600,24 @@ impl AccountStore {
|
||||
let sneedchat = file.sneedchat.into_iter().map(|a| (a.account_id(), a)).collect();
|
||||
let matrix = file.matrix.into_iter().map(|a| (a.account_id(), a)).collect();
|
||||
let kick = file.kick.into_iter().map(|a| (a.account_id(), a)).collect();
|
||||
(irc, discord, sneedchat, matrix, kick)
|
||||
let steam = file.steam.into_iter().map(|a| (a.account_id(), a)).collect();
|
||||
let twitch = file.twitch.into_iter().map(|a| (a.account_id(), a)).collect();
|
||||
let telegram = file.telegram.into_iter().map(|a| (a.account_id(), a)).collect();
|
||||
(irc, discord, sneedchat, matrix, kick, steam, twitch, telegram)
|
||||
} else {
|
||||
(HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new())
|
||||
(HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new())
|
||||
};
|
||||
Ok(Self { path, irc: Mutex::new(irc), discord: Mutex::new(discord), sneedchat: Mutex::new(sneedchat), matrix: Mutex::new(matrix), kick: Mutex::new(kick) })
|
||||
Ok(Self {
|
||||
path,
|
||||
irc: Mutex::new(irc),
|
||||
discord: Mutex::new(discord),
|
||||
sneedchat: Mutex::new(sneedchat),
|
||||
matrix: Mutex::new(matrix),
|
||||
kick: Mutex::new(kick),
|
||||
steam: Mutex::new(steam),
|
||||
twitch: Mutex::new(twitch),
|
||||
telegram: Mutex::new(telegram),
|
||||
})
|
||||
}
|
||||
|
||||
/// Callers already hold whichever map they just mutated - the others
|
||||
@@ -433,6 +637,9 @@ impl AccountStore {
|
||||
sneedchat: sneedchat.values().cloned().collect(),
|
||||
matrix: matrix.values().cloned().collect(),
|
||||
kick: kick.values().cloned().collect(),
|
||||
steam: self.steam.lock().unwrap().values().cloned().collect(),
|
||||
twitch: self.twitch.lock().unwrap().values().cloned().collect(),
|
||||
telegram: self.telegram.lock().unwrap().values().cloned().collect(),
|
||||
};
|
||||
let text = toml::to_string_pretty(&file)?;
|
||||
// Atomic write-temp-then-rename, same spirit as libpurple's periodic
|
||||
@@ -576,6 +783,142 @@ impl AccountStore {
|
||||
self.kick.lock().unwrap().values().cloned().collect()
|
||||
}
|
||||
|
||||
pub fn get_steam(&self, account_id: &str) -> Option<SteamAccountConfig> {
|
||||
self.steam.lock().unwrap().get(account_id).cloned()
|
||||
}
|
||||
|
||||
pub fn all_steam(&self) -> Vec<SteamAccountConfig> {
|
||||
self.steam.lock().unwrap().values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Upserts: signing in again with the same name is how a token Steam has
|
||||
/// stopped accepting gets replaced. A route that was on stays on, as with
|
||||
/// the others - a sign-in from the account's own card does not ask.
|
||||
pub fn add_steam(&self, mut config: SteamAccountConfig) -> Result<SteamAccountConfig> {
|
||||
config.username = config.username.trim().to_lowercase();
|
||||
let id = config.account_id();
|
||||
{
|
||||
let mut steam = self.steam.lock().unwrap();
|
||||
if let Some(old) = steam.get(&id) {
|
||||
config.strict_route |= old.strict_route;
|
||||
config.use_tor |= old.use_tor;
|
||||
if config.display_name.is_none() {
|
||||
config.display_name = old.display_name.clone();
|
||||
}
|
||||
}
|
||||
steam.insert(id, config.clone());
|
||||
}
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sneedchat.lock().unwrap(), &self.matrix.lock().unwrap(), &self.kick.lock().unwrap())?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Changes one Steam account, and writes the file. `false` when there is
|
||||
/// no such account.
|
||||
///
|
||||
/// The lock is let go before the write: `persist` takes it again.
|
||||
pub fn mutate_steam(&self, account_id: &str, change: impl FnOnce(&mut SteamAccountConfig)) -> Result<bool> {
|
||||
{
|
||||
let mut steam = self.steam.lock().unwrap();
|
||||
match steam.get_mut(account_id) {
|
||||
None => return Ok(false),
|
||||
Some(account) => change(account),
|
||||
}
|
||||
}
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sneedchat.lock().unwrap(), &self.matrix.lock().unwrap(), &self.kick.lock().unwrap())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn get_twitch(&self, account_id: &str) -> Option<TwitchAccountConfig> {
|
||||
self.twitch.lock().unwrap().get(account_id).cloned()
|
||||
}
|
||||
|
||||
pub fn all_twitch(&self) -> Vec<TwitchAccountConfig> {
|
||||
self.twitch.lock().unwrap().values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Upserts: signing in again with the same login is how a token Twitch has
|
||||
/// stopped accepting gets replaced. What the person set up on the account
|
||||
/// - its route, its name here, the channels it had open - stays.
|
||||
pub fn add_twitch(&self, mut config: TwitchAccountConfig) -> Result<TwitchAccountConfig> {
|
||||
config.login = config.login.trim().to_lowercase();
|
||||
let id = config.account_id();
|
||||
{
|
||||
let mut twitch = self.twitch.lock().unwrap();
|
||||
if let Some(old) = twitch.get(&id) {
|
||||
config.strict_route |= old.strict_route;
|
||||
config.use_tor |= old.use_tor;
|
||||
if config.display_name.is_none() {
|
||||
config.display_name = old.display_name.clone();
|
||||
}
|
||||
if config.channels.is_empty() {
|
||||
config.channels = old.channels.clone();
|
||||
}
|
||||
if config.web_token.is_empty() {
|
||||
config.web_token = old.web_token.clone();
|
||||
}
|
||||
}
|
||||
twitch.insert(id, config.clone());
|
||||
}
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sneedchat.lock().unwrap(), &self.matrix.lock().unwrap(), &self.kick.lock().unwrap())?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Changes one Twitch account, and writes the file. `false` when there is
|
||||
/// no such account. The lock is let go before the write.
|
||||
pub fn mutate_twitch(&self, account_id: &str, change: impl FnOnce(&mut TwitchAccountConfig)) -> Result<bool> {
|
||||
{
|
||||
let mut twitch = self.twitch.lock().unwrap();
|
||||
match twitch.get_mut(account_id) {
|
||||
None => return Ok(false),
|
||||
Some(account) => change(account),
|
||||
}
|
||||
}
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sneedchat.lock().unwrap(), &self.matrix.lock().unwrap(), &self.kick.lock().unwrap())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn get_telegram(&self, account_id: &str) -> Option<TelegramAccountConfig> {
|
||||
self.telegram.lock().unwrap().get(account_id).cloned()
|
||||
}
|
||||
|
||||
pub fn all_telegram(&self) -> Vec<TelegramAccountConfig> {
|
||||
self.telegram.lock().unwrap().values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Upserts: signing in again as the same account is how a session Telegram has ended gets replaced. What the
|
||||
/// person set up on the account - its route, its name here - stays.
|
||||
pub fn add_telegram(&self, mut config: TelegramAccountConfig) -> Result<TelegramAccountConfig> {
|
||||
let id = config.account_id();
|
||||
{
|
||||
let mut telegram = self.telegram.lock().unwrap();
|
||||
if let Some(old) = telegram.get(&id) {
|
||||
config.strict_route |= old.strict_route;
|
||||
config.use_tor |= old.use_tor;
|
||||
if config.display_name.is_none() {
|
||||
config.display_name = old.display_name.clone();
|
||||
}
|
||||
}
|
||||
telegram.insert(id, config.clone());
|
||||
}
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sneedchat.lock().unwrap(), &self.matrix.lock().unwrap(), &self.kick.lock().unwrap())?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Changes one Telegram account, and writes the file. `false` when there is no such account.
|
||||
///
|
||||
/// The lock is let go before the write: `persist` takes it again.
|
||||
pub fn mutate_telegram(&self, account_id: &str, change: impl FnOnce(&mut TelegramAccountConfig)) -> Result<bool> {
|
||||
{
|
||||
let mut telegram = self.telegram.lock().unwrap();
|
||||
match telegram.get_mut(account_id) {
|
||||
None => return Ok(false),
|
||||
Some(account) => change(account),
|
||||
}
|
||||
}
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sneedchat.lock().unwrap(), &self.matrix.lock().unwrap(), &self.kick.lock().unwrap())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Upserts, like the others: signing in to Kick again with the same
|
||||
/// username is how a rejected token gets replaced.
|
||||
pub fn add_kick(&self, mut config: KickAccountConfig) -> Result<KickAccountConfig> {
|
||||
@@ -710,6 +1053,18 @@ impl AccountStore {
|
||||
a.use_tor = routed;
|
||||
a.strict_route = strict;
|
||||
true
|
||||
} else if let Some(a) = self.steam.lock().unwrap().get_mut(account_id) {
|
||||
a.use_tor = routed;
|
||||
a.strict_route = strict;
|
||||
true
|
||||
} else if let Some(a) = self.twitch.lock().unwrap().get_mut(account_id) {
|
||||
a.use_tor = routed;
|
||||
a.strict_route = strict;
|
||||
true
|
||||
} else if let Some(a) = self.telegram.lock().unwrap().get_mut(account_id) {
|
||||
a.use_tor = routed;
|
||||
a.strict_route = strict;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
@@ -734,6 +1089,15 @@ impl AccountStore {
|
||||
if let Some(a) = self.get_matrix(account_id) {
|
||||
return pick(a.use_tor, a.strict_route);
|
||||
}
|
||||
if let Some(a) = self.get_steam(account_id) {
|
||||
return pick(a.use_tor, a.strict_route);
|
||||
}
|
||||
if let Some(a) = self.get_twitch(account_id) {
|
||||
return pick(a.use_tor, a.strict_route);
|
||||
}
|
||||
if let Some(a) = self.get_telegram(account_id) {
|
||||
return pick(a.use_tor, a.strict_route);
|
||||
}
|
||||
self.get_kick(account_id).and_then(|a| pick(a.use_tor, a.strict_route))
|
||||
}
|
||||
|
||||
@@ -807,6 +1171,27 @@ impl AccountStore {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
{
|
||||
let removed = self.steam.lock().unwrap().remove(account_id).is_some();
|
||||
if removed {
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sneedchat.lock().unwrap(), &self.matrix.lock().unwrap(), &self.kick.lock().unwrap())?;
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
{
|
||||
let removed = self.twitch.lock().unwrap().remove(account_id).is_some();
|
||||
if removed {
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sneedchat.lock().unwrap(), &self.matrix.lock().unwrap(), &self.kick.lock().unwrap())?;
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
{
|
||||
let removed = self.telegram.lock().unwrap().remove(account_id).is_some();
|
||||
if removed {
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sneedchat.lock().unwrap(), &self.matrix.lock().unwrap(), &self.kick.lock().unwrap())?;
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
let mut kick = self.kick.lock().unwrap();
|
||||
if kick.remove(account_id).is_some() {
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sneedchat.lock().unwrap(), &self.matrix.lock().unwrap(), &kick)?;
|
||||
@@ -965,6 +1350,15 @@ impl AccountStore {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
if self.mutate_steam(account_id, |a| a.display_name = value.clone())? {
|
||||
return Ok(true);
|
||||
}
|
||||
if self.mutate_twitch(account_id, |a| a.display_name = value.clone())? {
|
||||
return Ok(true);
|
||||
}
|
||||
if self.mutate_telegram(account_id, |a| a.display_name = value.clone())? {
|
||||
return Ok(true);
|
||||
}
|
||||
let mut kick = self.kick.lock().unwrap();
|
||||
match kick.get_mut(account_id) {
|
||||
None => Ok(false),
|
||||
@@ -995,6 +1389,15 @@ impl AccountStore {
|
||||
if let Some(a) = self.matrix.lock().unwrap().get(account_id) {
|
||||
return named(&a.display_name);
|
||||
}
|
||||
if let Some(a) = self.steam.lock().unwrap().get(account_id) {
|
||||
return named(&a.display_name);
|
||||
}
|
||||
if let Some(a) = self.twitch.lock().unwrap().get(account_id) {
|
||||
return named(&a.display_name);
|
||||
}
|
||||
if let Some(a) = self.telegram.lock().unwrap().get(account_id) {
|
||||
return named(&a.display_name);
|
||||
}
|
||||
self.kick.lock().unwrap().get(account_id).and_then(|a| named(&a.display_name))
|
||||
}
|
||||
|
||||
@@ -1083,6 +1486,7 @@ pub fn irc_account_to_json(a: &IrcAccountConfig, state: &str) -> Account {
|
||||
Account {
|
||||
status: "online".to_string(),
|
||||
status_text: String::new(),
|
||||
auto_idle: false,
|
||||
display_name: a.display_name.clone().filter(|s| !s.is_empty()).unwrap_or_else(|| id.clone()),
|
||||
id,
|
||||
service: "irc".to_string(),
|
||||
@@ -1113,6 +1517,8 @@ pub fn irc_account_to_json(a: &IrcAccountConfig, state: &str) -> Account {
|
||||
rtc_focus_url: None,
|
||||
sliding_sync: false,
|
||||
dehydration: false,
|
||||
web_session: false,
|
||||
moderation: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1125,6 +1531,7 @@ pub fn discord_account_to_json(a: &DiscordAccountConfig, state: &str) -> Account
|
||||
service: "discord".to_string(),
|
||||
status: "online".to_string(),
|
||||
status_text: String::new(),
|
||||
auto_idle: false,
|
||||
display_name: a.display_name.clone().filter(|s| !s.is_empty()).unwrap_or_else(|| a.username.clone()),
|
||||
state: state.to_string(),
|
||||
autojoin: String::new(),
|
||||
@@ -1151,6 +1558,8 @@ pub fn discord_account_to_json(a: &DiscordAccountConfig, state: &str) -> Account
|
||||
rtc_focus_url: None,
|
||||
sliding_sync: false,
|
||||
dehydration: false,
|
||||
web_session: false,
|
||||
moderation: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1162,6 +1571,7 @@ pub fn sneedchat_account_to_json(a: &SneedChatAccountConfig, state: &str) -> Acc
|
||||
service: "sneedchat".to_string(),
|
||||
status: "online".to_string(),
|
||||
status_text: String::new(),
|
||||
auto_idle: false,
|
||||
display_name: a.display_name.clone().filter(|s| !s.is_empty()).unwrap_or_else(|| a.username.clone()),
|
||||
state: state.to_string(),
|
||||
autojoin: String::new(),
|
||||
@@ -1188,6 +1598,8 @@ pub fn sneedchat_account_to_json(a: &SneedChatAccountConfig, state: &str) -> Acc
|
||||
rtc_focus_url: None,
|
||||
sliding_sync: false,
|
||||
dehydration: false,
|
||||
web_session: false,
|
||||
moderation: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1204,6 +1616,7 @@ pub fn kick_account_to_json(a: &KickAccountConfig, state: &str) -> Account {
|
||||
service: "kick".to_string(),
|
||||
status: "online".to_string(),
|
||||
status_text: String::new(),
|
||||
auto_idle: false,
|
||||
display_name: a.display_name.clone().filter(|s| !s.is_empty()).unwrap_or_else(|| a.username.clone()),
|
||||
state: state.to_string(),
|
||||
// The watched channels are deliberately not reported as `autojoin`.
|
||||
@@ -1234,6 +1647,143 @@ pub fn kick_account_to_json(a: &KickAccountConfig, state: &str) -> Account {
|
||||
rtc_focus_url: None,
|
||||
sliding_sync: false,
|
||||
dehydration: false,
|
||||
web_session: false,
|
||||
moderation: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Steam has none of IRC's concepts either. What it has that the others
|
||||
/// have too is a name to show, and whether it is signed in: `has_password`
|
||||
/// says there is a token, which is what "signed in" means for an account that
|
||||
/// never keeps a password.
|
||||
///
|
||||
/// `persona_name` is what Steam calls the account, learned on connecting; the
|
||||
/// sign-in name is shown until then.
|
||||
pub fn steam_account_to_json(a: &SteamAccountConfig, state: &str, persona_name: Option<&str>) -> Account {
|
||||
Account {
|
||||
id: a.account_id(),
|
||||
service: "steam".to_string(),
|
||||
status: "online".to_string(),
|
||||
status_text: String::new(),
|
||||
auto_idle: false,
|
||||
display_name: a
|
||||
.display_name
|
||||
.clone()
|
||||
.filter(|s| !s.is_empty())
|
||||
.or_else(|| persona_name.filter(|n| !n.is_empty()).map(str::to_string))
|
||||
.unwrap_or_else(|| a.username.clone()),
|
||||
state: state.to_string(),
|
||||
autojoin: String::new(),
|
||||
has_nickserv_password: false,
|
||||
sasl_enabled: false,
|
||||
sasl_username: String::new(),
|
||||
allow_plaintext_sasl: false,
|
||||
reconnect_for_sasl: false,
|
||||
sasl_mechanism: String::new(),
|
||||
has_sasl_certificate: false,
|
||||
current_nick: String::new(),
|
||||
highlight_keywords: Vec::new(),
|
||||
ssl: true,
|
||||
has_password: !a.refresh_token.is_empty(),
|
||||
avatar_url: None,
|
||||
sneedchat_rooms: Vec::new(),
|
||||
tor_mode: None,
|
||||
tor_proxy: None,
|
||||
use_tor: a.use_tor,
|
||||
route_level: route_level(a.use_tor, a.strict_route),
|
||||
has_key_backup: false,
|
||||
rtc_focus_url: None,
|
||||
sliding_sync: false,
|
||||
dehydration: false,
|
||||
web_session: false,
|
||||
moderation: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Twitch has none of IRC's concepts either. `has_password` says there is a
|
||||
/// token, which is what "signed in" means for an account that never keeps a
|
||||
/// password; an anonymous reader has none and says so.
|
||||
pub fn twitch_account_to_json(a: &TwitchAccountConfig, state: &str) -> Account {
|
||||
Account {
|
||||
id: a.account_id(),
|
||||
service: "twitch".to_string(),
|
||||
status: "online".to_string(),
|
||||
status_text: String::new(),
|
||||
auto_idle: false,
|
||||
display_name: a
|
||||
.display_name
|
||||
.clone()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| if a.anonymous { "Anonymous".to_string() } else { a.login.clone() }),
|
||||
state: state.to_string(),
|
||||
autojoin: String::new(),
|
||||
has_nickserv_password: false,
|
||||
sasl_enabled: false,
|
||||
sasl_username: String::new(),
|
||||
allow_plaintext_sasl: false,
|
||||
reconnect_for_sasl: false,
|
||||
sasl_mechanism: String::new(),
|
||||
has_sasl_certificate: false,
|
||||
current_nick: String::new(),
|
||||
highlight_keywords: Vec::new(),
|
||||
ssl: true,
|
||||
has_password: a.is_signed_in(),
|
||||
avatar_url: None,
|
||||
sneedchat_rooms: Vec::new(),
|
||||
tor_mode: None,
|
||||
tor_proxy: None,
|
||||
use_tor: a.use_tor,
|
||||
route_level: route_level(a.use_tor, a.strict_route),
|
||||
has_key_backup: false,
|
||||
rtc_focus_url: None,
|
||||
sliding_sync: false,
|
||||
dehydration: false,
|
||||
web_session: !a.web_token.is_empty(),
|
||||
moderation: crate::backend::twitch::auth::has_moderation(&a.scopes),
|
||||
}
|
||||
}
|
||||
|
||||
/// Telegram has none of IRC's concepts either. `has_password` says there is a sign-in behind the account, which is
|
||||
/// what "signed in" means for one that never keeps a password.
|
||||
pub fn telegram_account_to_json(a: &TelegramAccountConfig, state: &str) -> Account {
|
||||
Account {
|
||||
id: a.account_id(),
|
||||
service: "telegram".to_string(),
|
||||
status: "online".to_string(),
|
||||
status_text: String::new(),
|
||||
auto_idle: false,
|
||||
display_name: a
|
||||
.display_name
|
||||
.clone()
|
||||
.filter(|s| !s.is_empty())
|
||||
.or_else(|| Some(a.name.clone()).filter(|s| !s.is_empty()))
|
||||
.or_else(|| a.username.clone().filter(|s| !s.is_empty()))
|
||||
.unwrap_or_else(|| a.phone.clone()),
|
||||
state: state.to_string(),
|
||||
autojoin: String::new(),
|
||||
has_nickserv_password: false,
|
||||
sasl_enabled: false,
|
||||
sasl_username: String::new(),
|
||||
allow_plaintext_sasl: false,
|
||||
reconnect_for_sasl: false,
|
||||
sasl_mechanism: String::new(),
|
||||
has_sasl_certificate: false,
|
||||
current_nick: String::new(),
|
||||
highlight_keywords: Vec::new(),
|
||||
ssl: true,
|
||||
has_password: a.is_signed_in(),
|
||||
avatar_url: None,
|
||||
sneedchat_rooms: Vec::new(),
|
||||
tor_mode: None,
|
||||
tor_proxy: None,
|
||||
use_tor: a.use_tor,
|
||||
route_level: route_level(a.use_tor, a.strict_route),
|
||||
has_key_backup: false,
|
||||
rtc_focus_url: None,
|
||||
sliding_sync: false,
|
||||
dehydration: false,
|
||||
web_session: false,
|
||||
moderation: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1249,6 +1799,7 @@ pub fn matrix_account_to_json(a: &MatrixAccountConfig, state: &str, has_key_back
|
||||
service: "matrix".to_string(),
|
||||
status: "online".to_string(),
|
||||
status_text: String::new(),
|
||||
auto_idle: false,
|
||||
display_name: a.display_name.clone().filter(|s| !s.is_empty()).unwrap_or_else(|| a.user_id.clone()),
|
||||
state: state.to_string(),
|
||||
autojoin: String::new(),
|
||||
@@ -1275,6 +1826,8 @@ pub fn matrix_account_to_json(a: &MatrixAccountConfig, state: &str, has_key_back
|
||||
rtc_focus_url: a.rtc_focus_url.clone(),
|
||||
sliding_sync: a.prefer_sliding_sync,
|
||||
dehydration: a.dehydration_enabled,
|
||||
web_session: false,
|
||||
moderation: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1330,3 +1883,217 @@ mod display_name_tests {
|
||||
assert!(!store.set_display_name("kick:nobody", "You").expect("set"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod steam_account_tests {
|
||||
use super::*;
|
||||
|
||||
fn store(name: &str) -> (AccountStore, PathBuf) {
|
||||
let path = std::env::temp_dir().join(format!("nobilis-steam-accounts-{name}-{}.toml", std::process::id()));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
(AccountStore::open(path.clone()).expect("accounts"), path)
|
||||
}
|
||||
|
||||
fn account(name: &str, token: &str) -> SteamAccountConfig {
|
||||
SteamAccountConfig { username: name.into(), steamid: 76561197960287930, refresh_token: token.into(), session: "chat".into(), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_steam_account_survives_a_restart_with_its_token_and_not_a_password() {
|
||||
let (s, path) = store("restart");
|
||||
s.add_steam(account("Someone", "eyJ.token")).unwrap();
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(text.contains("[[steam_account]]"), "{text}");
|
||||
assert!(text.contains("eyJ.token"));
|
||||
assert!(!text.to_lowercase().contains("password"), "no password is ever written: {text}");
|
||||
|
||||
let reopened = AccountStore::open(path.clone()).unwrap();
|
||||
let got = reopened.get_steam("steam:someone").expect("found under its lower-case id");
|
||||
assert_eq!((got.username.as_str(), got.steamid, got.refresh_token.as_str()), ("someone", 76561197960287930, "eyJ.token"));
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_name_is_how_steam_reads_it_so_two_spellings_are_one_account() {
|
||||
let (s, path) = store("case");
|
||||
s.add_steam(account("Someone", "a")).unwrap();
|
||||
s.add_steam(account(" SOMEONE ", "b")).unwrap();
|
||||
assert_eq!(s.all_steam().len(), 1);
|
||||
assert_eq!(s.get_steam("steam:someone").unwrap().refresh_token, "b");
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signing_in_again_keeps_the_route_and_the_local_name() {
|
||||
let (s, path) = store("relogin");
|
||||
s.add_steam(account("someone", "old")).unwrap();
|
||||
s.set_route_level("steam:someone", "strict").unwrap();
|
||||
s.set_display_name("steam:someone", "Me on Steam").unwrap();
|
||||
s.add_steam(account("someone", "new")).unwrap();
|
||||
let got = s.get_steam("steam:someone").unwrap();
|
||||
assert_eq!(got.refresh_token, "new");
|
||||
assert!(got.use_tor && got.strict_route, "a re-login must not send a routed account out directly");
|
||||
assert_eq!(s.route_level_of("steam:someone").as_deref(), Some("strict"));
|
||||
assert_eq!(s.display_name_override("steam:someone").as_deref(), Some("Me on Steam"));
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_steam_account_takes_it_and_only_it() {
|
||||
let (s, path) = store("remove");
|
||||
s.add_steam(account("one", "a")).unwrap();
|
||||
s.add_steam(account("two", "b")).unwrap();
|
||||
assert!(s.remove("steam:one").unwrap());
|
||||
assert!(!s.remove("steam:one").unwrap());
|
||||
assert!(s.get_steam("steam:one").is_none());
|
||||
assert!(s.get_steam("steam:two").is_some());
|
||||
assert!(AccountStore::open(path.clone()).unwrap().get_steam("steam:one").is_none(), "the file forgot too");
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_account_is_listed_with_a_name_and_whether_it_is_signed_in() {
|
||||
let mut a = account("someone", "tok");
|
||||
let listed = steam_account_to_json(&a, "connected", None);
|
||||
assert_eq!((listed.service.as_str(), listed.id.as_str(), listed.display_name.as_str()), ("steam", "steam:someone", "someone"));
|
||||
assert!(listed.has_password, "a token is what being signed in means");
|
||||
// Steam's name for the account stands in until the person picks one.
|
||||
assert_eq!(steam_account_to_json(&a, "connected", Some("Some One")).display_name, "Some One");
|
||||
a.display_name = Some("Mine".into());
|
||||
assert_eq!(steam_account_to_json(&a, "connected", Some("Some One")).display_name, "Mine");
|
||||
a.refresh_token.clear();
|
||||
assert!(!steam_account_to_json(&a, "disconnected", None).has_password);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_steam_account_does_not_disturb_the_others_in_the_file() {
|
||||
let (s, path) = store("others");
|
||||
s.add_kick(KickAccountConfig { username: "streamer".into(), ..Default::default() }).unwrap();
|
||||
s.add_steam(account("someone", "tok")).unwrap();
|
||||
s.add_kick(KickAccountConfig { username: "streamer".into(), channels: vec!["x".into()], ..Default::default() }).unwrap();
|
||||
let reopened = AccountStore::open(path.clone()).unwrap();
|
||||
assert_eq!(reopened.get_kick("kick:streamer").unwrap().channels, vec!["x".to_string()]);
|
||||
assert!(reopened.get_steam("steam:someone").is_some(), "writing another service's account kept this one");
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod twitch_account_tests {
|
||||
use super::*;
|
||||
|
||||
fn store(name: &str) -> AccountStore {
|
||||
let path = std::env::temp_dir().join(format!("nobilis-twitch-accounts-{name}-{}.toml", std::process::id()));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
AccountStore::open(path).unwrap()
|
||||
}
|
||||
|
||||
fn account(login: &str, access: &str) -> TwitchAccountConfig {
|
||||
TwitchAccountConfig { login: login.into(), user_id: "42".into(), access_token: access.into(), refresh_token: "ref".into(), expires_at: 1000, scopes: vec!["chat:read".into()], ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_twitch_account_survives_a_restart_with_its_tokens() {
|
||||
let s = store("restart");
|
||||
s.add_twitch(account("Someone", "acc")).unwrap();
|
||||
let text = std::fs::read_to_string(s.path_for_tests()).unwrap();
|
||||
assert!(text.contains("[[twitch_account]]"), "{text}");
|
||||
let reopened = AccountStore::open(s.path_for_tests().to_path_buf()).unwrap();
|
||||
let got = reopened.get_twitch("twitch:someone").expect("found under its lower-case id");
|
||||
assert_eq!((got.login.as_str(), got.user_id.as_str(), got.access_token.as_str(), got.scopes.len()), ("someone", "42", "acc", 1));
|
||||
assert!(got.is_signed_in());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_anonymous_reader_has_an_id_no_login_can_have() {
|
||||
let s = store("anon");
|
||||
let saved = s.add_twitch(TwitchAccountConfig { anonymous: true, ..Default::default() }).unwrap();
|
||||
assert_eq!(saved.account_id(), TWITCH_ANONYMOUS_ID);
|
||||
assert!(!saved.is_signed_in());
|
||||
assert!(!TWITCH_ANONYMOUS_ID.trim_start_matches("twitch:").chars().all(|c| c.is_ascii_alphanumeric() || c == '_'));
|
||||
let json = twitch_account_to_json(&saved, "connected");
|
||||
assert_eq!((json.service.as_str(), json.display_name.as_str(), json.has_password), ("twitch", "Anonymous", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signing_in_again_keeps_the_route_the_name_and_the_open_channels() {
|
||||
let s = store("again");
|
||||
s.add_twitch(account("someone", "old")).unwrap();
|
||||
s.mutate_twitch("twitch:someone", |a| {
|
||||
a.channels = vec!["xqc".into()];
|
||||
a.display_name = Some("Mine".into());
|
||||
})
|
||||
.unwrap();
|
||||
s.set_route_level("twitch:someone", "strict").unwrap();
|
||||
let again = s.add_twitch(account("SOMEONE", "new")).unwrap();
|
||||
assert_eq!(s.all_twitch().len(), 1, "the same login is the same account");
|
||||
assert_eq!((again.access_token.as_str(), again.channels.clone(), again.display_name.clone()), ("new", vec!["xqc".to_string()], Some("Mine".to_string())));
|
||||
assert!(again.strict_route && again.use_tor, "a sign-in does not take a route away");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_twitch_account_removes_it_from_the_file() {
|
||||
let s = store("remove");
|
||||
s.add_twitch(account("someone", "acc")).unwrap();
|
||||
assert!(s.remove("twitch:someone").unwrap());
|
||||
assert!(s.get_twitch("twitch:someone").is_none());
|
||||
let reopened = AccountStore::open(s.path_for_tests().to_path_buf()).unwrap();
|
||||
assert!(reopened.all_twitch().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod telegram_account_tests {
|
||||
use super::*;
|
||||
|
||||
fn store(name: &str) -> (AccountStore, PathBuf) {
|
||||
let path = std::env::temp_dir().join(format!("nobilis-telegram-accounts-{name}-{}.toml", std::process::id()));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
(AccountStore::open(path.clone()).unwrap(), path)
|
||||
}
|
||||
|
||||
fn account(user_id: i64) -> TelegramAccountConfig {
|
||||
TelegramAccountConfig { api_id: 12345, api_hash: "abcdef".into(), phone: "+15551234567".into(), user_id, name: "Some One".into(), username: Some("someone".into()), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_telegram_account_survives_a_restart_without_its_session_in_the_file() {
|
||||
let (s, path) = store("restart");
|
||||
s.add_telegram(account(777)).unwrap();
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(text.contains("[[telegram_account]]"), "{text}");
|
||||
assert!(!text.contains("session"), "the session is a file of its own, not a field here: {text}");
|
||||
let reopened = AccountStore::open(path.clone()).unwrap();
|
||||
let got = reopened.get_telegram("telegram:777").expect("found under the id built from Telegram's number");
|
||||
assert_eq!((got.api_id, got.api_hash.as_str(), got.phone.as_str(), got.username.as_deref()), (12345, "abcdef", "+15551234567", Some("someone")));
|
||||
assert!(got.is_signed_in());
|
||||
std::fs::remove_file(path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signing_in_again_keeps_what_the_person_set_up_and_removing_takes_only_that_account() {
|
||||
let (s, path) = store("again");
|
||||
s.add_telegram(account(1)).unwrap();
|
||||
s.add_telegram(account(2)).unwrap();
|
||||
s.set_route_level("telegram:1", "strict").unwrap();
|
||||
s.set_display_name("telegram:1", "Me on Telegram").unwrap();
|
||||
s.add_telegram(TelegramAccountConfig { name: "Renamed".into(), ..account(1) }).unwrap();
|
||||
let got = s.get_telegram("telegram:1").unwrap();
|
||||
assert_eq!((got.name.as_str(), got.display_name.as_deref(), got.strict_route), ("Renamed", Some("Me on Telegram"), true));
|
||||
assert_eq!(s.route_level_of("telegram:1").as_deref(), Some("strict"));
|
||||
assert_eq!(s.all_telegram().len(), 2);
|
||||
assert!(s.remove("telegram:1").unwrap());
|
||||
assert!(!s.remove("telegram:1").unwrap());
|
||||
assert!(s.get_telegram("telegram:2").is_some());
|
||||
assert!(AccountStore::open(path.clone()).unwrap().get_telegram("telegram:1").is_none(), "the file forgot too");
|
||||
std::fs::remove_file(path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_listed_account_is_named_for_the_person_and_says_it_is_signed_in() {
|
||||
let listed = telegram_account_to_json(&account(5), "connected");
|
||||
assert_eq!((listed.service.as_str(), listed.id.as_str(), listed.display_name.as_str(), listed.has_password), ("telegram", "telegram:5", "Some One", true));
|
||||
let unnamed = telegram_account_to_json(&TelegramAccountConfig { name: String::new(), username: None, ..account(5) }, "connected");
|
||||
assert_eq!(unnamed.display_name, "+15551234567");
|
||||
}
|
||||
}
|
||||
|
||||
+5
-3
@@ -1177,16 +1177,18 @@ mod tests {
|
||||
assert!(!devices[1].is_default);
|
||||
}
|
||||
|
||||
/// Ignored by default: with no sound server there is nothing to resolve,
|
||||
/// and a test that returned early there passed having tested nothing.
|
||||
/// cargo test -- --ignored the_system_default
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn the_system_default_is_a_destination_rather_than_an_absence() {
|
||||
// Choosing "system default" has to actively move the stream. The sound
|
||||
// server puts an application back on the device it was last moved to,
|
||||
// so treating the default as "do not route" silently means "keep
|
||||
// whatever was chosen before" - which is how audio ends up playing
|
||||
// into a device nobody is listening to.
|
||||
let Ok(output) = default_device(Stream::Output) else {
|
||||
return; // no sound server here; nothing to assert against
|
||||
};
|
||||
let output = default_device(Stream::Output).expect("a sound server to ask");
|
||||
assert!(!output.is_empty(), "the default sink resolved to nothing");
|
||||
let input = default_device(Stream::Input).expect("a server with a sink should name a source");
|
||||
assert!(!input.is_empty(), "the default source resolved to nothing");
|
||||
|
||||
@@ -324,7 +324,7 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
|
||||
"large_threshold": 50,
|
||||
// Carried in IDENTIFY as well as pushed live, so a status set
|
||||
// before a reconnect survives it.
|
||||
"presence": presence_payload(&state.runtime.account_status(&account_id), &state.runtime.account_status_text(&account_id)),
|
||||
"presence": presence_payload(&state.runtime.effective_status(state, &account_id), &state.runtime.account_status_text(&account_id)),
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
@@ -482,6 +482,16 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
|
||||
}
|
||||
}
|
||||
}
|
||||
// What they are doing, now that their conversations exist
|
||||
// to say it in: READY's presences carry it with the
|
||||
// status, and the rosters above were made with the
|
||||
// status alone.
|
||||
for p in d["presences"].as_array().into_iter().flatten() {
|
||||
let Some(user_id) = p["user"]["id"].as_str() else { continue };
|
||||
if let Some(activity) = super::presence::activity_of(&p["activities"]) {
|
||||
update_presence_in_rosters(state, user_id, p["status"].as_str().unwrap_or("offline"), Some(activity));
|
||||
}
|
||||
}
|
||||
// Conversations are the ones people notice a gap in,
|
||||
// and there are few enough of them to ask about
|
||||
// directly. Channels wait until opened - a few hundred
|
||||
@@ -881,7 +891,9 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
|
||||
// The author's id travels with the message. It is what a profile
|
||||
// lookup asks Discord about - a display name is not something
|
||||
// the API accepts - and what a moderation action would need.
|
||||
state.runtime.record_message(state, &account_id, &buffer_name, &kind, &from, &body, false, "chat", reply_to, real_msg_id.clone(), is_mention, avatar_url, embeds, attachments, author["id"].as_str().map(str::to_string));
|
||||
let badges = author_badges(author);
|
||||
let style = if badges.is_empty() { None } else { Some(model::SenderStyle { color: None, badges }) };
|
||||
state.runtime.record_message_at(state, &account_id, &buffer_name, &kind, &from, &body, false, "chat", reply_to, real_msg_id.clone(), is_mention, avatar_url, embeds, attachments, author["id"].as_str().map(str::to_string), None, None, style);
|
||||
// Who wrote a forwarded message, which the snapshot
|
||||
// does not say. A read of the original, so it happens
|
||||
// after the message is already on screen rather than
|
||||
@@ -1320,7 +1332,7 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
|
||||
// Also refresh any roster this person appears in, so
|
||||
// an open channel's list follows them going online or
|
||||
// away without waiting for a fresh subscription.
|
||||
update_presence_in_rosters(state, user_id, status);
|
||||
update_presence_in_rosters(state, user_id, status, super::presence::activity_of(&d["activities"]));
|
||||
}
|
||||
// Everything this client does not act on.
|
||||
//
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! The GIFs Discord offers, for browsing.
|
||||
//!
|
||||
//! Discord's own client gets its GIFs from the same two requests - what is
|
||||
//! trending, and a search - and shows what comes back; this asks for the same
|
||||
//! and does no more than the official client does: reads, and only when the
|
||||
//! picker is open and somebody has stopped typing.
|
||||
//!
|
||||
//! Any Discord account will do, whichever conversation the picker is open in,
|
||||
//! since what it borrows is the account's way of asking and not the account's
|
||||
//! conversations: a GIF found this way can be sent anywhere.
|
||||
//!
|
||||
//! Answers are kept for a few minutes. Opening the picker twice, or typing a
|
||||
//! word and deleting it, is not a reason to ask again.
|
||||
|
||||
use super::*;
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Instant;
|
||||
|
||||
static CACHE: LazyLock<std::sync::Mutex<HashMap<String, (Instant, Value)>>> = LazyLock::new(|| std::sync::Mutex::new(HashMap::new()));
|
||||
const TRENDING_FOR: Duration = Duration::from_secs(10 * 60);
|
||||
const SEARCH_FOR: Duration = Duration::from_secs(5 * 60);
|
||||
const CACHE_LIMIT: usize = 64;
|
||||
|
||||
/// One GIF as the picker wants it: where it is, what it is called, and what to
|
||||
/// send. Discord's own words for these are `src` for the video, `gif_src` for
|
||||
/// the file, `url` for the page - kept, since each is used for something.
|
||||
fn one(item: &Value) -> Option<Value> {
|
||||
let url = item["url"].as_str().filter(|u| !u.is_empty())?;
|
||||
let src = item["src"].as_str().filter(|u| !u.is_empty());
|
||||
let gif = item["gif_src"].as_str().filter(|u| !u.is_empty());
|
||||
// Something to play, or there is nothing to show.
|
||||
let play = src.or(gif)?;
|
||||
Some(json!({
|
||||
"id": item["id"].as_str().map(str::to_string).unwrap_or_else(|| url.to_string()),
|
||||
"title": item["title"].as_str().unwrap_or(""),
|
||||
"url": url,
|
||||
"src": play,
|
||||
"gifSrc": gif.unwrap_or(play),
|
||||
"preview": item["preview"].as_str().unwrap_or(""),
|
||||
"width": item["width"].as_u64().unwrap_or(0),
|
||||
"height": item["height"].as_u64().unwrap_or(0),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Discord's two shapes of answer: a search is a list of GIFs, and trending is
|
||||
/// an object holding them with the categories beside.
|
||||
pub(super) fn parse_gifs(answer: &Value) -> Value {
|
||||
let list = answer.as_array().or_else(|| answer["gifs"].as_array()).or_else(|| answer["results"].as_array());
|
||||
let gifs: Vec<Value> = list.into_iter().flatten().filter_map(one).collect();
|
||||
let categories: Vec<Value> = answer["categories"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|c| {
|
||||
let name = c["name"].as_str().filter(|n| !n.is_empty())?;
|
||||
Some(json!({ "name": name, "src": c["src"].as_str().unwrap_or("") }))
|
||||
})
|
||||
.collect();
|
||||
json!({ "gifs": gifs, "categories": categories })
|
||||
}
|
||||
|
||||
/// What is trending, or what matches `query`.
|
||||
pub async fn list_gifs(state: &AppState, query: &str) -> Result<Value> {
|
||||
let query = query.trim();
|
||||
let key = query.to_lowercase();
|
||||
let ttl = if key.is_empty() { TRENDING_FOR } else { SEARCH_FOR };
|
||||
if let Some((at, answer)) = CACHE.lock().unwrap().get(&key) {
|
||||
if at.elapsed() < ttl {
|
||||
return Ok(answer.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let config = state
|
||||
.accounts
|
||||
.all_discord()
|
||||
.into_iter()
|
||||
.next()
|
||||
.context("GIFs are found through a Discord account, and none is set up")?;
|
||||
let mut url = url::Url::parse(&format!("{API_BASE}/gifs/{}", if query.is_empty() { "trending" } else { "search" })).context("building the GIF request")?;
|
||||
{
|
||||
let mut pairs = url.query_pairs_mut();
|
||||
if !query.is_empty() {
|
||||
pairs.append_pair("q", &query.chars().take(100).collect::<String>());
|
||||
}
|
||||
pairs.append_pair("media_format", "mp4").append_pair("locale", "en-US");
|
||||
}
|
||||
let resp = http_client_for(&config.token)
|
||||
.get(url)
|
||||
.header("Authorization", &config.token)
|
||||
.send()
|
||||
.await
|
||||
.context("asking Discord for GIFs")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
bail!("{}", discord_error_text(status, &text, "asking Discord for GIFs"));
|
||||
}
|
||||
let answer: Value = resp.json().await.context("reading Discord's GIFs")?;
|
||||
let parsed = parse_gifs(&answer);
|
||||
|
||||
let mut cache = CACHE.lock().unwrap();
|
||||
if cache.len() >= CACHE_LIMIT {
|
||||
cache.clear();
|
||||
}
|
||||
cache.insert(key, (Instant::now(), parsed.clone()));
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn gif(id: &str) -> Value {
|
||||
json!({
|
||||
"id": id, "title": "cat", "url": format!("https://tenor.com/view/cat-gif-{id}"),
|
||||
"src": format!("https://media.tenor.com/{id}.mp4"), "gif_src": format!("https://media.tenor.com/{id}.gif"),
|
||||
"preview": format!("https://media.tenor.com/{id}.png"), "width": 480, "height": 270
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_search_is_a_list_of_gifs() {
|
||||
let out = parse_gifs(&json!([gif("1"), gif("2")]));
|
||||
assert_eq!(out["gifs"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(out["gifs"][0]["url"], "https://tenor.com/view/cat-gif-1");
|
||||
assert_eq!(out["gifs"][0]["src"], "https://media.tenor.com/1.mp4");
|
||||
assert_eq!(out["gifs"][0]["gifSrc"], "https://media.tenor.com/1.gif");
|
||||
assert_eq!((out["gifs"][0]["width"].as_u64(), out["gifs"][0]["height"].as_u64()), (Some(480), Some(270)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trending_holds_its_gifs_beside_its_categories() {
|
||||
let out = parse_gifs(&json!({
|
||||
"categories": [{ "name": "reaction", "src": "https://media.tenor.com/c.mp4" }, { "name": "" }],
|
||||
"gifs": [gif("1")]
|
||||
}));
|
||||
assert_eq!(out["gifs"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(out["categories"].as_array().unwrap().len(), 1, "a category with no name is not one");
|
||||
assert_eq!(out["categories"][0]["name"], "reaction");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_with_nothing_to_play_or_no_page_is_left_out() {
|
||||
let out = parse_gifs(&json!([
|
||||
{ "url": "https://tenor.com/view/x" },
|
||||
{ "src": "https://media.tenor.com/y.mp4" },
|
||||
gif("3")
|
||||
]));
|
||||
assert_eq!(out["gifs"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_file_the_video_is_what_there_is() {
|
||||
let out = parse_gifs(&json!([{ "url": "https://tenor.com/view/x", "src": "https://media.tenor.com/x.mp4" }]));
|
||||
assert_eq!(out["gifs"][0]["gifSrc"], "https://media.tenor.com/x.mp4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_recognisable_is_nothing() {
|
||||
let out = parse_gifs(&json!({ "message": "nope" }));
|
||||
assert!(out["gifs"].as_array().unwrap().is_empty());
|
||||
assert!(out["categories"].as_array().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,27 @@ pub async fn member_verification(state: &AppState, account_id: &str, guild_id: &
|
||||
serde_json::from_str(&text).context("parsing this server's rules")
|
||||
}
|
||||
|
||||
/// The form's fields with the answers filled in: the rules are agreed to, and every other field takes what the person
|
||||
/// gave for it (their text, or the number of the choice), by its place in the form. A field nobody answered is sent
|
||||
/// without a response, which Discord accepts only where the server did not require one.
|
||||
pub fn responses_for(form: &Value, answers: &[Value]) -> Vec<Value> {
|
||||
form["form_fields"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
.map(|(i, f)| {
|
||||
let mut field = f.clone();
|
||||
if f["field_type"] == "TERMS" {
|
||||
field["response"] = Value::Bool(true);
|
||||
} else if let Some(answer) = answers.get(i).filter(|a| !a.is_null()) {
|
||||
field["response"] = answer.clone();
|
||||
}
|
||||
field
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Agrees to them, which is what lifts the gate.
|
||||
///
|
||||
/// The form is fetched again rather than taken from the caller: what is
|
||||
@@ -114,20 +135,11 @@ pub async fn member_verification(state: &AppState, account_id: &str, guild_id: &
|
||||
/// captured when the dialog opened may not still be it. Every field is
|
||||
/// returned as sent with its response set - Discord rejects a form that has
|
||||
/// been reshaped, and it is the server's form, not ours to edit.
|
||||
pub async fn accept_member_verification(state: &AppState, account_id: &str, guild_id: &str) -> Result<()> {
|
||||
pub async fn accept_member_verification(state: &AppState, account_id: &str, guild_id: &str, answers: &[Value]) -> Result<()> {
|
||||
let cfg = state.accounts.get_discord(account_id).context("account not connected")?;
|
||||
let form = member_verification(state, account_id, guild_id).await?;
|
||||
let version = form["version"].clone();
|
||||
let fields: Vec<Value> = form["form_fields"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|f| {
|
||||
let mut field = f.clone();
|
||||
field["response"] = Value::Bool(true);
|
||||
field
|
||||
})
|
||||
.collect();
|
||||
let fields = responses_for(&form, answers);
|
||||
|
||||
let resp = send_write(
|
||||
http_client_for(&cfg.token)
|
||||
@@ -1217,3 +1229,29 @@ mod system_dm_tests {
|
||||
assert!(!is_system_dm(&json!({ "recipients": [{ "id": "1", "system": true }, { "id": "2" }] })));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod verification_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_rules_are_agreed_to_and_other_fields_take_the_persons_answers() {
|
||||
let form = json!({
|
||||
"version": "2026-01-01",
|
||||
"form_fields": [
|
||||
{ "field_type": "TERMS", "label": "Read and agree to the server rules", "values": ["Be kind"], "required": true },
|
||||
{ "field_type": "TEXT_INPUT", "label": "Why are you joining?", "required": true },
|
||||
{ "field_type": "MULTIPLE_CHOICE", "label": "Pick one", "values": ["a", "b"], "required": false },
|
||||
]
|
||||
});
|
||||
let sent = responses_for(&form, &[Value::Null, json!("to talk"), json!(1)]);
|
||||
assert_eq!(sent[0]["response"], true);
|
||||
assert_eq!(sent[1]["response"], "to talk");
|
||||
assert_eq!(sent[2]["response"], 1);
|
||||
assert_eq!(sent[0]["values"], json!(["Be kind"]), "the server's own form is not reshaped");
|
||||
// Nothing answered: no response key at all, not a made-up one.
|
||||
let bare = responses_for(&form, &[]);
|
||||
assert!(bare[1].get("response").is_none() && bare[2].get("response").is_none());
|
||||
assert_eq!(bare[0]["response"], true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,12 +112,13 @@ pub(super) async fn send_write(request: reqwest::RequestBuilder) -> Result<reqwe
|
||||
/// number is not a reason to hammer, and one asking for half an hour is not a
|
||||
/// wait anybody would sit through inside a request.
|
||||
pub(super) fn retry_after(resp: &reqwest::Response) -> Duration {
|
||||
let seconds = resp
|
||||
.headers()
|
||||
.get("retry-after")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse::<f64>().ok())
|
||||
.unwrap_or(1.0);
|
||||
pause_for(resp.headers().get("retry-after").and_then(|v| v.to_str().ok()))
|
||||
}
|
||||
|
||||
/// The wait a `Retry-After` value asks for; none given, or none that reads as
|
||||
/// a number, is a second.
|
||||
fn pause_for(seconds: Option<&str>) -> Duration {
|
||||
let seconds = seconds.and_then(|v| v.parse::<f64>().ok()).unwrap_or(1.0);
|
||||
Duration::from_millis(((seconds.max(0.0) * 1000.0) as u64).clamp(200, 30_000))
|
||||
}
|
||||
|
||||
@@ -217,19 +218,9 @@ pub(super) fn form_errors(resp: &Value) -> Option<String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod pacing_tests {
|
||||
use super::pause_for;
|
||||
use std::time::Duration;
|
||||
|
||||
/// The wait Discord asked for, read the way it sends it: seconds, with a
|
||||
/// decimal point, in a header.
|
||||
///
|
||||
/// Clamped at both ends on purpose - a limit with no number is not a
|
||||
/// reason to hammer, and a half-hour one is not a wait to sit through
|
||||
/// inside a request - and this is the arithmetic that decides both.
|
||||
fn pause_for(seconds: Option<&str>) -> Duration {
|
||||
let seconds = seconds.and_then(|v| v.parse::<f64>().ok()).unwrap_or(1.0);
|
||||
Duration::from_millis(((seconds.max(0.0) * 1000.0) as u64).clamp(200, 30_000))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wait_is_taken_as_asked() {
|
||||
assert_eq!(pause_for(Some("1.5")), Duration::from_millis(1500));
|
||||
|
||||
@@ -629,6 +629,7 @@ mod tests {
|
||||
buffer_id: "b".into(),
|
||||
html: None,
|
||||
components: Vec::new(),
|
||||
moderated: None,
|
||||
sender_color: None,
|
||||
badges: Vec::new(),
|
||||
from: "x".into(),
|
||||
|
||||
+412
-59
@@ -187,6 +187,12 @@ pub(super) fn extract_body(d: &Value) -> Option<String> {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// A GIF is drawn from its embed. Passing its media address on as
|
||||
// well would be the same picture twice, and was how a GIF whose
|
||||
// link was not in the text showed as a bare mp4 address.
|
||||
if embed["type"].as_str() == Some("gifv") {
|
||||
continue;
|
||||
}
|
||||
if let Some(url) = embed["video"]["url"].as_str() {
|
||||
parts.push(url.to_string());
|
||||
} else if let Some(url) = embed["image"]["url"].as_str() {
|
||||
@@ -697,6 +703,12 @@ pub(super) fn extract_attachments(d: &Value) -> Vec<Attachment> {
|
||||
/// represented by extract_body's own media-URL handling above, so
|
||||
/// including it here too would just render an empty box under the media.
|
||||
pub(super) fn extract_embeds(d: &Value) -> Vec<Embed> {
|
||||
let mut out = ordinary_embeds(d);
|
||||
out.extend(layout_cards(d));
|
||||
out
|
||||
}
|
||||
|
||||
fn ordinary_embeds(d: &Value) -> Vec<Embed> {
|
||||
let Some(embeds) = d["embeds"].as_array() else { return Vec::new() };
|
||||
embeds
|
||||
.iter()
|
||||
@@ -704,6 +716,9 @@ pub(super) fn extract_embeds(d: &Value) -> Vec<Embed> {
|
||||
if embed["type"].as_str() == Some("safety_system_notification") {
|
||||
return Some(safety_notice(embed));
|
||||
}
|
||||
if embed["type"].as_str() == Some("gifv") {
|
||||
return gif_embed(embed);
|
||||
}
|
||||
let title = embed["title"].as_str().filter(|s| !s.is_empty()).map(|s| s.to_string());
|
||||
let description = embed["description"].as_str().filter(|s| !s.is_empty()).map(|s| s.to_string());
|
||||
let fields: Vec<model::EmbedField> = embed["fields"]
|
||||
@@ -742,6 +757,41 @@ pub(super) fn extract_embeds(d: &Value) -> Vec<Embed> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A GIF from Tenor, Klipy, Giphy or whoever Discord unfurled it from.
|
||||
///
|
||||
/// Discord answers a link to one with an embed of type `gifv` whose `video` is
|
||||
/// the moving picture itself - an mp4 - and whose `url` is the page that was
|
||||
/// linked, which has no file extension to recognise it by. That is why a
|
||||
/// posted GIF used to show as nothing but its address.
|
||||
///
|
||||
/// The proxied copy is preferred where there is one: it is served from
|
||||
/// Discord's own CDN, so reading a message does not tell a third party's server
|
||||
/// who read it, which is the same arrangement Discord's own client makes.
|
||||
fn gif_embed(embed: &Value) -> Option<Embed> {
|
||||
let video = &embed["video"];
|
||||
let from_video = video["proxy_url"].as_str().or_else(|| video["url"].as_str()).filter(|u| !u.is_empty());
|
||||
let media = from_video
|
||||
.or_else(|| embed["thumbnail"]["proxy_url"].as_str().filter(|u| !u.is_empty()))
|
||||
.or_else(|| embed["thumbnail"]["url"].as_str().filter(|u| !u.is_empty()))?;
|
||||
let size = |key: &str| {
|
||||
video[key]
|
||||
.as_u64()
|
||||
.or_else(|| embed["thumbnail"][key].as_u64())
|
||||
.and_then(|n| u32::try_from(n).ok())
|
||||
.filter(|&n| n > 0)
|
||||
};
|
||||
Some(Embed {
|
||||
kind: Some("gif".to_string()),
|
||||
url: embed["url"].as_str().map(str::to_string),
|
||||
media_url: Some(media.to_string()),
|
||||
media_kind: Some(if from_video.is_some() { "video" } else { "image" }.to_string()),
|
||||
width: size("width"),
|
||||
height: size("height"),
|
||||
provider: embed["provider"]["name"].as_str().filter(|s| !s.is_empty()).map(str::to_string),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// One of the platform's own notices to an account - a warning, a removed
|
||||
/// violation, a limit on the account - which arrive as an embed of this type
|
||||
/// whose content is a list of labelled values: `header`, `body`, `icon_type`,
|
||||
@@ -865,6 +915,8 @@ pub(super) fn extract_reply(d: &Value) -> Option<ReplyPreview> {
|
||||
body: String::new(),
|
||||
thread: false,
|
||||
forwarded: true,
|
||||
thread_root: None,
|
||||
chat: None,
|
||||
});
|
||||
}
|
||||
let referenced = &d["referenced_message"];
|
||||
@@ -876,7 +928,7 @@ pub(super) fn extract_reply(d: &Value) -> Option<ReplyPreview> {
|
||||
let author = &referenced["author"];
|
||||
let from = author["global_name"].as_str().filter(|s| !s.is_empty()).or_else(|| author["username"].as_str()).unwrap_or("unknown").to_string();
|
||||
let body = extract_body(referenced).unwrap_or_default();
|
||||
Some(ReplyPreview { id: reply_id.to_string(), from, body, thread: false, forwarded: false })
|
||||
Some(ReplyPreview { id: reply_id.to_string(), from, body, thread: false, forwarded: false, thread_root: None, chat: None })
|
||||
}
|
||||
|
||||
/// Shared by both the initial backfill and extend_history - Discord
|
||||
@@ -912,9 +964,17 @@ pub(super) fn store_history_messages(state: &AppState, buffer_id: &str, messages
|
||||
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
||||
.map(|dt| dt.timestamp())
|
||||
.unwrap_or(0);
|
||||
if let Err(e) = state.store.append_message(buffer_id, msg_id, from, &body, ts, false, false, "chat", reply_to.as_ref(), &reactions, is_own, avatar_url.as_deref(), &embeds, &attachments, author["id"].as_str(), None, &state.runtime.buffer_kind_of(buffer_id), None, &[]) {
|
||||
tracing::warn!("discord: storing history message: {e}");
|
||||
continue;
|
||||
let badges = author_badges(author);
|
||||
match state.store.append_message(buffer_id, msg_id, from, &body, ts, false, false, "chat", reply_to.as_ref(), &reactions, is_own, avatar_url.as_deref(), &embeds, &attachments, author["id"].as_str(), None, &state.runtime.buffer_kind_of(buffer_id), None, &badges) {
|
||||
Err(e) => {
|
||||
tracing::warn!("discord: storing history message: {e}");
|
||||
continue;
|
||||
}
|
||||
// Read again, a message kept before bots were marked gets its mark.
|
||||
Ok(false) if !badges.is_empty() => {
|
||||
let _ = state.store.set_badges(buffer_id, msg_id, &badges);
|
||||
}
|
||||
Ok(_) => {}
|
||||
}
|
||||
note_components(state, buffer_id, msg_id, msg);
|
||||
// A poll in the history may still be running - Discord's last hours
|
||||
@@ -967,6 +1027,26 @@ pub(super) fn extract_reactions(msg: &Value) -> Vec<Reaction> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// What Discord marks an author as, beside their name: "APP" for a bot or an application, with a check when Discord has
|
||||
/// verified it. Read off the author the message carries, so it is there for history and for live messages alike.
|
||||
pub(super) fn author_badges(author: &Value) -> Vec<crate::backend::kick::api::Badge> {
|
||||
// Bit 16 of the public flags: a verified bot.
|
||||
const VERIFIED_BOT: u64 = 1 << 16;
|
||||
// Discord's own account: what it sends is the platform speaking, and says so with the OFFICIAL mark.
|
||||
if author["system"].as_bool() == Some(true) {
|
||||
return vec![crate::backend::kick::api::Badge { kind: "official".to_string(), text: "Official Discord Message".to_string(), ..Default::default() }];
|
||||
}
|
||||
if author["bot"].as_bool() != Some(true) {
|
||||
return Vec::new();
|
||||
}
|
||||
let verified = author["public_flags"].as_u64().unwrap_or(0) & VERIFIED_BOT != 0;
|
||||
vec![crate::backend::kick::api::Badge {
|
||||
kind: if verified { "verified-app" } else { "app" }.to_string(),
|
||||
text: if verified { "Verified App" } else { "App" }.to_string(),
|
||||
..Default::default()
|
||||
}]
|
||||
}
|
||||
|
||||
/// The buttons and menus on a message, in this client's own shape.
|
||||
///
|
||||
/// Discord lays them out in rows of up to five; the row is kept so a client
|
||||
@@ -976,65 +1056,190 @@ pub(super) fn extract_reactions(msg: &Value) -> Vec<Reaction> {
|
||||
pub(super) fn extract_components(msg: &Value) -> Vec<model::Component> {
|
||||
let Some(rows) = msg["components"].as_array() else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
for (row_index, row) in rows.iter().enumerate() {
|
||||
// A row is itself a component (type 1) holding the real ones. A
|
||||
// message whose top level is already a control is not a shape Discord
|
||||
// sends, but reading it either way costs nothing.
|
||||
let children = row["components"].as_array().cloned().unwrap_or_else(|| vec![row.clone()]);
|
||||
for child in children {
|
||||
let kind = match child["type"].as_i64() {
|
||||
Some(2) => "button",
|
||||
// 3 is the plain string select; 5 through 8 are the ones that
|
||||
// pick users, roles, mentionables and channels. They are all
|
||||
// menus, and all of them answer the same way.
|
||||
Some(3) | Some(5) | Some(6) | Some(7) | Some(8) => "select",
|
||||
_ => continue,
|
||||
};
|
||||
let style = child["style"].as_i64().map(|s| match s {
|
||||
1 => "primary",
|
||||
2 => "secondary",
|
||||
3 => "success",
|
||||
4 => "danger",
|
||||
5 => "link",
|
||||
_ => "secondary",
|
||||
});
|
||||
let options = child["options"]
|
||||
.as_array()
|
||||
.map(|opts| {
|
||||
opts.iter()
|
||||
.filter_map(|o| {
|
||||
Some(model::ComponentOption {
|
||||
value: o["value"].as_str()?.to_string(),
|
||||
label: o["label"].as_str().unwrap_or_default().to_string(),
|
||||
description: o["description"].as_str().map(str::to_string),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
out.push(model::Component {
|
||||
kind: kind.to_string(),
|
||||
custom_id: child["custom_id"].as_str().map(str::to_string),
|
||||
label: child["label"].as_str().filter(|l| !l.is_empty()).map(str::to_string),
|
||||
style: style.map(str::to_string),
|
||||
url: child["url"].as_str().map(str::to_string),
|
||||
disabled: child["disabled"].as_bool().unwrap_or(false),
|
||||
// Written the way this client writes emoji everywhere else, so
|
||||
// a button's picture renders like one in a message does.
|
||||
emoji: match (child["emoji"]["name"].as_str(), child["emoji"]["id"].as_str()) {
|
||||
(Some(name), Some(id)) => Some(format!("<:{name}:{id}>")),
|
||||
(Some(name), None) => Some(name.to_string()),
|
||||
_ => None,
|
||||
},
|
||||
options,
|
||||
placeholder: child["placeholder"].as_str().map(str::to_string),
|
||||
row: row_index as i64,
|
||||
});
|
||||
}
|
||||
let mut row_index = 0i64;
|
||||
for row in rows {
|
||||
controls_in(row, &mut row_index, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The buttons and menus inside one component, however deep the layout puts them.
|
||||
///
|
||||
/// An ordinary message has rows (type 1) of controls. A message made with Discord's newer layout components wraps
|
||||
/// them: a container (17) holds rows, sections (9) - whose accessory may itself be a button - and text, and a control
|
||||
/// may sit anywhere in it. Each row, and each section's accessory, is one line of controls in the order met.
|
||||
fn controls_in(node: &Value, row_index: &mut i64, out: &mut Vec<model::Component>) {
|
||||
match node["type"].as_i64() {
|
||||
// An action row: its children are the controls.
|
||||
Some(1) => {
|
||||
for child in node["components"].as_array().into_iter().flatten() {
|
||||
push_control(child, *row_index, out);
|
||||
}
|
||||
*row_index += 1;
|
||||
}
|
||||
// A container, or anything else that holds components: look inside.
|
||||
Some(17) => {
|
||||
for child in node["components"].as_array().into_iter().flatten() {
|
||||
controls_in(child, row_index, out);
|
||||
}
|
||||
}
|
||||
// A section's accessory is a thumbnail or a button; the text beside it holds no controls.
|
||||
Some(9) => {
|
||||
if node["accessory"]["type"].as_i64() == Some(2) {
|
||||
push_control(&node["accessory"], *row_index, out);
|
||||
*row_index += 1;
|
||||
}
|
||||
}
|
||||
// A control with no row around it is not a shape Discord sends, but reading it costs nothing.
|
||||
_ => push_control(node, *row_index, out),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_control(child: &Value, row_index: i64, out: &mut Vec<model::Component>) {
|
||||
let kind = match child["type"].as_i64() {
|
||||
Some(2) => "button",
|
||||
// 3 is the plain string select; 5 through 8 are the ones that
|
||||
// pick users, roles, mentionables and channels. They are all
|
||||
// menus, and all of them answer the same way.
|
||||
Some(3) | Some(5) | Some(6) | Some(7) | Some(8) => "select",
|
||||
_ => return,
|
||||
};
|
||||
let style = child["style"].as_i64().map(|s| match s {
|
||||
1 => "primary",
|
||||
2 => "secondary",
|
||||
3 => "success",
|
||||
4 => "danger",
|
||||
5 => "link",
|
||||
_ => "secondary",
|
||||
});
|
||||
let options = child["options"]
|
||||
.as_array()
|
||||
.map(|opts| {
|
||||
opts.iter()
|
||||
.filter_map(|o| {
|
||||
Some(model::ComponentOption {
|
||||
value: o["value"].as_str()?.to_string(),
|
||||
label: o["label"].as_str().unwrap_or_default().to_string(),
|
||||
description: o["description"].as_str().map(str::to_string),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
out.push(model::Component {
|
||||
kind: kind.to_string(),
|
||||
custom_id: child["custom_id"].as_str().map(str::to_string),
|
||||
label: child["label"].as_str().filter(|l| !l.is_empty()).map(str::to_string),
|
||||
style: style.map(str::to_string),
|
||||
url: child["url"].as_str().map(str::to_string),
|
||||
disabled: child["disabled"].as_bool().unwrap_or(false),
|
||||
// Written the way this client writes emoji everywhere else, so
|
||||
// a button's picture renders like one in a message does.
|
||||
emoji: match (child["emoji"]["name"].as_str(), child["emoji"]["id"].as_str()) {
|
||||
(Some(name), Some(id)) => Some(format!("<:{name}:{id}>")),
|
||||
(Some(name), None) => Some(name.to_string()),
|
||||
_ => None,
|
||||
},
|
||||
options,
|
||||
placeholder: child["placeholder"].as_str().map(str::to_string),
|
||||
row: row_index,
|
||||
});
|
||||
}
|
||||
|
||||
/// What a message made with Discord's layout components says, as cards.
|
||||
///
|
||||
/// Those messages carry no text of their own: a container (type 17) holds text displays (10), sections (9) pairing text
|
||||
/// with a picture or a button, galleries of pictures (12), files (13) and separators (14). Each container becomes one
|
||||
/// card - its text as the description, a leading heading line as the title, its first picture as the card's picture and
|
||||
/// its accent colour - and text or pictures outside any container become one more. The buttons in them are read apart
|
||||
/// (see `controls_in`).
|
||||
fn layout_cards(d: &Value) -> Vec<Embed> {
|
||||
let Some(top) = d["components"].as_array() else { return Vec::new() };
|
||||
let mut cards = Vec::new();
|
||||
let mut loose = Card::default();
|
||||
for node in top {
|
||||
if node["type"].as_i64() == Some(17) {
|
||||
let mut card = Card::default();
|
||||
card.take(node);
|
||||
if let Some(embed) = card.into_embed(node["accent_color"].as_i64()) {
|
||||
cards.push(embed);
|
||||
}
|
||||
} else {
|
||||
loose.take(node);
|
||||
}
|
||||
}
|
||||
if let Some(embed) = loose.into_embed(None) {
|
||||
cards.insert(0, embed);
|
||||
}
|
||||
cards
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Card {
|
||||
texts: Vec<String>,
|
||||
picture: Option<String>,
|
||||
}
|
||||
|
||||
impl Card {
|
||||
fn take(&mut self, node: &Value) {
|
||||
match node["type"].as_i64() {
|
||||
Some(10) => {
|
||||
if let Some(text) = node["content"].as_str().filter(|t| !t.trim().is_empty()) {
|
||||
self.texts.push(text.to_string());
|
||||
}
|
||||
}
|
||||
// A thumbnail, or a gallery of pictures: the first is the card's.
|
||||
Some(11) => self.picture_of(&node["media"]),
|
||||
Some(12) => {
|
||||
for item in node["items"].as_array().into_iter().flatten() {
|
||||
self.picture_of(&item["media"]);
|
||||
}
|
||||
}
|
||||
Some(13) => {
|
||||
if let Some(url) = node["file"]["url"].as_str() {
|
||||
self.texts.push(format!("[{}]({url})", node["name"].as_str().unwrap_or("Attached file")));
|
||||
}
|
||||
}
|
||||
// A section: its text, and its accessory when that is a picture.
|
||||
Some(9) | Some(17) => {
|
||||
for child in node["components"].as_array().into_iter().flatten() {
|
||||
self.take(child);
|
||||
}
|
||||
if node["accessory"]["type"].as_i64() == Some(11) {
|
||||
self.picture_of(&node["accessory"]["media"]);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn picture_of(&mut self, media: &Value) {
|
||||
if self.picture.is_none() {
|
||||
self.picture = media["url"].as_str().map(str::to_string);
|
||||
}
|
||||
}
|
||||
|
||||
fn into_embed(self, accent: Option<i64>) -> Option<Embed> {
|
||||
if self.texts.is_empty() && self.picture.is_none() {
|
||||
return None;
|
||||
}
|
||||
let joined = self.texts.join("\n\n");
|
||||
// A first line that is a markdown heading is what the card is called.
|
||||
let (title, description) = match joined.split_once('\n') {
|
||||
Some((first, rest)) if first.starts_with('#') => (Some(first.trim_start_matches('#').trim().to_string()), rest.trim().to_string()),
|
||||
None if joined.starts_with('#') => (Some(joined.trim_start_matches('#').trim().to_string()), String::new()),
|
||||
_ => (None, joined),
|
||||
};
|
||||
Some(Embed {
|
||||
title: title.filter(|t| !t.is_empty()),
|
||||
description: Some(description).filter(|d| !d.is_empty()),
|
||||
color: accent,
|
||||
image_url: self.picture,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores a message's buttons and tells the client, if it has any.
|
||||
///
|
||||
/// Separate from the message itself because they arrive with it and are
|
||||
@@ -1620,3 +1825,151 @@ mod safety_notice_tests {
|
||||
assert!(cta.url.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gif_tests {
|
||||
use super::{extract_body, extract_embeds};
|
||||
use serde_json::json;
|
||||
|
||||
/// A Tenor GIF as Discord unfurls it.
|
||||
fn gifv() -> serde_json::Value {
|
||||
json!({
|
||||
"id": "1", "channel_id": "2", "content": "https://tenor.com/view/rickroll-gif-22954713",
|
||||
"embeds": [{
|
||||
"type": "gifv", "url": "https://tenor.com/view/rickroll-gif-22954713",
|
||||
"provider": { "name": "Tenor", "url": "https://tenor.co" },
|
||||
"thumbnail": { "url": "https://media.tenor.com/x.png", "width": 640, "height": 640 },
|
||||
"video": { "url": "https://media.tenor.com/x.mp4", "proxy_url": "https://images-ext-1.discordapp.net/external/x.mp4", "width": 640, "height": 640 }
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gifv_is_a_gif_with_somewhere_to_play_it() {
|
||||
let e = extract_embeds(&gifv());
|
||||
assert_eq!(e.len(), 1);
|
||||
assert_eq!(e[0].kind.as_deref(), Some("gif"));
|
||||
assert_eq!(e[0].url.as_deref(), Some("https://tenor.com/view/rickroll-gif-22954713"));
|
||||
assert_eq!((e[0].width, e[0].height), (Some(640), Some(640)));
|
||||
assert_eq!(e[0].provider.as_deref(), Some("Tenor"));
|
||||
assert_eq!(e[0].media_kind.as_deref(), Some("video"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gifv_with_only_a_thumbnail_is_a_picture() {
|
||||
let d = json!({ "embeds": [{ "type": "gifv", "url": "https://x/y", "thumbnail": { "url": "https://x/t.png" } }] });
|
||||
assert_eq!(extract_embeds(&d)[0].media_kind.as_deref(), Some("image"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_is_played_from_discords_own_copy_so_the_third_party_is_not_told() {
|
||||
let e = extract_embeds(&gifv());
|
||||
assert_eq!(e[0].media_url.as_deref(), Some("https://images-ext-1.discordapp.net/external/x.mp4"));
|
||||
let mut direct = gifv();
|
||||
direct["embeds"][0]["video"].as_object_mut().unwrap().remove("proxy_url");
|
||||
assert_eq!(extract_embeds(&direct)[0].media_url.as_deref(), Some("https://media.tenor.com/x.mp4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gifv_with_no_picture_anywhere_is_not_one() {
|
||||
let d = json!({ "embeds": [{ "type": "gifv", "url": "https://tenor.com/view/x" }] });
|
||||
assert!(extract_embeds(&d).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_picture_is_not_also_added_to_the_words() {
|
||||
// Whether or not the link is in the text: the embed is what carries it.
|
||||
assert_eq!(extract_body(&gifv()).as_deref(), Some("https://tenor.com/view/rickroll-gif-22954713"));
|
||||
let mut quiet = gifv();
|
||||
quiet["content"] = json!("");
|
||||
assert_eq!(extract_body(&quiet), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod layout_tests {
|
||||
use super::{extract_components, extract_embeds};
|
||||
use serde_json::json;
|
||||
|
||||
/// What a bot's message built with Discord's layout components looks like: no text of its own, a container with
|
||||
/// a section (text and a picture) and a row holding one button.
|
||||
fn honeypot() -> serde_json::Value {
|
||||
json!({
|
||||
"content": "",
|
||||
"flags": 32768,
|
||||
"components": [{
|
||||
"type": 17,
|
||||
"accent_color": 0x5865f2,
|
||||
"components": [
|
||||
{ "type": 9,
|
||||
"components": [{ "type": 10, "content": "## DO NOT SEND MESSAGES IN THIS CHANNEL\nThis channel is used to catch spam bots. Any messages sent here will result in **an immediate ban**." }],
|
||||
"accessory": { "type": 11, "media": { "url": "https://cdn.discordapp.com/attachments/1/2/honeypot.png" } } },
|
||||
{ "type": 14, "divider": true },
|
||||
{ "type": 1, "components": [{ "type": 2, "style": 2, "label": "Bans: 19", "custom_id": "bans", "disabled": true, "emoji": { "name": "🍯" } }] }
|
||||
]
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_container_is_one_card_with_a_title_its_text_and_its_picture() {
|
||||
let cards = extract_embeds(&honeypot());
|
||||
assert_eq!(cards.len(), 1);
|
||||
assert_eq!(cards[0].title.as_deref(), Some("DO NOT SEND MESSAGES IN THIS CHANNEL"));
|
||||
assert!(cards[0].description.as_deref().unwrap().starts_with("This channel is used to catch spam bots."));
|
||||
assert_eq!(cards[0].image_url.as_deref(), Some("https://cdn.discordapp.com/attachments/1/2/honeypot.png"));
|
||||
assert_eq!(cards[0].color, Some(0x5865f2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_buttons_inside_a_container_are_found() {
|
||||
let controls = extract_components(&honeypot());
|
||||
assert_eq!(controls.len(), 1);
|
||||
assert_eq!((controls[0].label.as_deref(), controls[0].kind.as_str(), controls[0].disabled), (Some("Bans: 19"), "button", true));
|
||||
assert_eq!(controls[0].emoji.as_deref(), Some("🍯"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sections_button_accessory_is_a_control_and_its_picture_accessory_is_not() {
|
||||
let msg = json!({ "components": [{ "type": 17, "components": [
|
||||
{ "type": 9, "components": [{ "type": 10, "content": "Pick" }], "accessory": { "type": 2, "style": 1, "label": "Go", "custom_id": "go" } },
|
||||
] }] });
|
||||
let controls = extract_components(&msg);
|
||||
assert_eq!(controls.len(), 1);
|
||||
assert_eq!(controls[0].label.as_deref(), Some("Go"));
|
||||
assert_eq!(extract_embeds(&msg)[0].description.as_deref(), Some("Pick"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ordinary_message_has_no_layout_card() {
|
||||
let msg = json!({ "content": "hi", "components": [{ "type": 1, "components": [{ "type": 2, "label": "x", "custom_id": "x", "style": 1 }] }] });
|
||||
assert!(extract_embeds(&msg).is_empty());
|
||||
assert_eq!(extract_components(&msg).len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod app_badge_tests {
|
||||
use super::author_badges;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn a_bot_is_an_app_and_a_verified_one_says_so() {
|
||||
let plain = author_badges(&json!({ "id": "1", "bot": true, "public_flags": 0 }));
|
||||
assert_eq!((plain[0].kind.as_str(), plain[0].text.as_str()), ("app", "App"));
|
||||
let verified = author_badges(&json!({ "id": "1", "bot": true, "public_flags": 65536 }));
|
||||
assert_eq!((verified[0].kind.as_str(), verified[0].text.as_str()), ("verified-app", "Verified App"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discords_own_account_is_official_even_though_it_is_not_an_app() {
|
||||
let official = author_badges(&json!({ "id": "643945264868098049", "system": true, "bot": true }));
|
||||
assert_eq!((official[0].kind.as_str(), official[0].text.as_str()), ("official", "Official Discord Message"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_person_has_no_badge() {
|
||||
assert!(author_badges(&json!({ "id": "2", "username": "someone" })).is_empty());
|
||||
assert!(author_badges(&json!({ "id": "2", "bot": false })).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,10 +73,12 @@ pub mod stickers;
|
||||
pub mod soundboard;
|
||||
pub mod events;
|
||||
pub mod forums;
|
||||
pub mod gifpicker;
|
||||
|
||||
pub use calls::*;
|
||||
pub use commands::*;
|
||||
pub use forums::*;
|
||||
pub use gifpicker::*;
|
||||
pub use gateway::*;
|
||||
pub use guilds::*;
|
||||
pub use history::*;
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
//! months ago was as loud in moho as anything else - and the busiest servers
|
||||
//! are exactly the ones people mute.
|
||||
//!
|
||||
//! Reading it, not writing it. Being *told* is the half that matters and the
|
||||
//! half that can go wrong quietly; setting it from here is a separate thing
|
||||
//! and can follow.
|
||||
//! Read first, and then written for a channel: muting one from here sets it on
|
||||
//! the account, so it is quiet on a phone as well as in this window - which
|
||||
//! is what somebody means by muting it. Discord answers with the account's
|
||||
//! settings as they now stand, and those are taken in like any others.
|
||||
//!
|
||||
//! Applying is deliberately its own step. The settings arrive in READY, and
|
||||
//! the channels they name arrive afterwards in GUILD_CREATE - so a mute
|
||||
@@ -81,6 +82,42 @@ pub(super) fn note_settings(state: &AppState, account_id: &str, settings: &Value
|
||||
apply(state, account_id);
|
||||
}
|
||||
|
||||
/// What to send to mute or unmute one channel: its own setting inside its
|
||||
/// guild's, with no end time - a mute that does not expire.
|
||||
fn override_body(channel_id: &str, muted: bool) -> Value {
|
||||
json!({ "channel_overrides": { channel_id: { "muted": muted, "mute_config": null } } })
|
||||
}
|
||||
|
||||
/// Mutes or unmutes a conversation on the account itself.
|
||||
///
|
||||
/// A muted channel still counts what was said to its reader - Discord keeps
|
||||
/// the mention badge on one - and stops sounding on every device signed in to
|
||||
/// the account. Direct messages are the `@me` scope.
|
||||
pub async fn set_muted(state: &AppState, account_id: &str, buffer_id: &str, muted: bool) -> anyhow::Result<()> {
|
||||
use anyhow::Context;
|
||||
let config = state.accounts.get_discord(account_id).context("no such account")?;
|
||||
let channel_id = state.runtime.get_discord_channel(buffer_id).context("not a Discord conversation")?;
|
||||
let guild = state.runtime.get_discord_guild(buffer_id).unwrap_or_else(|| DM_SCOPE.to_string());
|
||||
let response = send_write(
|
||||
http_client_for(&config.token)
|
||||
.patch(format!("{API_BASE}/users/@me/guilds/{guild}/settings"))
|
||||
.header("Authorization", &config.token)
|
||||
.json(&override_body(&channel_id, muted)),
|
||||
)
|
||||
.await?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("Discord refused to {} it (HTTP {})", if muted { "mute" } else { "unmute" }, status.as_u16());
|
||||
}
|
||||
// The account's settings as they now are. The gateway says the same a
|
||||
// moment later; taking it here as well is what makes the change show
|
||||
// without waiting on that.
|
||||
if let Ok(now) = response.json::<Value>().await {
|
||||
note_settings(state, account_id, &now);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Puts what is known onto the buffers that exist.
|
||||
///
|
||||
/// Cheap enough to run whenever anything might have changed - one pass over
|
||||
@@ -112,6 +149,15 @@ mod tests {
|
||||
|
||||
const NOW: i64 = 1_800_000_000;
|
||||
|
||||
#[test]
|
||||
fn muting_a_channel_is_its_own_setting_with_no_end() {
|
||||
assert_eq!(
|
||||
override_body("100", true),
|
||||
json!({ "channel_overrides": { "100": { "muted": true, "mute_config": null } } })
|
||||
);
|
||||
assert_eq!(override_body("100", false)["channel_overrides"]["100"]["muted"], false);
|
||||
}
|
||||
|
||||
/// A muted guild and, inside it, one channel muted on its own. Both are
|
||||
/// read; neither is inferred from the other.
|
||||
#[test]
|
||||
@@ -170,4 +216,42 @@ mod tests {
|
||||
let forever = json!({ "guild_id": "9", "muted": true });
|
||||
assert!(parse_entry(&forever, NOW).1.muted);
|
||||
}
|
||||
|
||||
/// A timed mute ends at its end time, to the second: at that moment it is
|
||||
/// over, and a second before it is not.
|
||||
#[test]
|
||||
fn a_mute_is_over_at_its_end_time() {
|
||||
let ends = |at: i64| {
|
||||
let end = chrono::DateTime::from_timestamp(at, 0).unwrap().to_rfc3339();
|
||||
json!({ "muted": true, "mute_config": { "end_time": end } })
|
||||
};
|
||||
assert!(still_muted(&ends(NOW + 1), NOW), "a second to go");
|
||||
assert!(!still_muted(&ends(NOW), NOW), "its moment");
|
||||
assert!(!still_muted(&ends(NOW - 1), NOW), "past");
|
||||
// An end time nobody can read is not a reason to say it is over.
|
||||
assert!(still_muted(&json!({ "muted": true, "mute_config": { "end_time": "someday" } }), NOW));
|
||||
assert!(!still_muted(&json!({ "muted": false, "mute_config": null }), NOW), "never muted");
|
||||
}
|
||||
|
||||
/// Settings in the three shapes, and an entry that is not an object at all
|
||||
/// is skipped rather than read as an empty one.
|
||||
#[test]
|
||||
fn what_is_read_is_kept_and_what_is_not_an_entry_is_skipped() {
|
||||
let state = crate::backend::kick::testkit::simulated_daemon("discord-mutes");
|
||||
let acct = "discord:me";
|
||||
note_settings(&state, acct, &json!([
|
||||
{ "guild_id": "9", "muted": true },
|
||||
"not an entry",
|
||||
42,
|
||||
{ "guild_id": null, "muted": false, "channel_overrides": [{ "channel_id": "5", "muted": true }] }
|
||||
]));
|
||||
assert!(state.runtime.discord_muted(acct, "9", "anything"), "a muted guild mutes what is in it");
|
||||
assert!(state.runtime.discord_muted(acct, DM_SCOPE, "5"), "a muted direct message");
|
||||
assert!(!state.runtime.discord_muted(acct, DM_SCOPE, "6"));
|
||||
// One entry on its own, as an update sends it, and the wrapped form.
|
||||
note_settings(&state, acct, &json!({ "guild_id": "9", "muted": false }));
|
||||
assert!(!state.runtime.discord_muted(acct, "9", "anything"));
|
||||
note_settings(&state, acct, &json!({ "entries": [{ "guild_id": "9", "muted": true }] }));
|
||||
assert!(state.runtime.discord_muted(acct, "9", "anything"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,19 @@ pub async fn apply_status(state: &AppState, account_id: &str, status: &str, text
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows the account as `status` on this session and leaves the saved setting
|
||||
/// alone.
|
||||
///
|
||||
/// For being idle because the person has walked away, which is a fact about
|
||||
/// this session and not a choice: Discord's own client does the same, and
|
||||
/// writing it to the account's settings would turn a coffee break into the
|
||||
/// status every other device starts from.
|
||||
pub fn apply_session_presence(state: &AppState, account_id: &str, status: &str) -> bool {
|
||||
let Some(sender) = state.runtime.discord_gateway_sender(account_id) else { return false };
|
||||
let said = state.runtime.account_status_text(account_id);
|
||||
sender.send(json!({ "op": 3, "d": presence_payload(status, &said) }).to_string()).is_ok()
|
||||
}
|
||||
|
||||
/// What goes to Discord's settings: the status, and the custom status only when
|
||||
/// it is being changed. Null clears it; an object sets it.
|
||||
fn settings_body(status: &str, text: Option<&str>) -> serde_json::Value {
|
||||
@@ -144,6 +157,28 @@ pub fn request_member_list(state: &AppState, buffer_id: &str) -> bool {
|
||||
/// offline buckets - or a member.
|
||||
/// One entry of Discord's member window, or nothing if the item is a role
|
||||
/// header rather than a person.
|
||||
/// The one line Discord's member list shows for somebody: what they are doing.
|
||||
///
|
||||
/// A custom status first, when they have one - it is words they wrote for
|
||||
/// exactly this place - and otherwise the first thing they are doing. A game is
|
||||
/// named bare, the gamepad saying what kind of thing it is; a track by its
|
||||
/// artist where there is one, which is what the member list shows (the full
|
||||
/// "Listening to Spotify" is in the profile card); anything else (watching,
|
||||
/// competing) as a sentence of words, since there is no picture for it.
|
||||
pub(super) fn activity_of(activities: &Value) -> Option<Value> {
|
||||
let list = activities.as_array()?;
|
||||
let chosen = list.iter().find(|a| a["type"].as_i64() == Some(4)).or_else(|| list.first())?;
|
||||
let said = |key: &str| chosen[key].as_str().map(str::trim).filter(|t| !t.is_empty());
|
||||
match chosen["type"].as_i64().unwrap_or(0) {
|
||||
4 => said("state").map(|t| json!({ "kind": "custom", "text": t })),
|
||||
// Streaming and competing are playing as far as a line can say.
|
||||
0 | 1 | 5 => said("name").map(|t| json!({ "kind": "game", "text": t })),
|
||||
2 => said("state").or_else(|| said("details")).or_else(|| said("name")).map(|t| json!({ "kind": "music", "text": t })),
|
||||
3 => said("name").map(|t| json!({ "kind": "custom", "text": format!("Watching {t}") })),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn member_entry(runtime: &crate::runtime::Runtime, account_id: &str, guild_id: &str, item: &Value) -> Option<Value> {
|
||||
let member = item.get("member")?;
|
||||
let user = &member["user"];
|
||||
@@ -187,6 +222,7 @@ pub(super) fn member_entry(runtime: &crate::runtime::Runtime, account_id: &str,
|
||||
// present, and their own status word says the rest.
|
||||
"away": status == "offline",
|
||||
"status": status,
|
||||
"activity": activity_of(&member["presence"]["activities"]),
|
||||
"avatarUrl": super::author_avatar_url(user),
|
||||
"roles": roles
|
||||
}))
|
||||
@@ -355,7 +391,7 @@ pub(super) fn update_member_list(state: &AppState, buffer_id: &str, d: &Value) {
|
||||
/// PRESENCE_UPDATE arrives for anyone the account can see, which is far more
|
||||
/// people than are in any open roster - so this only touches buffers that
|
||||
/// already list them, and says nothing otherwise.
|
||||
pub(super) fn update_presence_in_rosters(state: &AppState, user_id: &str, status: &str) {
|
||||
pub(super) fn update_presence_in_rosters(state: &AppState, user_id: &str, status: &str, activity: Option<Value>) {
|
||||
for buffer in state.runtime.list_buffers() {
|
||||
let Some(existing) = state.runtime.get_presence(&buffer.id) else { continue };
|
||||
let Some(members) = existing.as_array() else { continue };
|
||||
@@ -373,6 +409,7 @@ pub(super) fn update_presence_in_rosters(state: &AppState, user_id: &str, status
|
||||
// Same rule the initial sync uses; the two disagreeing would
|
||||
// move someone between groups on their next presence change.
|
||||
m["away"] = json!(status == "offline");
|
||||
m["activity"] = activity.clone().unwrap_or(Value::Null);
|
||||
m
|
||||
})
|
||||
.collect();
|
||||
@@ -382,6 +419,48 @@ pub(super) fn update_presence_in_rosters(state: &AppState, user_id: &str, status
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod activity_tests {
|
||||
use super::*;
|
||||
|
||||
fn said(activities: Value) -> Option<String> {
|
||||
activity_of(&activities).map(|a| format!("{}:{}", a["kind"].as_str().unwrap(), a["text"].as_str().unwrap()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_game_is_named_bare_and_a_track_by_its_artist() {
|
||||
assert_eq!(said(json!([{ "type": 0, "name": "Resident Evil 4" }])).as_deref(), Some("game:Resident Evil 4"));
|
||||
assert_eq!(said(json!([{ "type": 2, "name": "Spotify", "details": "Judy Is a Punk", "state": "Ramones" }])).as_deref(), Some("music:Ramones"));
|
||||
assert_eq!(said(json!([{ "type": 2, "name": "Spotify", "details": "Judy Is a Punk" }])).as_deref(), Some("music:Judy Is a Punk"));
|
||||
assert_eq!(said(json!([{ "type": 2, "name": "Spotify" }])).as_deref(), Some("music:Spotify"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words_somebody_wrote_come_first_and_have_no_picture() {
|
||||
assert_eq!(said(json!([{ "type": 0, "name": "Dota 2" }, { "type": 4, "name": "Custom Status", "state": "ask questions", "emoji": { "name": "x" } }])).as_deref(), Some("custom:ask questions"));
|
||||
assert_eq!(said(json!([{ "type": 3, "name": "a stream" }])).as_deref(), Some("custom:Watching a stream"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_is_nothing_and_an_empty_line_is_not_a_line() {
|
||||
assert_eq!(said(json!([])), None);
|
||||
assert_eq!(said(json!(null)), None);
|
||||
assert_eq!(said(json!([{ "type": 4, "state": " " }])), None, "a custom status with only an emoji says nothing");
|
||||
assert_eq!(said(json!([{ "type": 0, "name": "" }])), None);
|
||||
assert_eq!(said(json!([{ "type": 99, "name": "x" }])), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_member_list_entry_carries_it() {
|
||||
let runtime = crate::runtime::Runtime::new();
|
||||
let item = json!({ "member": { "nick": "Gremlin", "user": { "id": "1", "username": "g" }, "presence": { "status": "online", "activities": [{ "type": 4, "state": "ask questions" }] } } });
|
||||
let entry = member_entry(&runtime, "discord:me", "", &item).unwrap();
|
||||
assert_eq!(entry["activity"], json!({ "kind": "custom", "text": "ask questions" }));
|
||||
let quiet = json!({ "member": { "nick": "Q", "user": { "id": "2", "username": "q" }, "presence": { "status": "online" } } });
|
||||
assert!(member_entry(&runtime, "discord:me", "", &quiet).unwrap()["activity"].is_null());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod window_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -391,11 +391,26 @@ mod opus_guard_tests {
|
||||
assert!((1.9..=2.1).contains(&seconds), "decoded {seconds:.2}s of a 2s sound");
|
||||
}
|
||||
|
||||
/// Ogg's page checksum: CRC-32 with polynomial 0x04c11db7, not reflected,
|
||||
/// starting from zero, over the whole page with its own field zeroed.
|
||||
fn ogg_crc(page: &[u8]) -> u32 {
|
||||
let mut crc = 0u32;
|
||||
for &byte in page {
|
||||
crc ^= (byte as u32) << 24;
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 0x8000_0000 != 0 { (crc << 1) ^ 0x04c1_1db7 } else { crc << 1 };
|
||||
}
|
||||
}
|
||||
crc
|
||||
}
|
||||
|
||||
/// Packets that are not Opus are left out rather than handed over.
|
||||
#[test]
|
||||
fn a_damaged_ogg_opus_sound_does_not_reach_the_decoder_whole() {
|
||||
let samples: Vec<f32> = (0..48_000).map(|i| (i as f32 * 0.03).sin() * 0.5).collect();
|
||||
let mut file = crate::oggopus::encode(&samples).unwrap();
|
||||
let whole = super::decode(&file).expect("the undamaged sound decodes");
|
||||
assert!(!whole.is_empty(), "the undamaged sound has audio in it");
|
||||
// Corrupt the audio pages (after the two header pages) without
|
||||
// touching the Ogg framing: each packet's first byte becomes a code 3
|
||||
// header claiming 63 frames, which no valid packet can.
|
||||
@@ -414,10 +429,18 @@ mod opus_guard_tests {
|
||||
file[body + 1] = 63;
|
||||
}
|
||||
let len: usize = file[i + 27..i + 27 + segments].iter().map(|&b| b as usize).sum();
|
||||
// The page's checksum covers what was just changed. Left as it was
|
||||
// the container rejects the whole file before any packet is read,
|
||||
// and this would be testing that and not the check it is named for.
|
||||
file[i + 22..i + 26].copy_from_slice(&[0; 4]);
|
||||
let crc = ogg_crc(&file[i..body + len]);
|
||||
file[i + 22..i + 26].copy_from_slice(&crc.to_le_bytes());
|
||||
i = body + len;
|
||||
}
|
||||
// Still a file symphonia can read, and the check keeps the bad
|
||||
// packets out; it must not panic or error the whole sound.
|
||||
let _ = super::decode(&file);
|
||||
// packets out; it must not panic or error the whole sound, and what
|
||||
// comes back is what was left of it - less than there was.
|
||||
let damaged = super::decode(&file).expect("a damaged sound is decoded as far as it goes");
|
||||
assert!(damaged.len() < whole.len(), "{} of {} samples came through", damaged.len(), whole.len());
|
||||
}
|
||||
}
|
||||
|
||||
+62
-21
@@ -701,6 +701,21 @@ pub(super) fn cap_list<'a>(param: Option<&'a str>, suffix: Option<&'a str>) -> &
|
||||
param.or(suffix).unwrap_or("")
|
||||
}
|
||||
|
||||
/// One line of a `CAP LS` answer: whether another is coming, and what this one
|
||||
/// lists.
|
||||
///
|
||||
/// A multiline answer puts "*" in one field and the list in the other, and a
|
||||
/// live network is what proved that which field is which cannot be assumed:
|
||||
/// looking for the star in one only meant the last continuation line was taken
|
||||
/// for the whole answer, and every capability advertised after it was silently
|
||||
/// never requested. Libera offers nineteen; this client was seeing nine.
|
||||
pub(super) fn cap_ls_page<'a>(param: Option<&'a str>, suffix: Option<&'a str>) -> (bool, Vec<&'a str>) {
|
||||
let fields = [param, suffix];
|
||||
let more = fields.iter().any(|f| f.map(str::trim) == Some("*"));
|
||||
let caps = fields.into_iter().flatten().filter(|f| f.trim() != "*").flat_map(str::split_whitespace).collect();
|
||||
(more, caps)
|
||||
}
|
||||
|
||||
/// Which of a `CAP NEW` offer are worth asking for.
|
||||
///
|
||||
/// The intersection with `WANTED_CAPS`, because a server offering something
|
||||
@@ -854,13 +869,8 @@ pub(super) async fn offered_caps_raw(sender: &Sender, stream: &mut ClientStream)
|
||||
loop {
|
||||
let msg = wait_for(stream, |m| matches!(&m.command, Command::CAP(_, CapSubCommand::LS, _, _))).await.ok()?;
|
||||
let Command::CAP(_, _, ref param, ref suffix) = msg.command else { return None };
|
||||
// A multiline answer puts "*" where a single-line one puts the list,
|
||||
// so the list is whichever field is not the continuation marker.
|
||||
let more = param.as_deref() == Some("*");
|
||||
let list = if more { suffix.as_deref().unwrap_or("") } else { cap_list(param.as_deref(), suffix.as_deref()) };
|
||||
for cap in list.split_whitespace() {
|
||||
offered.push(cap.to_string());
|
||||
}
|
||||
let (more, caps) = cap_ls_page(param.as_deref(), suffix.as_deref());
|
||||
offered.extend(caps.into_iter().map(str::to_string));
|
||||
if !more {
|
||||
tracing::debug!("irc: server offers {} capabilities: {}", offered.len(), offered.join(" "));
|
||||
return Some(offered);
|
||||
@@ -1199,6 +1209,21 @@ mod quit_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod wanted_cap_tests {
|
||||
use super::WANTED_CAPS;
|
||||
|
||||
/// Both spellings of metadata, because networks that shipped it first
|
||||
/// still advertise the draft one; a rename in the list would end the
|
||||
/// negotiation for one of them without anything saying so.
|
||||
#[test]
|
||||
fn both_spellings_of_metadata_are_asked_for() {
|
||||
assert!(WANTED_CAPS.contains(&"draft/metadata"));
|
||||
assert!(WANTED_CAPS.contains(&"metadata-2"));
|
||||
assert!(!WANTED_CAPS.contains(&"metadata"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cap_new_tests {
|
||||
use super::caps_worth_requesting;
|
||||
@@ -1239,7 +1264,7 @@ mod cap_new_tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod cap_tests {
|
||||
use super::cap_list;
|
||||
use super::{cap_list, cap_ls_page};
|
||||
use irc::proto::{CapSubCommand, Command, Message};
|
||||
|
||||
/// Which field of a CAP LS line holds the list, and which holds the "*"
|
||||
@@ -1254,21 +1279,37 @@ mod cap_tests {
|
||||
fn a_multiline_offer_is_read_to_the_end() {
|
||||
// Both orderings of (param, suffix), because both occur.
|
||||
for (param, suffix) in [(Some("*"), Some("a b")), (Some("a b"), Some("*"))] {
|
||||
let fields = [param, suffix];
|
||||
let more = fields.iter().any(|f| f.map(str::trim) == Some("*"));
|
||||
let caps: Vec<&str> = fields
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|f| f.trim() != "*")
|
||||
.flat_map(str::split_whitespace)
|
||||
.collect();
|
||||
assert!(more, "a line carrying a star has more to come");
|
||||
assert_eq!(caps, vec!["a", "b"]);
|
||||
let (more, caps) = cap_ls_page(param, suffix);
|
||||
assert!(more, "a line carrying a star has more to come: {param:?} {suffix:?}");
|
||||
assert_eq!(caps, vec!["a", "b"], "{param:?} {suffix:?}");
|
||||
}
|
||||
// The final line carries no star and is the end of the answer, whichever
|
||||
// field its list is in.
|
||||
for (param, suffix) in [(None, Some("c d")), (Some("c d"), None)] {
|
||||
let (more, caps) = cap_ls_page(param, suffix);
|
||||
assert!(!more, "{param:?} {suffix:?}");
|
||||
assert_eq!(caps, vec!["c", "d"]);
|
||||
}
|
||||
// Nothing to list is an empty page, not a failure.
|
||||
assert_eq!(cap_ls_page(Some("*"), None), (true, vec![]));
|
||||
assert_eq!(cap_ls_page(None, None), (false, vec![]));
|
||||
}
|
||||
|
||||
// The final line carries no star and is the end of the answer.
|
||||
let fields = [None, Some("c d")];
|
||||
assert!(!fields.iter().any(|f| f.map(str::trim) == Some("*")));
|
||||
/// The same, through the parser the connection uses: a real server line
|
||||
/// and the fields the crate makes of it.
|
||||
#[test]
|
||||
fn a_continuation_line_off_the_wire_says_more_is_coming() {
|
||||
let msg: Message = ":irc.example CAP * LS * :multi-prefix away-notify\r\n".parse().unwrap();
|
||||
let Command::CAP(_, CapSubCommand::LS, param, suffix) = &msg.command else { panic!("not CAP LS: {:?}", msg.command) };
|
||||
let (more, caps) = cap_ls_page(param.as_deref(), suffix.as_deref());
|
||||
assert!(more);
|
||||
assert_eq!(caps, vec!["multi-prefix", "away-notify"]);
|
||||
|
||||
let last: Message = ":irc.example CAP * LS :sasl chghost\r\n".parse().unwrap();
|
||||
let Command::CAP(_, CapSubCommand::LS, param, suffix) = &last.command else { panic!("not CAP LS: {:?}", last.command) };
|
||||
let (more, caps) = cap_ls_page(param.as_deref(), suffix.as_deref());
|
||||
assert!(!more);
|
||||
assert_eq!(caps, vec!["sasl", "chghost"]);
|
||||
}
|
||||
|
||||
/// Where the granted capabilities actually live in a parsed CAP ACK.
|
||||
|
||||
+62
-19
@@ -1082,6 +1082,35 @@ pub async fn offer_file(state: &AppState, account_id: &str, nick: &str, path: &s
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Waits for whoever the offer was made to, for as long as the offer stands.
|
||||
///
|
||||
/// Looks at the cancel flag as it waits. It used to look only once somebody
|
||||
/// had connected, so cancelling an offer nobody had taken did nothing for the
|
||||
/// three minutes the offer lasted, with the port open and the transfer shown
|
||||
/// as still going.
|
||||
async fn wait_for_taker(
|
||||
listener: &tokio::net::TcpListener,
|
||||
wait: std::time::Duration,
|
||||
cancel: &std::sync::atomic::AtomicBool,
|
||||
) -> Result<(tokio::net::TcpStream, std::net::SocketAddr)> {
|
||||
let deadline = std::time::Instant::now() + wait;
|
||||
loop {
|
||||
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
bail!("cancelled");
|
||||
}
|
||||
let left = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
if left.is_zero() {
|
||||
bail!("they did not accept it within {} seconds", wait.as_secs());
|
||||
}
|
||||
let slice = left.min(std::time::Duration::from_millis(250));
|
||||
match tokio::time::timeout(slice, listener.accept()).await {
|
||||
Err(_) => continue,
|
||||
Ok(Err(e)) => return Err(anyhow!("waiting for them to connect: {e}")),
|
||||
Ok(Ok(pair)) => return Ok(pair),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits for them to connect, then hands the file over.
|
||||
///
|
||||
/// Takes what to do about progress rather than the state to do it to, the same
|
||||
@@ -1096,14 +1125,9 @@ async fn serve_file(
|
||||
on_accepted: impl FnOnce(),
|
||||
mut on_progress: impl FnMut(Progress),
|
||||
) -> Result<()> {
|
||||
let accepted = tokio::time::timeout(OFFER_TIMEOUT, listener.accept()).await;
|
||||
// The listener is dropped either way once this returns, so an offer that
|
||||
// nobody took does not leave a port open behind it.
|
||||
let (mut sock, peer) = match accepted {
|
||||
Err(_) => bail!("they did not accept it within {} seconds", OFFER_TIMEOUT.as_secs()),
|
||||
Ok(Err(e)) => return Err(anyhow!("waiting for them to connect: {e}")),
|
||||
Ok(Ok(pair)) => pair,
|
||||
};
|
||||
let (mut sock, peer) = wait_for_taker(&listener, OFFER_TIMEOUT, cancel).await?;
|
||||
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
bail!("cancelled");
|
||||
}
|
||||
@@ -2122,21 +2146,40 @@ mod transfer_tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_offer_nobody_takes_gives_up_rather_than_waiting_forever() {
|
||||
let dir = temp_dir("nobody");
|
||||
let path = dir.join("ignored.bin");
|
||||
std::fs::write(&path, b"x").unwrap();
|
||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
|
||||
let cancel = AtomicBool::new(false);
|
||||
let began = std::time::Instant::now();
|
||||
let err = wait_for_taker(&listener, std::time::Duration::from_millis(300), &cancel).await.unwrap_err();
|
||||
assert!(format!("{err:#}").contains("did not accept"), "{err:#}");
|
||||
assert!(began.elapsed() < std::time::Duration::from_secs(3), "it waited {:?}", began.elapsed());
|
||||
}
|
||||
|
||||
let cancel = AtomicBool::new(true);
|
||||
// Cancelled before anyone connects stands in for the timeout, which
|
||||
// is three minutes and not worth a test's patience.
|
||||
let err = tokio::time::timeout(
|
||||
std::time::Duration::from_millis(200),
|
||||
serve_file(listener, path.to_str().unwrap(), 1, 0, &cancel, || {}, |_| {}),
|
||||
)
|
||||
.await;
|
||||
assert!(err.is_err() || err.unwrap().is_err(), "an offer nobody takes must not succeed");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
#[tokio::test]
|
||||
async fn cancelling_an_offer_nobody_has_taken_ends_it_at_once() {
|
||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
|
||||
let cancel = std::sync::Arc::new(AtomicBool::new(false));
|
||||
let stop = cancel.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
stop.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
});
|
||||
let began = std::time::Instant::now();
|
||||
// An hour's patience, so only the cancel can be what ends it.
|
||||
let err = wait_for_taker(&listener, std::time::Duration::from_secs(3600), &cancel).await.unwrap_err();
|
||||
assert!(format!("{err:#}").contains("cancelled"), "{err:#}");
|
||||
assert!(began.elapsed() < std::time::Duration::from_secs(3), "it waited {:?}", began.elapsed());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn someone_who_connects_is_the_one_taken() {
|
||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
tokio::spawn(async move {
|
||||
let _ = tokio::net::TcpStream::connect(("127.0.0.1", port)).await;
|
||||
});
|
||||
let cancel = AtomicBool::new(false);
|
||||
let (_, peer) = wait_for_taker(&listener, std::time::Duration::from_secs(5), &cancel).await.expect("should accept");
|
||||
assert!(peer.ip().is_loopback());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
+41
-21
@@ -7,6 +7,18 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The line written in a channel when the server says what modes it has: the letters, and what they mean, so
|
||||
/// somebody new to IRC need not know them ("#archlinux is +Cnrt -- No CTCP messages to the channel, ...").
|
||||
pub fn channel_modes_line(channel: &str, modes: &str, params: &[String]) -> String {
|
||||
if modes.trim_matches('+').is_empty() {
|
||||
return format!("{channel} has no modes set");
|
||||
}
|
||||
match super::modes::describe(modes, params) {
|
||||
Some(why) => format!("{channel} is {modes} -- {why}"),
|
||||
None => format!("{channel} is {modes}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_channel(target: &str) -> bool {
|
||||
target.starts_with(['#', '&', '+', '!'])
|
||||
}
|
||||
@@ -290,7 +302,10 @@ pub(super) async fn handle_message(
|
||||
let adding = matches!(m, Mode::Plus(_, None));
|
||||
let letter = mode_letter(mode);
|
||||
state.runtime.set_irc_channel_mode(state, account_id, &channel, letter, adding);
|
||||
let body = format!("{from} sets {}{letter} on {channel}", if adding { "+" } else { "-" });
|
||||
let mut body = format!("{from} sets {}{letter} on {channel}", if adding { "+" } else { "-" });
|
||||
if let Some(why) = super::modes::describe_change(letter, adding) {
|
||||
body = format!("{body} -- {why}");
|
||||
}
|
||||
state.runtime.record_message(state, account_id, &channel, "channel", "*", &body, false, "system", None, None, false, None, Vec::new(), Vec::new(), None);
|
||||
continue;
|
||||
}
|
||||
@@ -312,12 +327,7 @@ pub(super) async fn handle_message(
|
||||
let Some(slot) = members.get_mut(target.as_str()) else { continue };
|
||||
slot.rank = if granting { rank } else { MemberRank::None };
|
||||
changed = true;
|
||||
let body = format!(
|
||||
"{from} {} {} {} {target}",
|
||||
if granting { "gives" } else { "takes" },
|
||||
rank_word(rank),
|
||||
if granting { "to" } else { "from" },
|
||||
);
|
||||
let body = rank_change_line(&from, granting, rank, target);
|
||||
state.runtime.record_message(state, account_id, &channel, "channel", "*", &body, false, "system", None, None, false, None, Vec::new(), Vec::new(), None);
|
||||
}
|
||||
if changed {
|
||||
@@ -494,7 +504,7 @@ pub(super) async fn handle_message(
|
||||
// capability. Without it away state is only ever known about people
|
||||
// who have just been WHOISed or messaged.
|
||||
Command::AWAY(reason) => {
|
||||
if state.runtime.set_irc_away(account_id, &from, reason.is_some()) {
|
||||
if state.runtime.set_irc_away_because(account_id, &from, reason.clone().map(Some)) {
|
||||
refresh_rosters_containing(state, account_id, &from, channels);
|
||||
}
|
||||
}
|
||||
@@ -852,11 +862,7 @@ pub(super) async fn handle_message(
|
||||
if let Some(channel) = args.get(1) {
|
||||
let modes = args.get(2).map(String::as_str).unwrap_or("");
|
||||
state.runtime.set_irc_channel_modes(state, account_id, channel, modes);
|
||||
let body = if modes.trim_matches('+').is_empty() {
|
||||
format!("{channel} has no modes set")
|
||||
} else {
|
||||
format!("{channel} is {modes}")
|
||||
};
|
||||
let body = channel_modes_line(channel, modes, &args.iter().skip(3).cloned().collect::<Vec<_>>());
|
||||
state.runtime.record_message(state, account_id, channel, "channel", "*", &body, false, "system", None, None, false, None, Vec::new(), Vec::new(), None);
|
||||
}
|
||||
}
|
||||
@@ -1024,6 +1030,16 @@ pub(super) fn rank_word(rank: MemberRank) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// The line for somebody being given a rank or losing one.
|
||||
pub(super) fn rank_change_line(from: &str, granting: bool, rank: MemberRank, target: &str) -> String {
|
||||
format!(
|
||||
"{from} {} {} {} {target}",
|
||||
if granting { "gives" } else { "takes" },
|
||||
rank_word(rank),
|
||||
if granting { "to" } else { "from" },
|
||||
)
|
||||
}
|
||||
|
||||
/// A message's own id, where the server gives one.
|
||||
///
|
||||
/// Used as the message id rather than a generated one, so a message seen twice -
|
||||
@@ -1130,6 +1146,16 @@ pub(super) fn buffer_kind_hint(target: &str) -> &'static str {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn a_channels_modes_are_written_with_what_they_mean() {
|
||||
assert_eq!(
|
||||
channel_modes_line("#archlinux", "+Cnrt", &[]),
|
||||
"#archlinux is +Cnrt -- No CTCP messages to the channel, no messages from outside the channel, registered users only, topic locked to operators."
|
||||
);
|
||||
assert_eq!(channel_modes_line("#quiet", "+", &[]), "#quiet has no modes set");
|
||||
assert_eq!(channel_modes_line("#odd", "+Z", &[]), "#odd is +Z -- +Z is this network's own.");
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
fn person(nick: &str, host: &str) -> Prefix {
|
||||
@@ -1229,14 +1255,8 @@ mod tests {
|
||||
/// words are used for granting and for taking away.
|
||||
#[test]
|
||||
fn a_rank_change_reads_as_a_sentence() {
|
||||
assert_eq!(
|
||||
format!("bob {} {} {} carol", "gives", rank_word(MemberRank::Op), "to"),
|
||||
"bob gives operator status to carol"
|
||||
);
|
||||
assert_eq!(
|
||||
format!("bob {} {} {} carol", "takes", rank_word(MemberRank::Voice), "from"),
|
||||
"bob takes voice from carol"
|
||||
);
|
||||
assert_eq!(rank_change_line("bob", true, MemberRank::Op, "carol"), "bob gives operator status to carol");
|
||||
assert_eq!(rank_change_line("bob", false, MemberRank::Voice, "carol"), "bob takes voice from carol");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -56,14 +56,6 @@ pub fn parse_keyvalue(args: &[String]) -> Option<(String, String, Option<String>
|
||||
Some((target, key, value))
|
||||
}
|
||||
|
||||
/// Whether a capability name is one of the two spellings of metadata.
|
||||
/// Only the tests reach this - the capability names are matched inline
|
||||
/// where they are negotiated.
|
||||
#[cfg(test)]
|
||||
pub fn is_metadata_cap(cap: &str) -> bool {
|
||||
matches!(cap, "draft/metadata" | "metadata-2")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -92,12 +84,4 @@ mod tests {
|
||||
assert_eq!(parse_keyvalue(&args(&["alice"])), None);
|
||||
assert_eq!(parse_keyvalue(&[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_spellings_of_the_capability_count() {
|
||||
assert!(is_metadata_cap("draft/metadata"));
|
||||
assert!(is_metadata_cap("metadata-2"));
|
||||
assert!(!is_metadata_cap("metadata"));
|
||||
assert!(!is_metadata_cap("away-notify"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ pub mod typing;
|
||||
pub mod dcc;
|
||||
pub mod nickserv;
|
||||
pub mod metadata;
|
||||
pub mod modes;
|
||||
pub mod sts;
|
||||
pub mod sasl;
|
||||
pub mod latesasl;
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! What a channel's modes mean, in words.
|
||||
//!
|
||||
//! A server answers `/mode #chan` with letters - `+Cnrt` - which a new IRC user cannot be expected to
|
||||
//! read. The channel line moho writes on joining explains them: "#archlinux is +Cnrt -- No CTCP messages
|
||||
//! to the channel, no messages from outside the channel, registered users only, topic locked to operators."
|
||||
//!
|
||||
//! The letters that mean the same everywhere (RFC 2811 and what every server inherited) are plain; a few
|
||||
//! that nearly every server agrees on are included; a letter a network invented for itself is named as
|
||||
//! one rather than guessed at, since a wrong explanation is worse than none.
|
||||
|
||||
/// One mode letter's meaning, given its argument if it takes one. `None` for a letter this does not know.
|
||||
fn meaning(letter: char, param: Option<&str>) -> Option<String> {
|
||||
let with = |what: &str| param.map(|p| format!("{what} {p}"));
|
||||
Some(match letter {
|
||||
'n' => "no messages from outside the channel".into(),
|
||||
't' => "topic locked to operators".into(),
|
||||
'm' => "moderated: only voiced people and operators can talk".into(),
|
||||
'i' => "invite only".into(),
|
||||
's' => "secret: left out of the channel list".into(),
|
||||
'p' => "private".into(),
|
||||
'r' => "registered users only".into(),
|
||||
'c' => "no colours or formatting".into(),
|
||||
'C' => "no CTCP messages to the channel".into(),
|
||||
'S' => "only people connected over TLS".into(),
|
||||
'g' => "anybody can invite".into(),
|
||||
'P' => "permanent: stays when it is empty".into(),
|
||||
'L' => "a larger ban list".into(),
|
||||
'k' => "needs a key to join".into(),
|
||||
'l' => match param {
|
||||
Some(p) => format!("limited to {p} people"),
|
||||
None => "limited in size".into(),
|
||||
},
|
||||
'f' => with("people who cannot join are sent to")?,
|
||||
'j' => with("joining is throttled to")?,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a mode letter takes an argument when it is set, for the ones this explains.
|
||||
fn takes_param(letter: char) -> bool {
|
||||
matches!(letter, 'k' | 'l' | 'f' | 'j')
|
||||
}
|
||||
|
||||
/// Explains a channel's modes: the string the server sent (`+Cnrt`, `+lk`) and the arguments after it.
|
||||
/// `None` when there is nothing to say (no modes set).
|
||||
pub fn describe(modes: &str, params: &[String]) -> Option<String> {
|
||||
let mut args = params.iter();
|
||||
let mut said: Vec<String> = Vec::new();
|
||||
let mut unknown: Vec<char> = Vec::new();
|
||||
let mut adding = true;
|
||||
for letter in modes.chars() {
|
||||
match letter {
|
||||
'+' => adding = true,
|
||||
'-' => adding = false,
|
||||
c if !adding || !c.is_ascii_alphabetic() => {
|
||||
let _ = c;
|
||||
}
|
||||
c => {
|
||||
let param = takes_param(c).then(|| args.next().map(String::as_str)).flatten();
|
||||
// A key is a secret: said that there is one, never what it is.
|
||||
let param = if c == 'k' { None } else { param };
|
||||
match meaning(c, param) {
|
||||
Some(text) => said.push(text),
|
||||
None => unknown.push(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !unknown.is_empty() {
|
||||
let list = unknown.iter().map(|c| format!("+{c}")).collect::<Vec<_>>().join(", ");
|
||||
said.push(format!("{list} {} this network's own", if unknown.len() == 1 { "is" } else { "are" }));
|
||||
}
|
||||
if said.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut text = said.join(", ");
|
||||
// A sentence: a capital to begin, a full stop to end.
|
||||
if let Some(first) = text.chars().next() {
|
||||
text.replace_range(..first.len_utf8(), &first.to_uppercase().to_string());
|
||||
}
|
||||
Some(format!("{text}."))
|
||||
}
|
||||
|
||||
/// What one mode being set or cleared means, for the line that says somebody changed it. `None` for a letter
|
||||
/// this does not know, where the line says no more than it did.
|
||||
pub fn describe_change(letter: char, adding: bool) -> Option<String> {
|
||||
let text = meaning(letter, None)?;
|
||||
if adding {
|
||||
let mut c = text.chars();
|
||||
return c.next().map(|f| format!("{}{}.", f.to_uppercase(), c.as_str()));
|
||||
}
|
||||
Some(format!("No longer in effect: {text}."))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn d(modes: &str, params: &[&str]) -> Option<String> {
|
||||
describe(modes, ¶ms.iter().map(|p| p.to_string()).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_letters_of_a_busy_channel_are_said_in_the_order_the_server_sent_them() {
|
||||
assert_eq!(
|
||||
d("+Cnrt", &[]).as_deref(),
|
||||
Some("No CTCP messages to the channel, no messages from outside the channel, registered users only, topic locked to operators.")
|
||||
);
|
||||
assert_eq!(d("+nt", &[]).as_deref(), Some("No messages from outside the channel, topic locked to operators."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_limit_says_its_number_and_a_key_is_never_told() {
|
||||
assert_eq!(d("+lntk", &["50", "hunter2"]).as_deref(), Some("Limited to 50 people, no messages from outside the channel, topic locked to operators, needs a key to join."));
|
||||
assert!(!d("+k", &["hunter2"]).unwrap().contains("hunter2"));
|
||||
assert_eq!(d("+f", &["#overflow"]).as_deref(), Some("People who cannot join are sent to #overflow."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_letter_nobody_agrees_on_is_named_and_not_guessed_at() {
|
||||
assert_eq!(d("+nQ", &[]).as_deref(), Some("No messages from outside the channel, +Q is this network's own."));
|
||||
assert_eq!(d("+QZ", &[]).as_deref(), Some("+Q, +Z are this network's own."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_change_says_what_it_did() {
|
||||
assert_eq!(describe_change('m', true).as_deref(), Some("Moderated: only voiced people and operators can talk."));
|
||||
assert_eq!(describe_change('t', false).as_deref(), Some("No longer in effect: topic locked to operators."));
|
||||
assert_eq!(describe_change('Q', true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_modes_say_nothing() {
|
||||
assert_eq!(d("+", &[]), None);
|
||||
assert_eq!(d("", &[]), None);
|
||||
}
|
||||
}
|
||||
@@ -420,6 +420,9 @@ pub(super) fn emit_presence(state: &AppState, account_id: &str, channel: &str, m
|
||||
// from away-notify where the network has it, and from a WHOIS or
|
||||
// a bounced message where it does not.
|
||||
"away": state.runtime.is_irc_away(account_id, nick),
|
||||
// What they said when they went, under their name: words they
|
||||
// wrote, so no picture.
|
||||
"activity": state.runtime.irc_away_reason(account_id, nick).map(|text| json!({ "kind": "custom", "text": text })),
|
||||
}))
|
||||
.collect();
|
||||
let member_list = json!(member_list);
|
||||
|
||||
+115
-1
@@ -83,6 +83,53 @@ pub struct Channel {
|
||||
pub playback_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Where a stream's video is, and until when that address is good.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Playback {
|
||||
/// A signed Amazon IVS playlist address: the signature is in the query, so it is fetched with no
|
||||
/// credential of ours.
|
||||
pub url: String,
|
||||
/// When the signature stops being accepted, in unix seconds, where the address says.
|
||||
pub expires: Option<i64>,
|
||||
/// Whether the signature opts out of ad insertion, which Kick's own web page is given.
|
||||
pub ads_opt_out: Option<bool>,
|
||||
}
|
||||
|
||||
impl Playback {
|
||||
pub fn of(url: &str) -> Playback {
|
||||
let claims = token_claims(url);
|
||||
Playback { url: url.to_string(), expires: claims.as_ref().and_then(|c| c["exp"].as_i64()), ads_opt_out: claims.as_ref().and_then(|c| c["aws:ads-opt-out"].as_bool()) }
|
||||
}
|
||||
}
|
||||
|
||||
/// What a signed playlist address says about itself: the claims of the token in its query.
|
||||
fn token_claims(url: &str) -> Option<serde_json::Value> {
|
||||
use base64::Engine;
|
||||
let token = url.split_once('?')?.1.split('&').find_map(|pair| pair.strip_prefix("token="))?;
|
||||
let claims = token.split('.').nth(1)?;
|
||||
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(claims.trim_end_matches('=')).ok()?;
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
}
|
||||
|
||||
/// A live channel's playlist address, asked for on its own.
|
||||
///
|
||||
/// The channel payload carries one too, but that is eight kilobytes about everything else and is kept
|
||||
/// by Kick for a while, so the address in it can be most of the way to its expiry. This answers with
|
||||
/// the address alone, signed fresh, for a tenth of the bytes; it is the call Kick's apps make. The
|
||||
/// signature lasts about ten minutes, which is what `Playback::expires` is for.
|
||||
pub async fn playback(http: &reqwest::Client, slug: &str) -> Result<Playback> {
|
||||
let res = paced(http.get(format!("{API_ROOT}/api/v2/channels/{slug}/playback-url")).header("Accept", "application/json"), Priority::Background)
|
||||
.await
|
||||
.context("asking Kick where the stream is")?;
|
||||
let status = res.status();
|
||||
if !status.is_success() {
|
||||
bail!("Kick answered {status} for the stream's address");
|
||||
}
|
||||
let body: serde_json::Value = res.json().await.context("unreadable answer")?;
|
||||
let url = body["data"].as_str().filter(|u| u.starts_with("https://")).context("Kick sent no address for the stream")?;
|
||||
Ok(Playback::of(url))
|
||||
}
|
||||
|
||||
/// A stream in progress, as the channel endpoint describes it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Live {
|
||||
@@ -855,8 +902,13 @@ pub struct HistoryMessage {
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
pub sender: HistorySender,
|
||||
/// A string of JSON here, not the object the live socket sends.
|
||||
#[serde(default)]
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
/// Where a reply's chain began. This endpoint has never sent it; the
|
||||
/// other history one does, and so does the socket.
|
||||
#[serde(default)]
|
||||
pub thread_parent_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
@@ -884,9 +936,12 @@ pub struct ChatIdentity {
|
||||
pub color: Option<String>,
|
||||
#[serde(default)]
|
||||
pub badges: Vec<Badge>,
|
||||
/// The pictures: level, event and founder badges, and a channel's own.
|
||||
#[serde(default)]
|
||||
pub badges_v2: Vec<BadgeV2>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, Serialize)]
|
||||
#[derive(Deserialize, Debug, Clone, Serialize, Default)]
|
||||
pub struct Badge {
|
||||
/// `moderator`, `subscriber`, `verified`, `og`, `founder`, `vip`...
|
||||
#[serde(rename = "type")]
|
||||
@@ -897,6 +952,38 @@ pub struct Badge {
|
||||
/// Months subscribed, for the badges that count.
|
||||
#[serde(default)]
|
||||
pub count: Option<u32>,
|
||||
/// A picture to draw it with, for the badges Kick sends as one: the level,
|
||||
/// a channel's or an event's own, the founder's. The role badges above
|
||||
/// have none and are drawn as words.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub image: Option<String>,
|
||||
/// Where Kick ranks it, highest first. Only used to put the badges in
|
||||
/// Kick's order, which the role and the picture badges share.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sort_order: Option<i32>,
|
||||
}
|
||||
|
||||
/// A badge in Kick's newer list, which says what it looks like where the old
|
||||
/// list names a role. `selected` is the person's own choice to wear it: a
|
||||
/// level badge comes on every message, and only some people show theirs.
|
||||
#[derive(Deserialize, Debug, Clone, Default)]
|
||||
pub struct BadgeV2 {
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub image_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub selected: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub sort_order: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub metadata: Option<BadgeV2Metadata>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, Default)]
|
||||
pub struct BadgeV2Metadata {
|
||||
#[serde(default)]
|
||||
pub level: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -2179,3 +2266,30 @@ pub async fn redeem(http: &reqwest::Client, token: &str, slug: &str, reward_id:
|
||||
None => bail!("Kick answered {status}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod playback_tests {
|
||||
use super::*;
|
||||
use base64::Engine;
|
||||
|
||||
fn address(claims: serde_json::Value) -> String {
|
||||
let part = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string());
|
||||
format!("https://example.live-video.net/api/video/v1/x.m3u8?token=eyJhbGciOiJFUzM4NCJ9.{part}.sig")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_address_says_when_it_expires_and_whether_it_opts_out_of_ads() {
|
||||
let p = Playback::of(&address(serde_json::json!({ "exp": 1791932879, "aws:ads-opt-out": true })));
|
||||
assert_eq!((p.expires, p.ads_opt_out), (Some(1791932879), Some(true)));
|
||||
let p = Playback::of(&address(serde_json::json!({ "exp": 5 })));
|
||||
assert_eq!((p.expires, p.ads_opt_out), (Some(5), None), "silence is not a refusal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_address_that_is_not_signed_the_way_it_expects_has_no_expiry_and_is_still_an_address() {
|
||||
for url in ["https://example.net/x.m3u8", "https://example.net/x.m3u8?other=1", "https://example.net/x.m3u8?token=notajwt", "https://example.net/x.m3u8?token=a.!!!.c"] {
|
||||
let p = Playback::of(url);
|
||||
assert_eq!((p.url.as_str(), p.expires), (url, None));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ pub async fn refresh_prediction(state: &AppState, buffer_id: &str) {
|
||||
}
|
||||
|
||||
/// Stores a card, writes it down, and tells the client - or says it is gone.
|
||||
pub(super) fn publish_card(state: &AppState, buffer_id: &str, kind: &str, card: Option<serde_json::Value>) {
|
||||
pub(crate) fn publish_card(state: &AppState, buffer_id: &str, kind: &str, card: Option<serde_json::Value>) {
|
||||
// When this was true, stamped here so every card carries one.
|
||||
//
|
||||
// The clock is the whole point: `remaining` is a number of seconds that
|
||||
@@ -263,7 +263,7 @@ pub fn card_is_current(card: &serde_json::Value) -> bool {
|
||||
}
|
||||
|
||||
/// The wall clock, in seconds, as everything here writes it down.
|
||||
pub(super) fn now_secs() -> i64 {
|
||||
pub(crate) fn now_secs() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
@@ -517,7 +517,7 @@ mod tests {
|
||||
assert!(matches!(id_in_subscription("chatrooms.53906513.v2"), Some(Subscribed::Chatroom(53906513))));
|
||||
}
|
||||
|
||||
/// A poll is one thing happening over a minute /// A poll is one thing happening over a minute, not a stream of events -
|
||||
/// A poll is one thing happening over a minute, not a stream of events -
|
||||
/// Kick sends its update on every vote, and a line each would bury the
|
||||
/// conversation the poll is about.
|
||||
#[test]
|
||||
|
||||
+754
-40
@@ -79,6 +79,8 @@ pub(super) struct Watched {
|
||||
/// wants the panel for: to mention them, whisper them, or see who the
|
||||
/// moderators are.
|
||||
pub(super) speakers: HashMap<String, Vec<Speaker>>,
|
||||
/// Every name that has spoken in each channel, for counting.
|
||||
pub(super) chatters: HashMap<String, std::collections::HashSet<String>>,
|
||||
}
|
||||
|
||||
/// Somebody who has said something, and how they looked saying it.
|
||||
@@ -91,12 +93,15 @@ pub(super) struct Speaker {
|
||||
pub(super) badge: Option<String>,
|
||||
}
|
||||
|
||||
/// How many to remember per channel.
|
||||
/// How many to list per channel.
|
||||
///
|
||||
/// A busy stream produces hundreds of distinct names an hour, and a list
|
||||
/// nobody can scan is no better than an empty one. This is the last two
|
||||
/// hundred to have spoken, which is the window somebody is actually reading.
|
||||
pub(super) const SPEAKERS_REMEMBERED: usize = 200;
|
||||
/// A busy stream produces thousands of distinct names an hour, and the list is
|
||||
/// sent whole every time a new one joins it and drawn row by row - a few
|
||||
/// hundred is what that stays cheap for. It is the most recent to have
|
||||
/// spoken, which is the window somebody is actually reading. How many people
|
||||
/// have spoken is counted apart from it (`Watched::chatters`) and is not cut
|
||||
/// off.
|
||||
pub(super) const SPEAKERS_REMEMBERED: usize = 500;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct ChatMessage {
|
||||
@@ -114,6 +119,10 @@ pub(super) struct ChatMessage {
|
||||
/// the cap. Kick's replies are quotable however old the original is.
|
||||
#[serde(default)]
|
||||
metadata: Option<ReplyMetadata>,
|
||||
/// The message at the top of the chain this one is part of, on a reply.
|
||||
/// Empty or absent on anything that is not one.
|
||||
#[serde(default)]
|
||||
thread_parent_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -129,6 +138,12 @@ pub(super) struct OriginalMessage {
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
content: Option<String>,
|
||||
/// Where the original itself sits: empty when it began the chain, and the
|
||||
/// chain's first message when it was a reply. It is how a reply read from
|
||||
/// history, which does not carry the thread on the reply itself, finds its
|
||||
/// thread.
|
||||
#[serde(default)]
|
||||
thread_parent_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -163,11 +178,7 @@ pub(super) fn handle_event(
|
||||
// Rebuilt so the arms below can keep reading `frame.channel`, which is
|
||||
// how a channel is identified for every event that does not carry an id
|
||||
// of its own.
|
||||
let frame = PusherFrame {
|
||||
event: event.to_string(),
|
||||
channel: channel.map(str::to_string),
|
||||
data: serde_json::Value::Null,
|
||||
};
|
||||
let frame = Delivery { channel: channel.map(str::to_string) };
|
||||
match event {
|
||||
"pusher:error" => {
|
||||
let msg = payload.get("message").and_then(|m| m.as_str()).unwrap_or("Kick refused the connection");
|
||||
@@ -177,7 +188,7 @@ pub(super) fn handle_event(
|
||||
let Ok(msg) = serde_json::from_value::<ChatMessage>(payload) else { return Ok(()) };
|
||||
let Some(slug) = watched.chatroom(msg.chatroom_id).cloned() else { return Ok(()) };
|
||||
let from = msg.sender.username.clone().unwrap_or_else(|| "someone".to_string());
|
||||
let reply_to = reply_preview(msg.metadata.as_ref());
|
||||
let reply_to = reply_preview(msg.metadata.as_ref(), msg.thread_parent_id.as_deref());
|
||||
let style = style_of(msg.sender.identity.as_ref());
|
||||
// Kick has no viewer list to ask for, so the panel is built from
|
||||
// who is talking - which is who somebody wants to reach anyway.
|
||||
@@ -192,8 +203,10 @@ pub(super) fn handle_event(
|
||||
) {
|
||||
let buffer_id = crate::model::buffer_id(account_id, &slug);
|
||||
let roster = serde_json::json!(watched.roster(&slug));
|
||||
let total = watched.chatters(&slug);
|
||||
state.runtime.set_presence(&buffer_id, roster.clone());
|
||||
state.events.emit("presenceChange", serde_json::json!({ "bufferId": buffer_id, "members": roster }));
|
||||
state.runtime.set_presence_total(&buffer_id, total);
|
||||
state.events.emit("presenceChange", serde_json::json!({ "bufferId": buffer_id, "members": roster, "total": total }));
|
||||
}
|
||||
state.runtime.record_message_at(
|
||||
state,
|
||||
@@ -227,7 +240,14 @@ pub(super) fn handle_event(
|
||||
e if e.ends_with("StreamerIsLive") || e.ends_with("StopStreamBroadcast") => {
|
||||
let Some(slug) = channel_of(watched, &frame.channel, &payload) else { return Ok(()) };
|
||||
let live = e.ends_with("StreamerIsLive");
|
||||
system_line(state, account_id, &slug, if live { "went live" } else { "ended the stream" }, "stream");
|
||||
let what = if live { "went live" } else { "ended the stream" };
|
||||
event_card(state, account_id, &slug, what, "stream", crate::model::Embed {
|
||||
kind: Some("stream".to_string()),
|
||||
author: Some(slug.clone()),
|
||||
title: Some(what.to_string()),
|
||||
icon: Some(if live { "live" } else { "offline" }.to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
// And the header, which is otherwise still describing the stream
|
||||
// that just ended. Asked of Kick rather than assembled from this
|
||||
// event: the title and the game arrive with the channel, not with
|
||||
@@ -241,7 +261,10 @@ pub(super) fn handle_event(
|
||||
// too, or moho shows a different chat from the one everybody else is
|
||||
// looking at - which on a stream where moderation is public is the
|
||||
// wrong way round entirely.
|
||||
e if e.ends_with("MessageDeletedEvent") => {
|
||||
// Not a pin coming down, which Kick calls `PinnedMessageDeletedEvent` and
|
||||
// which ends the same way: that is the pin's, below, and taking it for a
|
||||
// message deleted left a pin on screen for ever after it was removed.
|
||||
e if e.ends_with("MessageDeletedEvent") && !pins::is_pin_event(e) => {
|
||||
let Some(slug) = channel_of(watched, &frame.channel, &payload) else { return Ok(()) };
|
||||
// The id is nested under `message` on this event and flat on
|
||||
// others Kick has sent; both name the same thing.
|
||||
@@ -251,7 +274,10 @@ pub(super) fn handle_event(
|
||||
.or_else(|| payload.get("id"))
|
||||
.and_then(|v| v.as_str());
|
||||
if let Some(id) = id {
|
||||
state.runtime.delete_message(state, &crate::model::buffer_id(account_id, &slug), id);
|
||||
// Kept and marked, not removed: what it said is worth reading
|
||||
// beside the fact that it is gone, and how a removed message
|
||||
// is drawn is the window's to decide.
|
||||
state.runtime.moderate_message(state, &crate::model::buffer_id(account_id, &slug), id, &moderation_of(&payload));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,7 +386,12 @@ pub(super) fn handle_event(
|
||||
// A moderator emptying the room.
|
||||
e if e.ends_with("ChatroomClearEvent") => {
|
||||
if let Some(slug) = channel_of(watched, &frame.channel, &payload) {
|
||||
system_line(state, account_id, &slug, "a moderator cleared the chat", "moderation");
|
||||
event_card(state, account_id, &slug, "a moderator cleared the chat", "moderation", crate::model::Embed {
|
||||
kind: Some("moderation".to_string()),
|
||||
author: Some("A moderator".to_string()),
|
||||
title: Some("cleared the chat".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,7 +399,16 @@ pub(super) fn handle_event(
|
||||
e => {
|
||||
if let Some((kind, line)) = describe_action(e, &payload) {
|
||||
if let Some(slug) = channel_of(watched, &frame.channel, &payload) {
|
||||
system_line(state, account_id, &slug, &line, kind);
|
||||
// A KICKs gift or a gifted subscription is drawn as the
|
||||
// card it is, from what the line cannot hold: who it was
|
||||
// for, how much it was, what was said with it.
|
||||
match action_card(e, &payload) {
|
||||
Some(card) if !already_seen(&slug, &line, &payload) => {
|
||||
event_card(state, account_id, &slug, &line, kind, card)
|
||||
}
|
||||
Some(_) => {}
|
||||
None => system_line(state, account_id, &slug, &line, kind),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -417,7 +457,7 @@ pub async fn backfill(
|
||||
&msg.content,
|
||||
false,
|
||||
"chat",
|
||||
reply_preview_from_value(msg.metadata.as_ref()),
|
||||
reply_preview_from_value(msg.metadata.as_ref(), msg.thread_parent_id.as_deref()),
|
||||
Some(msg.id.clone()),
|
||||
false,
|
||||
None,
|
||||
@@ -436,9 +476,33 @@ pub async fn backfill(
|
||||
///
|
||||
/// History carries it in the same shape, so this is the one parser rather than
|
||||
/// a second one that could drift.
|
||||
pub(super) fn reply_preview_from_value(metadata: Option<&serde_json::Value>) -> Option<crate::model::ReplyPreview> {
|
||||
let parsed: ReplyMetadata = serde_json::from_value(metadata?.clone()).ok()?;
|
||||
reply_preview(Some(&parsed))
|
||||
///
|
||||
/// An object on the live socket and a string of JSON in history, which is
|
||||
/// the same object written down: a reply read back from history lost its quote
|
||||
/// because only the first was understood.
|
||||
pub(super) fn reply_preview_from_value(metadata: Option<&serde_json::Value>, thread_parent: Option<&str>) -> Option<crate::model::ReplyPreview> {
|
||||
let metadata = metadata?;
|
||||
let parsed: ReplyMetadata = match metadata {
|
||||
serde_json::Value::String(text) => serde_json::from_str(text).ok()?,
|
||||
other => serde_json::from_value(other.clone()).ok()?,
|
||||
};
|
||||
reply_preview(Some(&parsed), thread_parent)
|
||||
}
|
||||
|
||||
/// Who took a message down and for what, as the event says it.
|
||||
///
|
||||
/// `aiModerated` and `violatedRules` are on the payload of the event for a
|
||||
/// message Kick's automatic moderation removed; a person's removal has
|
||||
/// neither, which reads as not automatic with nothing to say about rules.
|
||||
pub(super) fn moderation_of(payload: &serde_json::Value) -> crate::model::Moderated {
|
||||
crate::model::Moderated {
|
||||
ai: payload.get("aiModerated").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
rules: payload
|
||||
.get("violatedRules")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|rules| rules.iter().filter_map(|r| r.as_str()).map(str::to_string).collect())
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The one badge worth marking somebody with in a list.
|
||||
@@ -463,32 +527,70 @@ pub(super) fn strongest_badge(badges: &[api::Badge]) -> Option<String> {
|
||||
/// the ones a reader is actually scanning for.
|
||||
pub(super) fn style_of(identity: Option<&api::ChatIdentity>) -> crate::model::SenderStyle {
|
||||
let Some(identity) = identity else { return crate::model::SenderStyle::default() };
|
||||
crate::model::SenderStyle {
|
||||
color: identity.color.clone().filter(|c| !c.is_empty()),
|
||||
badges: identity
|
||||
.badges
|
||||
// The role badges are drawn as words, and Kick's picture badges beside them
|
||||
// as pictures - the ones the person chose to wear, in Kick's order. The
|
||||
// old list's own "level" is dropped as it always was: the picture one says
|
||||
// the same with a number a reader can see.
|
||||
let mut badges: Vec<api::Badge> = identity.badges.iter().filter(|b| !matches!(b.kind.as_str(), "level" | "")).cloned().collect();
|
||||
let mut pictures: Vec<api::Badge> = identity
|
||||
.badges_v2
|
||||
.iter()
|
||||
.filter(|b| !matches!(b.kind.as_str(), "level" | ""))
|
||||
.cloned()
|
||||
.collect(),
|
||||
}
|
||||
.filter(|b| b.selected.unwrap_or(true) && !b.name.is_empty())
|
||||
.filter_map(|b| {
|
||||
let image = b.image_url.clone().filter(|u| u.starts_with("https://"))?;
|
||||
let level = b.metadata.as_ref().and_then(|m| m.level);
|
||||
Some(api::Badge {
|
||||
// Named by what Kick calls it, apart from the role names.
|
||||
kind: b.name.clone(),
|
||||
text: match level {
|
||||
Some(level) if b.name == "level" => format!("Level {level}"),
|
||||
_ => b.name.clone(),
|
||||
},
|
||||
count: None,
|
||||
image: Some(image),
|
||||
sort_order: b.sort_order,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// The highest-ranked of them, if there are more than a name can carry.
|
||||
pictures.sort_by_key(|b| std::cmp::Reverse(b.sort_order.unwrap_or(0)));
|
||||
pictures.truncate(MAX_BADGE_PICTURES);
|
||||
badges.extend(pictures);
|
||||
// Highest first, as Kick's own apps put them, and stable so two of a rank
|
||||
// stay in the order they came.
|
||||
badges.sort_by_key(|b| std::cmp::Reverse(b.sort_order.unwrap_or(0)));
|
||||
crate::model::SenderStyle { color: identity.color.clone().filter(|c| !c.is_empty()), badges }
|
||||
}
|
||||
|
||||
/// How many picture badges one name carries. Some people wear a dozen, and a
|
||||
/// name with that many beside it is the loudest thing on the line.
|
||||
const MAX_BADGE_PICTURES: usize = 4;
|
||||
|
||||
/// What a reply was replying to, out of the metadata Kick attaches to it.
|
||||
///
|
||||
/// Both halves are required rather than filled in with blanks: a preview with
|
||||
/// no author and no text is drawn as an empty quote above the message, which
|
||||
/// reads as something failing to load rather than as a reply. Better to show
|
||||
/// the message plainly than to show a hole above it.
|
||||
pub(super) fn reply_preview(metadata: Option<&ReplyMetadata>) -> Option<crate::model::ReplyPreview> {
|
||||
///
|
||||
/// `thread_parent` is what the message itself says its chain began with. When
|
||||
/// it says nothing - a reply out of history - the original's own place in its
|
||||
/// chain gives the same answer, and an original that began one is the root.
|
||||
pub(super) fn reply_preview(metadata: Option<&ReplyMetadata>, thread_parent: Option<&str>) -> Option<crate::model::ReplyPreview> {
|
||||
let metadata = metadata?;
|
||||
let original = metadata.original_message.as_ref()?;
|
||||
let from = metadata.original_sender.as_ref()?.username.clone()?;
|
||||
let filled = |s: Option<&str>| s.filter(|s| !s.is_empty()).map(str::to_string);
|
||||
let thread_root = filled(thread_parent).or_else(|| filled(original.thread_parent_id.as_deref())).unwrap_or_else(|| original.id.clone());
|
||||
Some(crate::model::ReplyPreview {
|
||||
id: original.id.clone(),
|
||||
from,
|
||||
body: original.content.clone().unwrap_or_default(),
|
||||
thread: false, forwarded: false })
|
||||
thread: false,
|
||||
forwarded: false,
|
||||
thread_root: Some(thread_root),
|
||||
chat: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// One line for something somebody did in a channel, or None if this event is
|
||||
@@ -557,10 +659,27 @@ pub(super) fn describe_action(event: &str, p: &serde_json::Value) -> Option<(&'s
|
||||
}))
|
||||
}
|
||||
|
||||
"StreamHostEvent" | "StreamHostedEvent" => {
|
||||
// KICKs sent to a streamer: Kick's own money, spent in the chat. The
|
||||
// payload is not documented; what is read is what its webhook form
|
||||
// (`kicks.gifted`) names, and an event with no sender or no amount is
|
||||
// skipped rather than drawn as "someone sent 0".
|
||||
"KicksGifted" | "KicksGiftedEvent" => {
|
||||
let (sender, amount) = kicks_of(p)?;
|
||||
Some(("kicks", format!("{sender} sent {amount} {}", if amount == 1 { "Kick" } else { "Kicks" })))
|
||||
}
|
||||
|
||||
// Kick sends one raid as two events on two subscriptions, measured
|
||||
// on a live channel: `StreamHostEvent`, with the host and how many
|
||||
// came, and `StreamHostedEvent`, the same raid with the viewer count
|
||||
// under another name. The first is the one believed; reading both
|
||||
// drew every raid twice, once without its number.
|
||||
"StreamHostedEvent" => None,
|
||||
|
||||
"StreamHostEvent" => {
|
||||
let who = text("host_username").or_else(who)?;
|
||||
Some(("raid", match p.get("number_viewers").and_then(|v| v.as_u64()) {
|
||||
Some(n) if n > 0 => format!("{who} raided with {n} viewers"),
|
||||
Some(1) => format!("{who} raided with 1 viewer"),
|
||||
Some(n) if n > 1 => format!("{who} raided with {n} viewers"),
|
||||
_ => format!("{who} raided the channel"),
|
||||
}))
|
||||
}
|
||||
@@ -597,6 +716,182 @@ pub(super) fn describe_action(event: &str, p: &serde_json::Value) -> Option<(&'s
|
||||
}
|
||||
}
|
||||
|
||||
/// Who sent KICKs and how many, from the payload wherever Kick puts them.
|
||||
fn kicks_of(p: &serde_json::Value) -> Option<(String, u64)> {
|
||||
let name = |v: &serde_json::Value| v.as_str().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
|
||||
let sender = p
|
||||
.get("sender")
|
||||
.and_then(|s| s.get("username"))
|
||||
.and_then(name)
|
||||
.or_else(|| p.get("username").and_then(name))
|
||||
.or_else(|| p.get("sender_username").and_then(name))
|
||||
.or_else(|| p.get("user").and_then(|u| u.get("username")).and_then(name))?;
|
||||
let number = |v: &serde_json::Value| v.as_u64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()));
|
||||
let amount = p
|
||||
.get("gift")
|
||||
.and_then(|g| g.get("amount"))
|
||||
.and_then(number)
|
||||
.or_else(|| p.get("amount").and_then(number))
|
||||
.or_else(|| p.get("kicks").and_then(number))
|
||||
.or_else(|| p.get("kicks_amount").and_then(number))
|
||||
.filter(|n| *n > 0)?;
|
||||
Some((sender, amount))
|
||||
}
|
||||
|
||||
/// The colour of a gift's tier, as the decimal RGB an embed carries. Kick's
|
||||
/// green for the ordinary kind; the others are told apart by hue, because
|
||||
/// what matters at a glance is that one is bigger than the rest.
|
||||
fn tier_colour(tier: &str) -> i64 {
|
||||
match tier.to_ascii_uppercase().as_str() {
|
||||
"MID" | "MEDIUM" | "STANDARD" => 0x2E9BFF,
|
||||
"HIGH" | "PREMIUM" | "RARE" => 0xB45CFF,
|
||||
"LEGENDARY" | "EPIC" | "ULTRA" => 0xFFA800,
|
||||
_ => 0x53FC18,
|
||||
}
|
||||
}
|
||||
|
||||
/// The card a KICKs gift or a gifted subscription is drawn as.
|
||||
///
|
||||
/// An embed, because that is the shape every frontend already has for "more
|
||||
/// than a line": the line stays as the text of the message - what a search, a
|
||||
/// copy or a frontend that draws no cards sees - and the card carries the parts.
|
||||
///
|
||||
/// - `kicks`: `author` sends, `title` is the amount, `provider` the gift's name
|
||||
/// ("Full Send"), `description` what was said with it, `color` its tier.
|
||||
/// - `gift-sub`: `author` gifts, `title` is how many, and `description` is who
|
||||
/// received them, one name to a line.
|
||||
/// - `raid`: `author` raids, `title` is how many came (absent when unsaid), and
|
||||
/// `description` what they said.
|
||||
/// - `subscribe`: `author` subscribes, `title` is how many months when more than one.
|
||||
/// - `moderation`: `author` is who it happened to, `title` what happened ("was banned",
|
||||
/// "was timed out", "is allowed back") and `description` who did it, where it says.
|
||||
/// - `stream`: `author` is the channel, `title` what it did, and `icon` is "live" or "offline".
|
||||
/// - `redeem`: `author` redeems, `title` is the reward, `description` what they
|
||||
/// typed with it, and `color` the streamer's colour for the reward.
|
||||
pub(super) fn action_card(event: &str, p: &serde_json::Value) -> Option<crate::model::Embed> {
|
||||
let name = event.rsplit('\\').next().unwrap_or(event);
|
||||
let text = |v: Option<&serde_json::Value>| v.and_then(|v| v.as_str()).map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
|
||||
match name {
|
||||
"KicksGifted" | "KicksGiftedEvent" => {
|
||||
let (sender, amount) = kicks_of(p)?;
|
||||
let gift = p.get("gift");
|
||||
let tier = text(gift.and_then(|g| g.get("tier"))).or_else(|| text(gift.and_then(|g| g.get("type"))));
|
||||
Some(crate::model::Embed {
|
||||
kind: Some("kicks".to_string()),
|
||||
author: Some(sender),
|
||||
author_color: text(p.get("sender").and_then(|s| s.get("username_color"))),
|
||||
title: Some(amount.to_string()),
|
||||
provider: text(gift.and_then(|g| g.get("name"))),
|
||||
description: text(gift.and_then(|g| g.get("message"))).or_else(|| text(p.get("message"))),
|
||||
color: Some(tier.as_deref().map_or(0x53FC18, tier_colour)),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
"SubscriptionEvent" => {
|
||||
let who = text(p.get("username"))
|
||||
.or_else(|| text(p.get("sender").and_then(|s| s.get("username"))))
|
||||
.or_else(|| text(p.get("user").and_then(|s| s.get("username"))))?;
|
||||
Some(crate::model::Embed {
|
||||
kind: Some("subscribe".to_string()),
|
||||
author: Some(who),
|
||||
// Months, where they are more than the first one.
|
||||
title: p.get("months").and_then(|v| v.as_u64()).filter(|n| *n > 1).map(|n| n.to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
"UserBannedEvent" => {
|
||||
let who = text(p.get("user").and_then(|u| u.get("username")))?;
|
||||
Some(crate::model::Embed {
|
||||
kind: Some("moderation".to_string()),
|
||||
author: Some(who),
|
||||
title: Some(if p.get("expires_at").and_then(|v| v.as_str()).is_some() { "was timed out" } else { "was banned" }.to_string()),
|
||||
description: text(p.get("banned_by").and_then(|u| u.get("username"))),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
"UserUnbannedEvent" => {
|
||||
let who = text(p.get("user").and_then(|u| u.get("username")))?;
|
||||
Some(crate::model::Embed {
|
||||
kind: Some("moderation".to_string()),
|
||||
author: Some(who),
|
||||
title: Some("is allowed back".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
"StreamHostEvent" => {
|
||||
let host = text(p.get("host_username")).or_else(|| text(p.get("username")))?;
|
||||
Some(crate::model::Embed {
|
||||
kind: Some("raid".to_string()),
|
||||
author: Some(host),
|
||||
title: p.get("number_viewers").and_then(|v| v.as_u64()).filter(|n| *n > 0).map(|n| n.to_string()),
|
||||
description: text(p.get("optional_message")),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
"RewardRedeemedEvent" => {
|
||||
let who = text(p.get("username"))?;
|
||||
let reward = text(p.get("reward_title")).or_else(|| text(p.get("title")))?;
|
||||
Some(crate::model::Embed {
|
||||
kind: Some("redeem".to_string()),
|
||||
author: Some(who),
|
||||
title: Some(reward),
|
||||
description: text(p.get("user_input")),
|
||||
// The streamer's own colour for the reward, which Kick draws
|
||||
// the card's bar and the name in.
|
||||
color: text(p.get("reward_background_color"))
|
||||
.and_then(|c| i64::from_str_radix(c.trim_start_matches('#'), 16).ok()),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
"GiftedSubscriptionsEvent" => {
|
||||
let gifter = text(p.get("gifter_username")).or_else(|| text(p.get("username")))?;
|
||||
let names: Vec<String> = p
|
||||
.get("gifted_usernames")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.iter().filter_map(|n| text(Some(n))).collect())
|
||||
.unwrap_or_default();
|
||||
let count = if names.is_empty() {
|
||||
p.get("gifted_total").and_then(|v| v.as_u64()).unwrap_or(1).max(1)
|
||||
} else {
|
||||
names.len() as u64
|
||||
};
|
||||
Some(crate::model::Embed {
|
||||
kind: Some("gift-sub".to_string()),
|
||||
author: Some(gifter),
|
||||
title: Some(count.to_string()),
|
||||
description: (!names.is_empty()).then(|| names.join("\n")),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Kick sends an event more than once when it reaches more than one of a
|
||||
/// channel's subscriptions, so the same gift is recognised for a few seconds
|
||||
/// rather than drawn twice.
|
||||
fn already_seen(slug: &str, line: &str, p: &serde_json::Value) -> bool {
|
||||
static RECENT: std::sync::LazyLock<std::sync::Mutex<HashMap<String, std::time::Instant>>> =
|
||||
std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new()));
|
||||
let now = std::time::Instant::now();
|
||||
let key = format!("{slug}\u{0}{line}\u{0}{}", p.get("gift_transaction_id").or_else(|| p.get("created_at")).or_else(|| p.get("message")).map(|v| v.to_string()).unwrap_or_default());
|
||||
let mut recent = RECENT.lock().unwrap();
|
||||
recent.retain(|_, at| now.duration_since(*at) < Duration::from_secs(10));
|
||||
if recent.contains_key(&key) {
|
||||
return true;
|
||||
}
|
||||
recent.insert(key, now);
|
||||
false
|
||||
}
|
||||
|
||||
/// A line that carries a card.
|
||||
pub(super) fn event_card(state: &AppState, account_id: &str, slug: &str, what: &str, kind: &str, card: crate::model::Embed) {
|
||||
state.runtime.record_message(
|
||||
state, account_id, slug, "channel", slug, what, false, kind, None, None, false, None,
|
||||
vec![card], Vec::new(), None,
|
||||
);
|
||||
}
|
||||
|
||||
/// A line that nobody said.
|
||||
///
|
||||
/// `kind` is what separates "the stream started" from "somebody redeemed
|
||||
@@ -695,14 +990,19 @@ mod tests {
|
||||
w.heard("chan", Speaker { nick: format!("nick{i}"), user_id: None, badge: None });
|
||||
}
|
||||
assert_eq!(w.roster("chan").len(), SPEAKERS_REMEMBERED);
|
||||
// The count is of everybody, not of those listed.
|
||||
assert_eq!(w.chatters("chan"), SPEAKERS_REMEMBERED + 50);
|
||||
// The most recent survive; a list nobody can scan is no better than
|
||||
// an empty one, and the newest names are the ones being read.
|
||||
assert_eq!(w.roster("chan")[0]["nick"], format!("nick{}", SPEAKERS_REMEMBERED + 49));
|
||||
// And somebody who spoke long ago and speaks again is not counted twice.
|
||||
w.heard("chan", Speaker { nick: "nick0".into(), user_id: None, badge: None });
|
||||
assert_eq!(w.chatters("chan"), SPEAKERS_REMEMBERED + 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marks_somebody_by_the_badge_worth_finding_them_by() {
|
||||
let badge = |kind: &str| api::Badge { kind: kind.into(), text: kind.into(), count: None };
|
||||
let badge = |kind: &str| api::Badge { kind: kind.into(), text: kind.into(), ..Default::default() };
|
||||
// Ranked by what a reader is scanning for, not by what is rarest: a
|
||||
// moderator who is also a subscriber shows as a moderator.
|
||||
assert_eq!(strongest_badge(&[badge("subscriber"), badge("moderator")]).as_deref(), Some("moderator"));
|
||||
@@ -717,6 +1017,7 @@ mod tests {
|
||||
fn closing_a_channel_forgets_who_was_talking_in_it() {
|
||||
let mut w = watching_odablock();
|
||||
w.heard("odablock", Speaker { nick: "someone".into(), user_id: None, badge: None });
|
||||
assert_eq!(w.roster("odablock").len(), 1, "somebody was heard to begin with");
|
||||
w.remove("odablock");
|
||||
assert!(w.roster("odablock").is_empty());
|
||||
}
|
||||
@@ -732,7 +1033,7 @@ mod tests {
|
||||
"original_message": { "id": "ddeccb11-29bc-4cf1-ab2a-9b409293de76", "content": "L0L0WERFLGEWRUGHBER" }
|
||||
}))
|
||||
.unwrap();
|
||||
let preview = reply_preview(Some(&metadata)).unwrap();
|
||||
let preview = reply_preview(Some(&metadata), None).unwrap();
|
||||
assert_eq!(preview.from, "Greg");
|
||||
assert_eq!(preview.id, "ddeccb11-29bc-4cf1-ab2a-9b409293de76");
|
||||
assert_eq!(preview.body, "L0L0WERFLGEWRUGHBER");
|
||||
@@ -764,7 +1065,7 @@ mod tests {
|
||||
let msg: ChatMessage = serde_json::from_str(raw).expect("a real reply frame must parse");
|
||||
assert_eq!(msg.chatroom_id, 2393554);
|
||||
assert_eq!(msg.sender.username.as_deref(), Some("L3galizeWee"));
|
||||
let preview = reply_preview(msg.metadata.as_ref()).expect("and carry what it answered");
|
||||
let preview = reply_preview(msg.metadata.as_ref(), None).expect("and carry what it answered");
|
||||
assert_eq!(preview.from, "Greg");
|
||||
assert_eq!(preview.body, "L0L0WERFLGEWRUGHBER");
|
||||
}
|
||||
@@ -776,12 +1077,12 @@ mod tests {
|
||||
let raw = r#"{ "id": "x", "chatroom_id": 1, "content": "hi", "type": "message",
|
||||
"sender": { "id": 2, "username": "someone" }, "metadata": { "message_ref": "1788305786864" } }"#;
|
||||
let msg: ChatMessage = serde_json::from_str(raw).unwrap();
|
||||
assert!(reply_preview(msg.metadata.as_ref()).is_none());
|
||||
assert!(reply_preview(msg.metadata.as_ref(), None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ordinary_message_is_not_a_reply() {
|
||||
assert!(reply_preview(None).is_none());
|
||||
assert!(reply_preview(None, None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -790,10 +1091,10 @@ mod tests {
|
||||
// which is worse than showing the message plainly.
|
||||
let no_sender: ReplyMetadata =
|
||||
serde_json::from_value(serde_json::json!({ "original_message": { "id": "x", "content": "y" } })).unwrap();
|
||||
assert!(reply_preview(Some(&no_sender)).is_none());
|
||||
assert!(reply_preview(Some(&no_sender), None).is_none());
|
||||
let no_message: ReplyMetadata =
|
||||
serde_json::from_value(serde_json::json!({ "original_sender": { "username": "Greg" } })).unwrap();
|
||||
assert!(reply_preview(Some(&no_message)).is_none());
|
||||
assert!(reply_preview(Some(&no_message), None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -916,4 +1217,417 @@ mod tests {
|
||||
Some("ItsDez gifted 5 subscriptions")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gifted_subscription_keeps_who_received_it() {
|
||||
let p = serde_json::json!({
|
||||
"chatroom_id": 2393554,
|
||||
"gifted_usernames": ["Interstelar", "Ron_OSRS", "Auzzzii"],
|
||||
"gifter_username": "ItsDez",
|
||||
"gifted_total": 3
|
||||
});
|
||||
let card = action_card("GiftedSubscriptionsEvent", &p).unwrap();
|
||||
assert_eq!(card.kind.as_deref(), Some("gift-sub"));
|
||||
assert_eq!(card.author.as_deref(), Some("ItsDez"));
|
||||
assert_eq!(card.title.as_deref(), Some("3"));
|
||||
assert_eq!(card.description.as_deref(), Some("Interstelar\nRon_OSRS\nAuzzzii"));
|
||||
// One with no names - the count alone - is still a card.
|
||||
let bare = action_card("GiftedSubscriptionsEvent", &serde_json::json!({ "gifter_username": "a", "gifted_total": 10 })).unwrap();
|
||||
assert_eq!(bare.title.as_deref(), Some("10"));
|
||||
assert_eq!(bare.description, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kicks_are_read_wherever_kick_puts_them() {
|
||||
let nested = serde_json::json!({
|
||||
"sender": { "id": 7, "username": "Currington" },
|
||||
"gift": { "amount": 100, "name": "Full Send", "tier": "BASIC", "message": "lets go" }
|
||||
});
|
||||
assert_eq!(describe_action("KicksGifted", &nested), Some(("kicks", "Currington sent 100 Kicks".to_string())));
|
||||
let card = action_card("App\\Events\\KicksGifted", &nested).unwrap();
|
||||
assert_eq!(card.kind.as_deref(), Some("kicks"));
|
||||
assert_eq!(card.author.as_deref(), Some("Currington"));
|
||||
assert_eq!(card.title.as_deref(), Some("100"));
|
||||
assert_eq!(card.provider.as_deref(), Some("Full Send"));
|
||||
assert_eq!(card.description.as_deref(), Some("lets go"));
|
||||
assert_eq!(card.color, Some(0x53FC18));
|
||||
// One Kick is singular, and a flat shape reads the same.
|
||||
let flat = serde_json::json!({ "username": "a", "amount": "1", "message": "hi" });
|
||||
assert_eq!(describe_action("KicksGifted", &flat).map(|(_, l)| l).as_deref(), Some("a sent 1 Kick"));
|
||||
assert_eq!(action_card("KicksGifted", &flat).unwrap().description.as_deref(), Some("hi"));
|
||||
// Nobody, or nothing, is not a gift.
|
||||
assert!(describe_action("KicksGifted", &serde_json::json!({ "gift": { "amount": 5 } })).is_none());
|
||||
assert!(describe_action("KicksGifted", &serde_json::json!({ "sender": { "username": "a" }, "gift": { "amount": 0 } })).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_a_real_kicks_payload() {
|
||||
// Copied from a live channel's `channel_<id>` subscription.
|
||||
let p = serde_json::json!({
|
||||
"gift_transaction_id": "2bd6023b-020b-4c91-a47b-e43eff66656f",
|
||||
"sender": { "id": 93750218, "username": "BlestRS", "username_color": "#FF2C56", "profile_picture": "https://kick.com/img/x.webp" },
|
||||
"gift": { "gift_id": "full_send", "name": "Full Send", "amount": 100, "type": "BASIC", "tier": "BASIC", "character_limit": 0, "pinned_time": 0 },
|
||||
"created_at": "2026-10-11T00:42:57.551767966Z"
|
||||
});
|
||||
assert_eq!(describe_action("KicksGifted", &p).map(|(_, l)| l).as_deref(), Some("BlestRS sent 100 Kicks"));
|
||||
let card = action_card("KicksGifted", &p).unwrap();
|
||||
assert_eq!(card.author.as_deref(), Some("BlestRS"));
|
||||
assert_eq!(card.author_color.as_deref(), Some("#FF2C56"));
|
||||
assert_eq!(card.provider.as_deref(), Some("Full Send"));
|
||||
assert_eq!(card.description, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gift_arriving_twice_is_drawn_once() {
|
||||
let p = serde_json::json!({ "created_at": "2026-10-10T20:00:00Z" });
|
||||
assert!(!already_seen("dupe-channel", "a sent 5 Kicks", &p));
|
||||
assert!(already_seen("dupe-channel", "a sent 5 Kicks", &p));
|
||||
assert!(!already_seen("dupe-channel", "a sent 6 Kicks", &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raid_is_one_card_and_a_redeem_keeps_its_colour() {
|
||||
let raid = serde_json::json!({ "chatroom_id": 1, "optional_message": null, "number_viewers": 25, "host_username": "kreationX" });
|
||||
let card = action_card("App\\Events\\StreamHostEvent", &raid).unwrap();
|
||||
assert_eq!((card.kind.as_deref(), card.author.as_deref(), card.title.as_deref()), (Some("raid"), Some("kreationX"), Some("25")));
|
||||
// The same raid, said again on another subscription, is not another line.
|
||||
let again = serde_json::json!({ "message": { "numberOfViewers": 25 }, "user": { "username": "kreationX" } });
|
||||
assert!(describe_action("App\\Events\\StreamHostedEvent", &again).is_none());
|
||||
|
||||
let redeem = serde_json::json!({ "reward_title": "5 TONGUE TWISTERS", "user_id": 1, "username": "onkhell", "user_input": "", "reward_background_color": "#FF55B3" });
|
||||
let card = action_card("RewardRedeemedEvent", &redeem).unwrap();
|
||||
assert_eq!((card.kind.as_deref(), card.author.as_deref(), card.title.as_deref()), (Some("redeem"), Some("onkhell"), Some("5 TONGUE TWISTERS")));
|
||||
assert_eq!(card.color, Some(0xFF55B3));
|
||||
assert_eq!(card.description, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kicks_and_gifted_subs_land_in_the_store_as_cards() {
|
||||
let state = testkit::simulated_daemon("cards");
|
||||
let mut watched = testkit::watching_odablock();
|
||||
// On the subscription they were measured arriving on.
|
||||
let kicks = serde_json::json!({
|
||||
"gift_transaction_id": "t-1",
|
||||
"sender": { "id": 1, "username": "BlestRS", "username_color": "#FF2C56" },
|
||||
"gift": { "gift_id": "full_send", "name": "Full Send", "amount": 100, "type": "BASIC", "tier": "BASIC" },
|
||||
"created_at": "2026-10-11T00:42:57.551767966Z"
|
||||
});
|
||||
for _ in 0..2 {
|
||||
handle_event(&state, "kick:tester", &mut watched, "KicksGifted", Some("channel_2401072"), kicks.clone()).unwrap();
|
||||
}
|
||||
let gift = serde_json::json!({ "chatroom_id": 2393554, "gifted_usernames": ["a", "b"], "gifter_username": "kufallen", "gifted_total": 2 });
|
||||
handle_event(&state, "kick:tester", &mut watched, "GiftedSubscriptionsEvent", Some("chatroom_2393554"), gift).unwrap();
|
||||
|
||||
let rows = state.store.get_backlog(&crate::model::buffer_id("kick:tester", "odablock"), 0, 50).unwrap();
|
||||
// The same gift on two subscriptions is one row.
|
||||
assert_eq!(rows.len(), 2, "{:?}", rows.iter().map(|m| &m.body).collect::<Vec<_>>());
|
||||
assert_eq!((rows[0].kind.as_str(), rows[0].body.as_str()), ("kicks", "BlestRS sent 100 Kicks"));
|
||||
assert_eq!(rows[0].embeds[0].kind.as_deref(), Some("kicks"));
|
||||
assert_eq!(rows[0].embeds[0].author_color.as_deref(), Some("#FF2C56"));
|
||||
assert_eq!((rows[1].kind.as_str(), rows[1].body.as_str()), ("sub", "kufallen gifted 2 subscriptions"));
|
||||
assert_eq!(rows[1].embeds[0].description.as_deref(), Some("a\nb"));
|
||||
}
|
||||
|
||||
fn spoken(state: &AppState, watched: &mut Watched, id: &str, text: &str) {
|
||||
feed(state, watched, "App\\Events\\ChatMessageEvent", serde_json::json!({
|
||||
"id": id, "chatroom_id": 2393554, "content": text, "sender": { "id": 9, "username": "bob", "identity": null }
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn going_live_and_ending_are_said_in_the_channel() {
|
||||
let state = testkit::simulated_daemon("live-lines");
|
||||
let mut watched = testkit::watching_odablock();
|
||||
feed(&state, &mut watched, "App\\Events\\StreamerIsLive", serde_json::json!({}));
|
||||
feed(&state, &mut watched, "App\\Events\\StopStreamBroadcast", serde_json::json!({}));
|
||||
assert_eq!(testkit::lines(&state, "odablock"), vec!["went live", "ended the stream"]);
|
||||
}
|
||||
|
||||
fn backlog(state: &AppState) -> Vec<crate::model::Message> {
|
||||
state.store.get_backlog(&crate::model::buffer_id("kick:tester", "odablock"), 0, 50).expect("backlog")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_message_taken_down_stays_in_the_log_marked() {
|
||||
let state = testkit::simulated_daemon("deleted");
|
||||
let mut watched = testkit::watching_odablock();
|
||||
spoken(&state, &mut watched, "keep", "stays");
|
||||
spoken(&state, &mut watched, "nested", "goes");
|
||||
spoken(&state, &mut watched, "flat", "goes too");
|
||||
// Kick nests the id under `message` on one event and sends it flat on another.
|
||||
feed(&state, &mut watched, "App\\Events\\MessageDeletedEvent", serde_json::json!({ "message": { "id": "nested" } }));
|
||||
feed(&state, &mut watched, "App\\Events\\MessageDeletedEvent", serde_json::json!({ "id": "flat" }));
|
||||
let log = backlog(&state);
|
||||
// Nothing left, and the words of the ones taken down are still there to read.
|
||||
assert_eq!(log.iter().map(|m| m.body.as_str()).collect::<Vec<_>>(), vec!["stays", "goes", "goes too"]);
|
||||
assert_eq!(log.iter().map(|m| m.moderated.is_some()).collect::<Vec<_>>(), vec![false, true, true]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_the_automatic_moderator_said_is_kept_with_the_message() {
|
||||
let state = testkit::simulated_daemon("moderated");
|
||||
let mut watched = testkit::watching_odablock();
|
||||
spoken(&state, &mut watched, "bad", "something vile");
|
||||
// As measured on a live channel: the event has its own id, and the message's is nested.
|
||||
feed(
|
||||
&state,
|
||||
&mut watched,
|
||||
"App\\Events\\MessageDeletedEvent",
|
||||
serde_json::json!({ "id": "event-id", "message": { "id": "bad" }, "aiModerated": true, "violatedRules": ["hate"] }),
|
||||
);
|
||||
let log = backlog(&state);
|
||||
assert_eq!(log.len(), 1);
|
||||
assert_eq!(log[0].moderated, Some(crate::model::Moderated { ai: true, rules: vec!["hate".into()] }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_person_removing_a_message_is_not_the_automatic_moderator() {
|
||||
let why = moderation_of(&serde_json::json!({ "message": { "id": "x" } }));
|
||||
assert_eq!(why, crate::model::Moderated { ai: false, rules: vec![] });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_names_the_thread_it_belongs_to() {
|
||||
// The top-level field when there is one: this is a reply to a reply, so what it quotes and
|
||||
// the thread it is in are different messages.
|
||||
let metadata: ReplyMetadata = serde_json::from_value(serde_json::json!({
|
||||
"original_sender": { "id": 1, "username": "Greg" },
|
||||
"original_message": { "id": "middle", "content": "hello" }
|
||||
}))
|
||||
.unwrap();
|
||||
let preview = reply_preview(Some(&metadata), Some("top")).unwrap();
|
||||
assert_eq!((preview.id.as_str(), preview.thread_root.as_deref()), ("middle", Some("top")));
|
||||
// Without it, a reply to a message that began nothing starts the thread itself.
|
||||
let preview = reply_preview(Some(&metadata), Some("")).unwrap();
|
||||
assert_eq!(preview.thread_root.as_deref(), Some("middle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_out_of_history_finds_its_thread_in_what_it_quoted() {
|
||||
// History writes the metadata down as a string and leaves the thread off the reply; the
|
||||
// quoted message knows its own.
|
||||
let written = serde_json::Value::String(
|
||||
serde_json::json!({
|
||||
"original_sender": { "id": 1, "username": "Greg" },
|
||||
"original_message": { "id": "middle", "content": "hello", "thread_parent_id": "top" }
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
let preview = reply_preview_from_value(Some(&written), None).expect("a string of JSON is the same metadata");
|
||||
assert_eq!((preview.id.as_str(), preview.from.as_str(), preview.thread_root.as_deref()), ("middle", "Greg", Some("top")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replies_are_stored_with_their_thread_and_read_back_whole() {
|
||||
let state = testkit::simulated_daemon("threads");
|
||||
let mut watched = testkit::watching_odablock();
|
||||
feed(&state, &mut watched, "App\\Events\\ChatMessageEvent", serde_json::json!({
|
||||
"id": "top", "chatroom_id": 2393554, "content": "the question", "created_at": "2026-10-14T00:59:00+00:00",
|
||||
"sender": { "id": 9, "username": "bob", "identity": null }
|
||||
}));
|
||||
for (id, to, root) in [("r1", "top", "top"), ("r2", "r1", "top"), ("elsewhere", "x", "x")] {
|
||||
feed(
|
||||
&state,
|
||||
&mut watched,
|
||||
"App\\Events\\ChatMessageEvent",
|
||||
serde_json::json!({
|
||||
"id": id, "chatroom_id": 2393554, "content": id, "type": "reply",
|
||||
"created_at": "2026-10-14T01:00:00+00:00",
|
||||
"sender": { "id": 7, "username": "someone" },
|
||||
"thread_parent_id": root,
|
||||
"metadata": { "original_sender": { "id": 1, "username": "Greg" }, "original_message": { "id": to, "content": "quoted" } }
|
||||
}),
|
||||
);
|
||||
}
|
||||
let buffer = crate::model::buffer_id("kick:tester", "odablock");
|
||||
let thread = state.store.thread_messages(&buffer, "top").unwrap();
|
||||
assert_eq!(thread.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(), vec!["top", "r1", "r2"]);
|
||||
let r2 = thread.iter().find(|m| m.id == "r2").unwrap().reply_to.clone().unwrap();
|
||||
// It still quotes what it answered, and says which thread that is part of.
|
||||
assert_eq!((r2.id.as_str(), r2.thread, r2.thread_root.as_deref()), ("r1", false, Some("top")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_moderator_emptying_the_room_is_said() {
|
||||
let state = testkit::simulated_daemon("cleared");
|
||||
let mut watched = testkit::watching_odablock();
|
||||
feed(&state, &mut watched, "App\\Events\\ChatroomClearEvent", serde_json::json!({}));
|
||||
assert_eq!(testkit::lines(&state, "odablock"), vec!["a moderator cleared the chat"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_chat_rules_changing_are_kept_for_the_next_message_to_be_explained_by() {
|
||||
let state = testkit::simulated_daemon("chat-modes");
|
||||
let mut watched = testkit::watching_odablock();
|
||||
let buffer = crate::model::buffer_id("kick:tester", "odablock");
|
||||
state.runtime.set_kick_channel(&buffer, crate::runtime::KickChannel {
|
||||
slug: "odablock".into(), channel_id: 2401072, history_cursor: None, chatroom_id: 2393554, subscribed: false,
|
||||
followers_only: false, subscribers_only: false, slow_seconds: None, emotes: Vec::new(),
|
||||
});
|
||||
feed(&state, &mut watched, "App\\Events\\ChatroomUpdatedEvent", serde_json::json!({
|
||||
"followers_mode": { "enabled": true },
|
||||
"subscribers_mode": { "enabled": false },
|
||||
"slow_mode": { "enabled": true, "message_interval": 30 }
|
||||
}));
|
||||
let now = state.runtime.kick_channel(&buffer).unwrap();
|
||||
assert_eq!((now.followers_only, now.subscribers_only, now.slow_seconds), (true, false, Some(30)));
|
||||
// A slow mode that is switched off has no interval, whatever it still says.
|
||||
feed(&state, &mut watched, "App\\Events\\ChatroomUpdatedEvent", serde_json::json!({
|
||||
"subscribers_mode": { "enabled": true }, "slow_mode": { "enabled": false, "message_interval": 30 }
|
||||
}));
|
||||
let now = state.runtime.kick_channel(&buffer).unwrap();
|
||||
assert_eq!((now.followers_only, now.subscribers_only, now.slow_seconds), (false, true, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pin_and_its_removal_reach_the_channel() {
|
||||
let state = testkit::simulated_daemon("pins");
|
||||
let mut watched = testkit::watching_odablock();
|
||||
let buffer = crate::model::buffer_id("kick:tester", "odablock");
|
||||
feed(&state, &mut watched, "App\\Events\\PinnedMessageCreatedEvent", serde_json::json!({
|
||||
"message": { "id": "p1", "content": "read the rules", "sender": { "username": "mod" } }
|
||||
}));
|
||||
assert_eq!(state.runtime.kick_pin(&buffer).map(|p| p.body), Some("read the rules".to_string()));
|
||||
// An event about a pin that says nothing readable leaves the pin up.
|
||||
feed(&state, &mut watched, "App\\Events\\PinnedMessageCreatedEvent", serde_json::json!({}));
|
||||
assert!(state.runtime.kick_pin(&buffer).is_some());
|
||||
feed(&state, &mut watched, "App\\Events\\PinnedMessageDeletedEvent", serde_json::json!({}));
|
||||
assert!(state.runtime.kick_pin(&buffer).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gifts_tier_is_a_colour_and_an_ordinary_one_is_green() {
|
||||
assert_eq!(tier_colour("BASIC"), 0x53FC18);
|
||||
assert_eq!(tier_colour("anything else"), 0x53FC18);
|
||||
assert_eq!(tier_colour("mid"), 0x2E9BFF);
|
||||
assert_eq!(tier_colour("PREMIUM"), 0xB45CFF);
|
||||
assert_eq!(tier_colour("Legendary"), 0xFFA800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raid_with_nobody_in_it_says_only_that_it_came() {
|
||||
let none = serde_json::json!({ "host_username": "friend", "number_viewers": 0 });
|
||||
assert_eq!(describe_action("StreamHostEvent", &none).map(|(_, l)| l).as_deref(), Some("friend raided the channel"));
|
||||
let one = serde_json::json!({ "host_username": "friend", "number_viewers": 1 });
|
||||
assert_eq!(describe_action("StreamHostEvent", &one).map(|(_, l)| l).as_deref(), Some("friend raided with 1 viewer"));
|
||||
assert_eq!(action_card("StreamHostEvent", &none).unwrap().title, None, "no count to put on the card");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sender_is_drawn_in_their_colour_with_the_badges_that_say_something() {
|
||||
let badge = |kind: &str| api::Badge { kind: kind.into(), text: kind.into(), ..Default::default() };
|
||||
assert_eq!(style_of(None).color, None);
|
||||
let style = style_of(Some(&api::ChatIdentity {
|
||||
color: Some("#75FD46".into()),
|
||||
badges: vec![badge("moderator"), badge("level"), badge(""), badge("subscriber")],
|
||||
..Default::default()
|
||||
}));
|
||||
assert_eq!(style.color.as_deref(), Some("#75FD46"));
|
||||
// An engagement level and a nameless badge say nothing about the person.
|
||||
assert_eq!(style.badges.iter().map(|b| b.kind.as_str()).collect::<Vec<_>>(), vec!["moderator", "subscriber"]);
|
||||
// A colour that is empty is no colour.
|
||||
assert_eq!(style_of(Some(&api::ChatIdentity { color: Some(String::new()), ..Default::default() })).color, None);
|
||||
}
|
||||
|
||||
/// As measured on a live channel: the picture badges come in their own list,
|
||||
/// each with the person's choice to wear it and where Kick ranks it.
|
||||
#[test]
|
||||
fn the_pictures_a_person_chose_to_wear_come_after_their_role_in_kicks_order() {
|
||||
let identity: api::ChatIdentity = serde_json::from_value(serde_json::json!({
|
||||
"color": "#E9113C",
|
||||
"badges": [
|
||||
{ "type": "moderator", "text": "Moderator", "sort_order": 12 },
|
||||
{ "type": "sub_gifter", "text": "Sub Gifter", "count": 1, "sort_order": 8 }
|
||||
],
|
||||
"badges_v2": [
|
||||
{ "name": "level", "badge_type": "global", "image_url": "https://ext.cdn.kick.com/chat/badges/48_a.png", "metadata": { "level": 48 }, "selected": true, "sort_order": 1 },
|
||||
{ "name": "GOAT", "badge_type": "global", "image_url": "https://ext.cdn.kick.com/chat/badges/GOAT_b.png", "selected": true, "sort_order": 4 },
|
||||
{ "name": "GoldenK", "badge_type": "global", "image_url": "https://ext.cdn.kick.com/chat/badges/GoldenK_c.png", "selected": false, "sort_order": 4 },
|
||||
{ "name": "kick_founder22", "badge_type": "global", "image_url": "https://ext.cdn.kick.com/chat/badges/f.png", "selected": true, "sort_order": 2 }
|
||||
]
|
||||
}))
|
||||
.unwrap();
|
||||
let style = style_of(Some(&identity));
|
||||
// Not the one the person chose not to wear, and highest rank first.
|
||||
assert_eq!(style.badges.iter().map(|b| b.kind.as_str()).collect::<Vec<_>>(), vec!["moderator", "sub_gifter", "GOAT", "kick_founder22", "level"]);
|
||||
// Words for the roles, pictures for the rest, and the level says what it is.
|
||||
assert!(style.badges[0].image.is_none());
|
||||
assert_eq!(style.badges[2].image.as_deref(), Some("https://ext.cdn.kick.com/chat/badges/GOAT_b.png"));
|
||||
assert_eq!(style.badges[4].text, "Level 48");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_badge_picture_is_only_ever_a_web_address_and_a_crowd_of_them_is_cut() {
|
||||
let wear = |name: &str, url: &str| api::BadgeV2 { name: name.into(), image_url: Some(url.into()), selected: Some(true), ..Default::default() };
|
||||
let identity = api::ChatIdentity {
|
||||
badges_v2: vec![
|
||||
wear("local", "file:///etc/passwd"),
|
||||
wear("a", "https://x/a.png"), wear("b", "https://x/b.png"), wear("c", "https://x/c.png"),
|
||||
wear("d", "https://x/d.png"), wear("e", "https://x/e.png"),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let kinds: Vec<_> = style_of(Some(&identity)).badges.into_iter().map(|b| b.kind).collect();
|
||||
assert_eq!(kinds, vec!["a", "b", "c", "d"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_needs_both_who_and_what() {
|
||||
let whole = serde_json::json!({
|
||||
"original_sender": { "id": 1, "username": "Greg" },
|
||||
"original_message": { "id": "abc", "content": "hello" }
|
||||
});
|
||||
let preview = reply_preview_from_value(Some(&whole), None).unwrap();
|
||||
assert_eq!((preview.id.as_str(), preview.from.as_str(), preview.body.as_str()), ("abc", "Greg", "hello"));
|
||||
// Half of one is a hole above the message, and is left out.
|
||||
assert!(reply_preview_from_value(Some(&serde_json::json!({ "original_message": { "id": "abc", "content": "hello" } })), None).is_none());
|
||||
assert!(reply_preview_from_value(Some(&serde_json::json!({ "original_sender": { "id": 1, "username": "Greg" } })), None).is_none());
|
||||
assert!(reply_preview_from_value(Some(&serde_json::json!({ "original_sender": { "id": 1 }, "original_message": { "id": "abc" } })), None).is_none());
|
||||
assert!(reply_preview_from_value(None, None).is_none());
|
||||
assert!(reply_preview_from_value(Some(&serde_json::json!("not metadata")), None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_kind_of_kick_event_is_a_card() {
|
||||
let sub = serde_json::json!({ "username": "bigkhernandez", "months": 21 });
|
||||
assert_eq!(describe_action("SubscriptionEvent", &sub).map(|(_, l)| l).as_deref(), Some("bigkhernandez subscribed - 21 months"));
|
||||
let card = action_card("SubscriptionEvent", &sub).unwrap();
|
||||
assert_eq!((card.kind.as_deref(), card.author.as_deref(), card.title.as_deref()), (Some("subscribe"), Some("bigkhernandez"), Some("21")));
|
||||
// The first month has none to say.
|
||||
assert_eq!(action_card("SubscriptionEvent", &serde_json::json!({ "username": "x", "months": 1 })).unwrap().title, None);
|
||||
assert_eq!(action_card("SubscriptionEvent", &serde_json::json!({ "username": "x" })).unwrap().title, None);
|
||||
|
||||
let ban = serde_json::json!({ "user": { "username": "troll" }, "banned_by": { "username": "mod" } });
|
||||
let card = action_card("App\\Events\\UserBannedEvent", &ban).unwrap();
|
||||
assert_eq!((card.kind.as_deref(), card.author.as_deref(), card.title.as_deref(), card.description.as_deref()),
|
||||
(Some("moderation"), Some("troll"), Some("was banned"), Some("mod")));
|
||||
let timeout = serde_json::json!({ "user": { "username": "troll" }, "expires_at": "2026-10-11T00:10:00Z" });
|
||||
let card = action_card("UserBannedEvent", &timeout).unwrap();
|
||||
assert_eq!((card.title.as_deref(), card.description.as_deref()), (Some("was timed out"), None));
|
||||
let back = action_card("UserUnbannedEvent", &serde_json::json!({ "user": { "username": "troll" } })).unwrap();
|
||||
assert_eq!((back.author.as_deref(), back.title.as_deref()), (Some("troll"), Some("is allowed back")));
|
||||
// Nobody named is nobody to draw a card about.
|
||||
assert!(action_card("UserBannedEvent", &serde_json::json!({})).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_stream_and_the_room_are_cards_too() {
|
||||
let state = testkit::simulated_daemon("event-cards");
|
||||
let mut watched = testkit::watching_odablock();
|
||||
feed(&state, &mut watched, "App\\Events\\StreamerIsLive", serde_json::json!({}));
|
||||
feed(&state, &mut watched, "App\\Events\\StopStreamBroadcast", serde_json::json!({}));
|
||||
feed(&state, &mut watched, "App\\Events\\ChatroomClearEvent", serde_json::json!({}));
|
||||
feed(&state, &mut watched, "SubscriptionEvent", serde_json::json!({ "username": "newcomer", "months": 3 }));
|
||||
let rows = state.store.get_backlog(&crate::model::buffer_id("kick:tester", "odablock"), 0, 50).unwrap();
|
||||
let cards: Vec<_> = rows.iter().map(|m| (m.kind.as_str(), m.embeds[0].kind.as_deref().unwrap(), m.embeds[0].title.as_deref().unwrap())).collect();
|
||||
assert_eq!(cards, vec![
|
||||
("stream", "stream", "went live"),
|
||||
("stream", "stream", "ended the stream"),
|
||||
("moderation", "moderation", "cleared the chat"),
|
||||
("sub", "subscribe", "3"),
|
||||
]);
|
||||
assert_eq!(rows[0].embeds[0].icon.as_deref(), Some("live"));
|
||||
assert_eq!(rows[1].embeds[0].icon.as_deref(), Some("offline"));
|
||||
assert_eq!(rows[0].embeds[0].author.as_deref(), Some("odablock"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ use super::*;
|
||||
///
|
||||
/// Kick's own channel endpoint carries all of it and this backend was already
|
||||
/// calling it - the numbers were parsed away and dropped.
|
||||
pub(super) fn announce_stream(state: &AppState, buffer_id: &str, channel: &api::Channel, following: Option<bool>) {
|
||||
pub(super) fn announce_stream(state: &AppState, buffer_id: &str, channel: &api::Channel, following: Option<bool>, playback: Option<&api::Playback>) {
|
||||
let stream = serde_json::json!({
|
||||
"bufferId": buffer_id,
|
||||
"live": channel.live.is_some(),
|
||||
@@ -29,7 +29,10 @@ pub(super) fn announce_stream(state: &AppState, buffer_id: &str, channel: &api::
|
||||
// whoever wants to watch, because every frontend that shows video
|
||||
// needs it and the refresh already running is what replaces the
|
||||
// signed token before it expires.
|
||||
"playbackUrl": channel.playback_url,
|
||||
"playbackUrl": playback.map(|p| p.url.clone()),
|
||||
// When that address stops being accepted, so whoever plays it can ask for a new one in time
|
||||
// rather than finding out from a segment that is refused.
|
||||
"playbackExpires": playback.and_then(|p| p.expires),
|
||||
// Absent rather than false for an account that is not signed in:
|
||||
// "not following" and "cannot say" are different, and a button that
|
||||
// offers to follow when it cannot is a button that fails when pressed.
|
||||
@@ -39,6 +42,38 @@ pub(super) fn announce_stream(state: &AppState, buffer_id: &str, channel: &api::
|
||||
state.events.emit("kickStream", stream);
|
||||
}
|
||||
|
||||
/// How long before an address stops being accepted a new one is asked for.
|
||||
const RENEW_BEFORE: i64 = 180;
|
||||
|
||||
/// The address of a live channel's video: the one the stream already has while it is good for a few
|
||||
/// minutes yet, otherwise a new one from the call that gives only that, and failing that the one the
|
||||
/// channel payload carried.
|
||||
pub(super) async fn playback_for(state: &AppState, buffer_id: &str, http: &reqwest::Client, channel: &api::Channel) -> Option<api::Playback> {
|
||||
channel.live.as_ref()?;
|
||||
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or_default();
|
||||
if let Some(have) = state.runtime.kick_stream(buffer_id) {
|
||||
let url = have["playbackUrl"].as_str();
|
||||
let expires = have["playbackExpires"].as_i64();
|
||||
if let (Some(url), Some(expires)) = (url, expires) {
|
||||
if expires - now > RENEW_BEFORE {
|
||||
return Some(api::Playback::of(url));
|
||||
}
|
||||
}
|
||||
}
|
||||
match api::playback(http, &channel.slug).await {
|
||||
Ok(p) => {
|
||||
if p.ads_opt_out == Some(false) {
|
||||
tracing::warn!("kick: the address for {} no longer opts out of ads", channel.slug);
|
||||
}
|
||||
Some(p)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("kick: the address for {}: {e:#}", channel.slug);
|
||||
channel.playback_url.as_deref().map(api::Playback::of)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this account follows the channel, where it can say.
|
||||
pub(super) async fn following_now(
|
||||
state: &AppState,
|
||||
@@ -169,7 +204,8 @@ pub async fn refresh_stream_now(state: &AppState, buffer_id: &str) {
|
||||
match api::channel(&http, &channel.slug).await {
|
||||
Ok(fresh) => {
|
||||
let following = following_now(state, &http, &account_id, &channel.slug, known).await;
|
||||
announce_stream(state, buffer_id, &fresh, following);
|
||||
let playback = playback_for(state, buffer_id, &http, &fresh).await;
|
||||
announce_stream(state, buffer_id, &fresh, following, playback.as_ref());
|
||||
}
|
||||
Err(e) => tracing::debug!("kick: refreshing {}: {e:#}", channel.slug),
|
||||
}
|
||||
|
||||
+18
-6
@@ -1,4 +1,4 @@
|
||||
//! Kick - livestream chat, read over the same Pusher websocket kick.com's own
|
||||
//! Kick - livestream chat, read over the same realtime websocket kick.com's own
|
||||
//! player page uses.
|
||||
//!
|
||||
//! Two things make this backend shaped differently from the others here.
|
||||
@@ -11,7 +11,7 @@
|
||||
//! are subscribed to. That is why the token is optional throughout rather than
|
||||
//! a precondition for connecting.
|
||||
//!
|
||||
//! **One websocket carries every channel.** Pusher multiplexes: each channel
|
||||
//! **One websocket carries every channel.** The service multiplexes: each channel
|
||||
//! is a `chatrooms.<id>.v2` subscription on the one connection, so watching
|
||||
//! twenty streamers costs one socket rather than twenty. Sneedchat next door
|
||||
//! opens one per room because its protocol has no such verb; this one does,
|
||||
@@ -21,7 +21,8 @@
|
||||
//!
|
||||
//! # How this folder is arranged
|
||||
//!
|
||||
//! - `socket` - the Pusher connection, its subscriptions, the frame envelope
|
||||
//! - `socket` - the realtime connection,
|
||||
//! - `realtime` - its two HTTP calls and its frames, its subscriptions, the frame envelope
|
||||
//! - `chat` - what is said in a channel, and who said it
|
||||
//! - `cards` - polls and predictions, which are the same shape twice
|
||||
//! - `live` - whether a channel is streaming, and who just started
|
||||
@@ -34,10 +35,11 @@ pub mod cards;
|
||||
pub mod chat;
|
||||
pub mod live;
|
||||
pub mod pins;
|
||||
pub mod realtime;
|
||||
pub mod socket;
|
||||
pub mod watch;
|
||||
#[cfg(test)]
|
||||
mod testkit;
|
||||
pub(crate) mod testkit;
|
||||
pub mod api;
|
||||
|
||||
pub use cards::*;
|
||||
@@ -66,6 +68,9 @@ impl Watched {
|
||||
/// nearly identical. Their badges changing is - somebody subscribes, or
|
||||
/// is given moderator, and the list should follow.
|
||||
fn heard(&mut self, slug: &str, speaker: Speaker) -> bool {
|
||||
// Everybody who has spoken, kept for the count alone: the list below is
|
||||
// cut off, and what it is cut off at is not how many people have talked.
|
||||
self.chatters.entry(slug.to_string()).or_default().insert(speaker.nick.clone());
|
||||
let list = self.speakers.entry(slug.to_string()).or_default();
|
||||
if let Some(existing) = list.iter_mut().find(|s| s.nick == speaker.nick) {
|
||||
let changed = existing.badge != speaker.badge;
|
||||
@@ -77,6 +82,11 @@ impl Watched {
|
||||
true
|
||||
}
|
||||
|
||||
/// How many different people have spoken in this channel since it was opened.
|
||||
fn chatters(&self, slug: &str) -> usize {
|
||||
self.chatters.get(slug).map_or(0, |set| set.len())
|
||||
}
|
||||
|
||||
fn roster(&self, slug: &str) -> Vec<serde_json::Value> {
|
||||
self.speakers
|
||||
.get(slug)
|
||||
@@ -105,20 +115,22 @@ impl Watched {
|
||||
}
|
||||
|
||||
/// Forgets a channel, and names every subscription it was using so the
|
||||
/// caller can cancel each one. All four, or the socket keeps delivering
|
||||
/// caller can cancel each one. All of them, or the socket keeps delivering
|
||||
/// events for a conversation that has been closed.
|
||||
fn remove(&mut self, slug: &str) -> Vec<String> {
|
||||
self.speakers.remove(slug);
|
||||
self.chatters.remove(slug);
|
||||
let mut names = Vec::new();
|
||||
if let Some(room) = self.by_chatroom.iter().find(|(_, s)| *s == slug).map(|(i, _)| *i) {
|
||||
self.by_chatroom.remove(&room);
|
||||
names.push(format!("chatrooms.{room}.v2"));
|
||||
names.push(format!("chatrooms.{room}"));
|
||||
names.push(format!("chatroom_{room}"));
|
||||
}
|
||||
if let Some(channel) = self.by_channel.iter().find(|(_, s)| *s == slug).map(|(i, _)| *i) {
|
||||
self.by_channel.remove(&channel);
|
||||
names.push(format!("channel.{channel}"));
|
||||
names.push(format!("channel_{channel}"));
|
||||
names.push(format!("predictions-channel-{channel}"));
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
//! Kick's realtime service, which carries its chat.
|
||||
//!
|
||||
//! Kick ran its chat on Pusher and turned that application off: its key is
|
||||
//! refused on every cluster. What kick.com's own pages use now is Centrifugo,
|
||||
//! behind two small HTTP calls on `web.kick.com`:
|
||||
//!
|
||||
//! 1. Ask where the service is. The answer is a websocket address, and it is
|
||||
//! given to anybody - the client names itself with an id of its own making.
|
||||
//! 2. Ask for a token for that id, which the connection is opened with. A guest
|
||||
//! gets one without signing in; it lasts forty-five minutes and the
|
||||
//! connection has to be given a fresh one before then or it is closed.
|
||||
//!
|
||||
//! Then it is subscribe and listen. A public channel is subscribed with no
|
||||
//! token at all; what arrives is a *publication*, whose payload is the same
|
||||
//! `{event, data}` pair Pusher carried, with `data` still a JSON string.
|
||||
//!
|
||||
//! Frames are JSON objects, one to a line, and several may share one websocket
|
||||
//! message. The keepalive is an empty object, sent by the server and answered
|
||||
//! in kind.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
const WEB_ROOT: &str = "https://web.kick.com";
|
||||
const PLATFORM: &str = "web";
|
||||
|
||||
/// A name for this client, in the shape of a version 4 UUID, which is what
|
||||
/// kick.com's own pages make one of.
|
||||
pub fn client_id() -> String {
|
||||
use rand::Rng;
|
||||
let mut rng = rand::thread_rng();
|
||||
let mut b: [u8; 16] = rng.gen();
|
||||
b[6] = (b[6] & 0x0f) | 0x40;
|
||||
b[8] = (b[8] & 0x3f) | 0x80;
|
||||
let h: String = b.iter().map(|x| format!("{x:02x}")).collect();
|
||||
format!("{}-{}-{}-{}-{}", &h[0..8], &h[8..12], &h[12..16], &h[16..20], &h[20..32])
|
||||
}
|
||||
|
||||
/// Where the service is. Asked, not written down: the address names a region,
|
||||
/// and a client that pinned one would break the day Kick moved it.
|
||||
pub async fn address(http: &reqwest::Client, client: &str) -> Result<String> {
|
||||
let body = json!({
|
||||
"client": { "id": client, "type": PLATFORM },
|
||||
"capabilities": { "accepted_providers": [ { "provider": "centrifugo" } ] }
|
||||
});
|
||||
let answer = post(http, "/api/v1/realtime/connection", None, body).await.context("asking Kick where its realtime service is")?;
|
||||
answer["data"]["connections"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find(|c| c["provider"].as_str() == Some("centrifugo"))
|
||||
.and_then(|c| c["credentials"]["url"].as_str())
|
||||
.map(str::to_string)
|
||||
.context("Kick named no realtime service")
|
||||
}
|
||||
|
||||
/// The token a connection is opened (or renewed) with. `bearer` is the signed-in
|
||||
/// account's own token where the client is one; a guest needs none.
|
||||
pub async fn token(http: &reqwest::Client, client: &str, bearer: Option<&str>) -> Result<String> {
|
||||
let answer = post(http, "/api/v1/realtime/auth/connection", bearer, json!({ "client_id": client })).await.context("asking Kick for a realtime token")?;
|
||||
answer["data"]["token"].as_str().map(str::to_string).context("Kick sent no realtime token")
|
||||
}
|
||||
|
||||
async fn post(http: &reqwest::Client, path: &str, bearer: Option<&str>, body: Value) -> Result<Value> {
|
||||
let mut request = http.post(format!("{WEB_ROOT}{path}")).header("Accept", "application/json").header("x-app-platform", PLATFORM).json(&body);
|
||||
if let Some(bearer) = bearer {
|
||||
request = request.bearer_auth(bearer);
|
||||
}
|
||||
let response = request.send().await.context("request failed")?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
bail!("Kick answered {status}");
|
||||
}
|
||||
response.json().await.context("unreadable answer")
|
||||
}
|
||||
|
||||
/// Numbers each request, which is how an answer is matched to what asked. One
|
||||
/// counter for everything that writes to any connection: the numbers only have
|
||||
/// to differ within a connection, and a shared one cannot repeat.
|
||||
static NEXT: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
fn id() -> u64 {
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn connect(token: &str) -> Value {
|
||||
json!({ "id": id(), "connect": { "token": token, "name": "js" } })
|
||||
}
|
||||
|
||||
/// A public channel needs no token.
|
||||
pub fn subscribe(channel: &str) -> Value {
|
||||
json!({ "id": id(), "subscribe": { "channel": channel } })
|
||||
}
|
||||
|
||||
pub fn unsubscribe(channel: &str) -> Value {
|
||||
json!({ "id": id(), "unsubscribe": { "channel": channel } })
|
||||
}
|
||||
|
||||
pub fn refresh(token: &str) -> Value {
|
||||
json!({ "id": id(), "refresh": { "token": token } })
|
||||
}
|
||||
|
||||
pub fn pong() -> Value {
|
||||
json!({})
|
||||
}
|
||||
|
||||
/// One thing the service said.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Said {
|
||||
/// The keepalive, to be answered.
|
||||
Ping,
|
||||
/// The connection was accepted. `ttl` is how many seconds the token it was
|
||||
/// opened with is good for, if it expires.
|
||||
Connected { ttl: Option<u64> },
|
||||
/// Something was published to a channel: the event it names, and its data
|
||||
/// read out of the string it arrives in.
|
||||
Published { channel: String, event: String, data: Value },
|
||||
/// A request was refused.
|
||||
Refused(String),
|
||||
/// The service is ending the connection.
|
||||
Disconnect(String),
|
||||
/// Anything else - an answer to a subscription, mostly - which says nothing.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Reads one websocket message, which may hold several frames.
|
||||
pub fn read(text: &str) -> Vec<Said> {
|
||||
text.lines().filter(|l| !l.trim().is_empty()).filter_map(|l| serde_json::from_str::<Value>(l).ok()).map(|v| read_frame(&v)).collect()
|
||||
}
|
||||
|
||||
fn read_frame(v: &Value) -> Said {
|
||||
if v.as_object().is_some_and(|o| o.is_empty()) {
|
||||
return Said::Ping;
|
||||
}
|
||||
if let Some(error) = v.get("error") {
|
||||
let message = error["message"].as_str().unwrap_or("refused");
|
||||
return Said::Refused(match error["code"].as_u64() {
|
||||
Some(code) => format!("{message} ({code})"),
|
||||
None => message.to_string(),
|
||||
});
|
||||
}
|
||||
if let Some(connect) = v.get("connect") {
|
||||
let ttl = connect.get("expires").and_then(|e| e.as_bool()).filter(|e| *e).and_then(|_| connect["ttl"].as_u64());
|
||||
return Said::Connected { ttl };
|
||||
}
|
||||
if let Some(d) = v.get("disconnect") {
|
||||
let reason = d["reason"].as_str().unwrap_or("no reason given");
|
||||
return Said::Disconnect(match d["code"].as_u64() {
|
||||
Some(code) => format!("{reason} ({code})"),
|
||||
None => reason.to_string(),
|
||||
});
|
||||
}
|
||||
if let Some(push) = v.get("push") {
|
||||
let channel = push["channel"].as_str().unwrap_or_default();
|
||||
if let Some(inner) = push.get("pub").and_then(|p| p.get("data")) {
|
||||
if let Some(event) = inner["event"].as_str() {
|
||||
// The payload is a JSON string inside the JSON.
|
||||
let data = match &inner["data"] {
|
||||
Value::String(s) => serde_json::from_str(s).unwrap_or(Value::Null),
|
||||
other => other.clone(),
|
||||
};
|
||||
return Said::Published { channel: channel.to_string(), event: event.to_string(), data };
|
||||
}
|
||||
}
|
||||
if let Some(d) = push.get("disconnect") {
|
||||
return Said::Disconnect(d["reason"].as_str().unwrap_or("unsubscribed").to_string());
|
||||
}
|
||||
}
|
||||
Said::Other
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_client_is_named_like_a_uuid() {
|
||||
let a = client_id();
|
||||
assert_eq!(a.len(), 36);
|
||||
assert_eq!(a.as_bytes()[14], b'4', "version 4");
|
||||
assert_ne!(a, client_id());
|
||||
}
|
||||
|
||||
/// Frames as the service sends them, from a live channel.
|
||||
#[test]
|
||||
fn what_a_chat_channel_sends_is_read() {
|
||||
let hello = r#"{"id":1,"connect":{"client":"c","version":"6.9.7 PRO","expires":true,"ttl":2700,"ping":25,"pong":true}}"#;
|
||||
assert_eq!(read(hello), vec![Said::Connected { ttl: Some(2700) }]);
|
||||
assert_eq!(read(r#"{"id":1,"connect":{"client":"c","ping":25}}"#), vec![Said::Connected { ttl: None }]);
|
||||
assert_eq!(read("{}"), vec![Said::Ping]);
|
||||
assert_eq!(read(r#"{"id":2,"subscribe":{}}"#), vec![Said::Other]);
|
||||
|
||||
let publication = r#"{"push":{"channel":"chatrooms.668.v2","pub":{"data":{"event":"App\\Events\\ChatMessageEvent","data":"{\"id\":\"a\",\"chatroom_id\":668,\"content\":\"hi\"}"}}}}"#;
|
||||
let said = read(publication);
|
||||
assert_eq!(said.len(), 1);
|
||||
match &said[0] {
|
||||
Said::Published { channel, event, data } => {
|
||||
assert_eq!((channel.as_str(), event.as_str()), ("chatrooms.668.v2", "App\\Events\\ChatMessageEvent"));
|
||||
assert_eq!((data["chatroom_id"].as_u64(), data["content"].as_str()), (Some(668), Some("hi")));
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_frames_in_one_message_are_all_read() {
|
||||
let both = "{}\n{\"id\":3,\"subscribe\":{}}\n\n{\"id\":4,\"error\":{\"code\":103,\"message\":\"permission denied\"}}";
|
||||
assert_eq!(read(both), vec![Said::Ping, Said::Other, Said::Refused("permission denied (103)".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ending_is_read_with_its_reason() {
|
||||
assert_eq!(read(r#"{"disconnect":{"code":3005,"reason":"expired"}}"#), vec![Said::Disconnect("expired (3005)".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_requests_are_what_the_service_takes() {
|
||||
let c = connect("jwt");
|
||||
assert_eq!((c["connect"]["token"].as_str(), c["connect"]["name"].as_str()), (Some("jwt"), Some("js")));
|
||||
assert_eq!(subscribe("chatrooms.1.v2")["subscribe"]["channel"], json!("chatrooms.1.v2"));
|
||||
assert_eq!(unsubscribe("x")["unsubscribe"]["channel"], json!("x"));
|
||||
assert_eq!(refresh("t")["refresh"]["token"], json!("t"));
|
||||
assert_ne!(connect("a")["id"], connect("a")["id"], "each request has a number of its own");
|
||||
assert_eq!(pong(), json!({}));
|
||||
}
|
||||
}
|
||||
+141
-75
@@ -1,28 +1,13 @@
|
||||
//! The Pusher socket Kick's chat rides on.
|
||||
//! The realtime socket Kick's chat rides on.
|
||||
//!
|
||||
//! Kick does not run a chat server of its own so much as a channel on a
|
||||
//! commercial pub/sub service, which means connecting is subscribing: this
|
||||
//! module owns the websocket, the subscription list, and the frame envelope
|
||||
//! everything else arrives inside.
|
||||
//! everything else arrives inside. The service's own protocol is in
|
||||
//! `realtime`; what is said on it is the same events as ever.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Kick's own Pusher application, as served to every visitor of kick.com.
|
||||
///
|
||||
/// Public by construction: a Pusher *key* is the identifier a browser needs to
|
||||
/// subscribe to public channels, which is why it appears in their page source
|
||||
/// and why reading a public chat needs no credential. It is not a secret and
|
||||
/// grants nothing beyond what a browser on the channel page already has.
|
||||
pub(super) const PUSHER_KEY: &str = "32cbd69e4b950bf97679";
|
||||
|
||||
pub(super) const PUSHER_CLUSTER: &str = "us2";
|
||||
|
||||
pub(super) fn pusher_url() -> String {
|
||||
format!(
|
||||
"wss://ws-{PUSHER_CLUSTER}.pusher.com/app/{PUSHER_KEY}?protocol=7&client=moho&version=8.4.0&flash=false"
|
||||
)
|
||||
}
|
||||
|
||||
/// Asked of the connection from elsewhere in the daemon, over a channel,
|
||||
/// because the socket is owned by its own task and these arrive from RPC.
|
||||
#[derive(Debug)]
|
||||
@@ -249,9 +234,10 @@ pub(super) async fn run(state: &AppState, config: &KickAccountConfig, account_id
|
||||
});
|
||||
}
|
||||
|
||||
let (mut socket, _) = crate::net::route::websocket(super::api::SHARED_KEY, pusher_url())
|
||||
.await
|
||||
.context("connecting to Kick's chat")?;
|
||||
// A guest of the realtime service: public chat needs nothing more, and
|
||||
// what the account signed in as is for sending, which goes by HTTP.
|
||||
let realtime_client = realtime::client_id();
|
||||
let (mut socket, token_ttl) = open_realtime(&http, &realtime_client).await?;
|
||||
|
||||
// Two maps because Kick uses two numbers. A chat message names the
|
||||
// chatroom; the events on `channel.<id>` name the channel, which is a
|
||||
@@ -366,12 +352,11 @@ pub(super) async fn run(state: &AppState, config: &KickAccountConfig, account_id
|
||||
|
||||
state.runtime.set_conn_state(state, account_id, ConnState::Connected, None);
|
||||
|
||||
// Pusher closes a socket it has not heard from. Its own timeout arrives in
|
||||
// the handshake, but a fixed interval well inside the shortest it ever
|
||||
// sends is simpler than tracking it and cannot be wrong in the direction
|
||||
// that matters.
|
||||
let mut ping = tokio::time::interval(Duration::from_secs(60));
|
||||
ping.tick().await;
|
||||
// The connection's token runs out, and a connection past it is closed. A
|
||||
// fresh one is handed over before then. The keepalive is answered as it
|
||||
// arrives, in `handle_frame`.
|
||||
let mut renew = tokio::time::interval(token_ttl);
|
||||
renew.tick().await;
|
||||
|
||||
// Who is live, and how many people are watching. Kick pushes the start and
|
||||
// the stop of a stream but never the count, so the only way to keep the
|
||||
@@ -447,10 +432,7 @@ pub(super) async fn run(state: &AppState, config: &KickAccountConfig, account_id
|
||||
}
|
||||
Some(Command::Part(slug)) => {
|
||||
for name in watched.remove(&slug) {
|
||||
let _ = socket.send(WsMessage::Text(serde_json::json!({
|
||||
"event": "pusher:unsubscribe",
|
||||
"data": { "channel": name }
|
||||
}).to_string())).await;
|
||||
let _ = socket.send(WsMessage::Text(realtime::unsubscribe(&name).to_string())).await;
|
||||
}
|
||||
state.runtime.forget_kick_channel(&format!("{account_id}|{slug}"));
|
||||
}
|
||||
@@ -463,8 +445,9 @@ pub(super) async fn run(state: &AppState, config: &KickAccountConfig, account_id
|
||||
let (account_id, token) = (account_id.to_string(), live_token.clone().unwrap_or_default());
|
||||
tokio::spawn(async move { refresh_followed_live(&state, &http, &account_id, &token).await });
|
||||
}
|
||||
_ = ping.tick() => {
|
||||
socket.send(WsMessage::Text(r#"{"event":"pusher:ping","data":{}}"#.to_string())).await
|
||||
_ = renew.tick() => {
|
||||
let token = realtime::token(&http, &realtime_client, None).await.context("renewing the chat connection's token")?;
|
||||
socket.send(WsMessage::Text(realtime::refresh(&token).to_string())).await
|
||||
.context("Kick's chat connection went away")?;
|
||||
}
|
||||
frame = socket.next() => match frame {
|
||||
@@ -478,6 +461,41 @@ pub(super) async fn run(state: &AppState, config: &KickAccountConfig, account_id
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the realtime connection as a guest: where it is, a token for it, and
|
||||
/// the wait for the service to accept it. Returns how long the token is good
|
||||
/// for, which is how long until it has to be renewed.
|
||||
async fn open_realtime(http: &reqwest::Client, client: &str) -> Result<(Socket, Duration)> {
|
||||
let url = realtime::address(http, client).await?;
|
||||
let token = realtime::token(http, client, None).await?;
|
||||
let (mut socket, _) = crate::net::route::websocket(super::api::SHARED_KEY, url.as_str()).await.context("connecting to Kick's chat")?;
|
||||
socket.send(WsMessage::Text(realtime::connect(&token).to_string())).await.context("opening the connection to Kick's chat")?;
|
||||
let waited = tokio::time::timeout(Duration::from_secs(20), async {
|
||||
loop {
|
||||
match socket.next().await {
|
||||
None => anyhow::bail!("Kick closed the chat connection before it answered"),
|
||||
Some(Err(e)) => return Err(e).context("reading from Kick's chat"),
|
||||
Some(Ok(WsMessage::Text(text))) => {
|
||||
for said in realtime::read(&text) {
|
||||
match said {
|
||||
realtime::Said::Connected { ttl } => return Ok(ttl),
|
||||
realtime::Said::Refused(why) | realtime::Said::Disconnect(why) => anyhow::bail!("Kick's chat refused the connection: {why}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Ok(WsMessage::Close(_))) => anyhow::bail!("Kick closed the chat connection before it answered"),
|
||||
Some(Ok(_)) => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.context("Kick's chat did not answer")??;
|
||||
// Renewed well before it runs out. Not at the last moment: a renewal that
|
||||
// is slow or retried must still land inside the token's life.
|
||||
let ttl = waited.unwrap_or(2700);
|
||||
Ok((socket, Duration::from_secs((ttl * 2 / 3).max(30))))
|
||||
}
|
||||
|
||||
pub(super) type Socket = tokio_tungstenite::WebSocketStream<
|
||||
tokio_tungstenite::MaybeTlsStream<crate::net::route::TcpOrSocks>,
|
||||
>;
|
||||
@@ -533,7 +551,7 @@ async fn prepare(
|
||||
// Follow state is not asked for here: this runs for every channel at
|
||||
// connect, and that would be one extra request per channel before the
|
||||
// first message arrives. It is filled in when the channel is opened.
|
||||
announce_stream(state, &buffer.id, &channel, None);
|
||||
announce_stream(state, &buffer.id, &channel, None, channel.playback_url.as_deref().filter(|_| channel.live.is_some()).map(api::Playback::of).as_ref());
|
||||
|
||||
// The streamer's own picture on the channel. Parsed since the first
|
||||
// version of this backend and used by nothing, so every Kick channel drew
|
||||
@@ -641,19 +659,24 @@ async fn prepare(
|
||||
|
||||
/// Asks the socket for everything that happens in a channel's chat.
|
||||
///
|
||||
/// Four subscriptions, not one, because Kick scatters them and only the first
|
||||
/// Several subscriptions, not one, because Kick scatters them and only the first
|
||||
/// carries what people say. Watching that one alone is why a chat can look
|
||||
/// quiet while the stream is visibly reacting to something. Measured against a
|
||||
/// live subathon rather than guessed - each name here is one an event was
|
||||
/// actually observed arriving on:
|
||||
///
|
||||
/// - `chatrooms.<room>.v2` what people say, and plain subscriptions
|
||||
/// - `chatrooms.<room>` rewards, and the info lines Kick's own UI draws
|
||||
/// - `chatroom_<room>` gifted subscriptions, on an older name entirely
|
||||
/// - `chatroom_<room>` rewards and gifted subscriptions, on an older name entirely
|
||||
/// - `channel.<channel>` going live, and the channel's own subscriptions
|
||||
/// - `channel_<channel>` KICKs sent to the streamer, and goal and leaderboard updates
|
||||
///
|
||||
/// There used to be a sixth, `chatrooms.<room>` without the `.v2`. Kick's own
|
||||
/// apps do not ask for it, and six minutes on a channel with 60,000 watching
|
||||
/// delivered nothing on it - chat came on `.v2` and rewards on `chatroom_` -
|
||||
/// so it was a subscription to silence, paid for on every channel opened.
|
||||
///
|
||||
/// The overlap is real and deliberate on Kick's part: one subscription arrives
|
||||
/// three times over three of these. Which of the three is believed is settled
|
||||
/// more than once over several of these. Which of the three is believed is settled
|
||||
/// in `describe_action`, not here - subscribing to fewer channels would lose
|
||||
/// whole categories of event to avoid a duplicate that is cheaper to filter.
|
||||
///
|
||||
@@ -662,9 +685,14 @@ async fn prepare(
|
||||
pub(super) async fn subscribe(socket: &mut Socket, chatroom_id: u64, channel_id: u64) -> Result<()> {
|
||||
for channel in [
|
||||
format!("chatrooms.{chatroom_id}.v2"),
|
||||
format!("chatrooms.{chatroom_id}"),
|
||||
format!("chatroom_{chatroom_id}"),
|
||||
format!("channel.{channel_id}"),
|
||||
// A third name for the channel, with an underscore, which is where
|
||||
// KICKs sent to the streamer arrive and nothing else this client
|
||||
// listened to carries them - found by listening to a live channel's
|
||||
// public events rather than guessed, which is why a gift of KICKs was
|
||||
// never drawn: nobody had asked for the subscription it came on.
|
||||
format!("channel_{channel_id}"),
|
||||
// Predictions are broadcast nowhere near the chat: Kick's own viewer
|
||||
// panel listens on this one, and this client heard none of them
|
||||
// because it never asked. Named with hyphens rather than dots, which
|
||||
@@ -673,27 +701,18 @@ pub(super) async fn subscribe(socket: &mut Socket, chatroom_id: u64, channel_id:
|
||||
format!("predictions-channel-{channel_id}"),
|
||||
] {
|
||||
socket
|
||||
.send(WsMessage::Text(
|
||||
serde_json::json!({ "event": "pusher:subscribe", "data": { "auth": "", "channel": channel } })
|
||||
.to_string(),
|
||||
))
|
||||
.send(WsMessage::Text(realtime::subscribe(&channel).to_string()))
|
||||
.await
|
||||
.context("subscribing to the channel's chat")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct PusherFrame {
|
||||
pub(super) event: String,
|
||||
#[serde(default)]
|
||||
pub(super) data: serde_json::Value,
|
||||
/// Which subscription this arrived on. The most reliable way to know
|
||||
/// whose channel an event belongs to: a chat message names its room in the
|
||||
/// payload, but the doing-things events name variously the channel, the
|
||||
/// chatroom, or nothing at all - while Pusher always says which
|
||||
/// subscription delivered it.
|
||||
#[serde(default)]
|
||||
/// Where an event arrived. The most reliable way to know whose channel it
|
||||
/// belongs to: a chat message names its room in the payload, but the
|
||||
/// doing-things events name variously the channel, the chatroom, or nothing at
|
||||
/// all - while the service always says which subscription delivered it.
|
||||
pub(super) struct Delivery {
|
||||
pub(super) channel: Option<String>,
|
||||
}
|
||||
|
||||
@@ -704,23 +723,18 @@ pub(super) async fn handle_frame(
|
||||
text: &str,
|
||||
socket: &mut Socket,
|
||||
) -> Result<()> {
|
||||
let frame: PusherFrame = match serde_json::from_str(text) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
|
||||
// Pusher nests the real payload as a JSON *string* inside the frame, so
|
||||
// every event needs unwrapping twice.
|
||||
let payload = match &frame.data {
|
||||
serde_json::Value::String(s) => serde_json::from_str::<serde_json::Value>(s).unwrap_or(serde_json::Value::Null),
|
||||
other => other.clone(),
|
||||
};
|
||||
|
||||
if frame.event == "pusher:ping" {
|
||||
socket.send(WsMessage::Text(r#"{"event":"pusher:pong","data":{}}"#.to_string())).await?;
|
||||
return Ok(());
|
||||
for said in realtime::read(text) {
|
||||
match said {
|
||||
realtime::Said::Ping => socket.send(WsMessage::Text(realtime::pong().to_string())).await?,
|
||||
realtime::Said::Published { channel, event, data } => handle_event(state, account_id, watched, &event, Some(&channel), data)?,
|
||||
// A refused subscription is a channel that will not arrive, and
|
||||
// looks like nothing: said in the log, once for each.
|
||||
realtime::Said::Refused(why) => tracing::warn!("kick[{account_id}]: the chat service refused a request: {why}"),
|
||||
realtime::Said::Disconnect(why) => anyhow::bail!("Kick's chat ended the connection: {why}"),
|
||||
realtime::Said::Connected { .. } | realtime::Said::Other => {}
|
||||
}
|
||||
}
|
||||
handle_event(state, account_id, watched, &frame.event, frame.channel.as_deref(), payload)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The channel behind a conversation, waiting a little for it to arrive.
|
||||
@@ -792,7 +806,7 @@ pub(super) fn id_in_subscription(name: &str) -> Option<Subscribed> {
|
||||
if let Some(rest) = name.strip_prefix("chatrooms.").or_else(|| name.strip_prefix("chatroom_")) {
|
||||
return rest.split('.').next()?.parse().ok().map(Subscribed::Chatroom);
|
||||
}
|
||||
if let Some(rest) = name.strip_prefix("channel.") {
|
||||
if let Some(rest) = name.strip_prefix("channel.").or_else(|| name.strip_prefix("channel_")) {
|
||||
return rest.split('.').next()?.parse().ok().map(Subscribed::Channel);
|
||||
}
|
||||
if let Some(rest) = name.strip_prefix("predictions-channel-") {
|
||||
@@ -816,7 +830,7 @@ mod tests {
|
||||
let none = serde_json::json!({});
|
||||
// All four names this connection subscribes to resolve to the same
|
||||
// streamer, across both of Kick's number spaces.
|
||||
for name in ["chatrooms.2393554.v2", "chatrooms.2393554", "chatroom_2393554", "channel.2401072"] {
|
||||
for name in ["chatrooms.2393554.v2", "chatroom_2393554", "channel.2401072"] {
|
||||
assert_eq!(channel_of(&w, &Some(name.into()), &none).as_deref(), Some("odablock"), "for {name}");
|
||||
}
|
||||
}
|
||||
@@ -876,21 +890,73 @@ mod tests {
|
||||
names.sort();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec!["channel.2401072", "chatroom_2393554", "chatrooms.2393554", "chatrooms.2393554.v2"]
|
||||
vec!["channel.2401072", "channel_2401072", "chatroom_2393554", "chatrooms.2393554.v2", "predictions-channel-2401072"]
|
||||
);
|
||||
// And it is gone from both maps, so a straggling event has nowhere to
|
||||
// land rather than reopening the buffer that was just closed.
|
||||
assert_eq!(channel_of(&w, &Some("chatrooms.2393554".into()), &serde_json::json!({})), None);
|
||||
assert_eq!(channel_of(&w, &Some("chatrooms.2393554.v2".into()), &serde_json::json!({})), None);
|
||||
assert!(w.remove("odablock").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_id_out_of_a_subscription_name() {
|
||||
assert_eq!(id_in_subscription("chatrooms.123.v2"), Some(Subscribed::Chatroom(123)));
|
||||
assert_eq!(id_in_subscription("chatrooms.123"), Some(Subscribed::Chatroom(123)));
|
||||
assert_eq!(id_in_subscription("chatroom_123"), Some(Subscribed::Chatroom(123)));
|
||||
assert_eq!(id_in_subscription("channel.123"), Some(Subscribed::Channel(123)));
|
||||
assert_eq!(id_in_subscription("channel_123"), Some(Subscribed::Channel(123)));
|
||||
assert_eq!(id_in_subscription("chatrooms.abc"), None);
|
||||
assert_eq!(id_in_subscription("something-else"), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod live {
|
||||
use super::*;
|
||||
use crate::backend::kick::testkit::simulated_daemon;
|
||||
use crate::runtime::ConnState;
|
||||
|
||||
/// Against the real service: a signed-out account opens a live channel and
|
||||
/// has to hear somebody talk in it. Nothing is sent. Ignored because it
|
||||
/// needs the network and a channel that is live and busy.
|
||||
///
|
||||
/// ```text
|
||||
/// MOHO_KICK_CHANNEL=xqc cargo test --release kick::socket::live -- --ignored --nocapture
|
||||
/// ```
|
||||
#[tokio::test]
|
||||
#[ignore = "needs the network and a live Kick channel"]
|
||||
async fn a_signed_out_reader_hears_a_live_channel() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let _ = tracing_subscriber::fmt().with_env_filter("nobilis::backend::kick=info").with_test_writer().try_init();
|
||||
let channel = std::env::var("MOHO_KICK_CHANNEL").unwrap_or_else(|_| "xqc".to_string());
|
||||
let state = simulated_daemon("kick-live-probe");
|
||||
let config = KickAccountConfig { username: "reader".into(), channels: vec![channel.clone()], ..Default::default() };
|
||||
let id = config.account_id();
|
||||
state.accounts.add_kick(config.clone()).unwrap();
|
||||
spawn(state.clone(), config);
|
||||
let buffer = crate::model::buffer_id(&id, &channel);
|
||||
let mut heard = 0;
|
||||
for _ in 0..240 {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
heard = state.store.get_backlog(&buffer, 0, 200).map(|m| m.iter().filter(|m| m.kind == "chat" || m.kind == "message").count()).unwrap_or(0);
|
||||
if heard >= 5 && state.runtime.conn_state(&id) == Some(ConnState::Connected) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// And for as long as asked, to see that the keepalive is answered and
|
||||
// that chat keeps arriving after the first minute.
|
||||
let secs: u64 = std::env::var("MOHO_KICK_SECS").ok().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
let before = heard;
|
||||
tokio::time::sleep(Duration::from_secs(secs)).await;
|
||||
let after = state.store.get_backlog(&buffer, 0, 1000).map(|m| m.len()).unwrap_or(0);
|
||||
println!("after {secs}s more: {} -> {after} message(s); state {:?}", before, state.runtime.conn_state(&id));
|
||||
if secs > 0 {
|
||||
assert!(after > before, "chat kept arriving");
|
||||
assert_eq!(state.runtime.conn_state(&id), Some(ConnState::Connected));
|
||||
}
|
||||
println!("state {:?}; heard {heard} message(s) in {channel}", state.runtime.conn_state(&id));
|
||||
for m in state.store.get_backlog(&buffer, 0, 5).unwrap_or_default() {
|
||||
println!(" {}: {}", m.from, m.body.chars().take(80).collect::<String>());
|
||||
}
|
||||
assert!(heard >= 5, "a live channel's chat should be arriving");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ use super::*;
|
||||
/// real channel - they happen when a streamer decides to run one - so the
|
||||
/// only way to know this code works is to hand it the payload and look at
|
||||
/// what lands in the store. That is what these do.
|
||||
pub(super) fn simulated_daemon(name: &str) -> AppState {
|
||||
pub(crate) fn simulated_daemon(name: &str) -> AppState {
|
||||
// The same convention the audio and Tor probes use for a scratch
|
||||
// directory - a dependency for one test would be a poor trade.
|
||||
let dir = std::env::temp_dir().join(format!("nobilis-kick-{name}-{}", std::process::id()));
|
||||
|
||||
+85
-13
@@ -79,13 +79,39 @@ pub async fn negotiate(http: &reqwest::Client, token: &str, user_id: &str) -> Re
|
||||
|
||||
/// A token for one livestream's channel - what actually says who is watching.
|
||||
pub async fn channel_token(http: &reqwest::Client, token: &str, livestream_id: u64) -> Result<String> {
|
||||
let body = serde_json::json!({ "channel": channel_name(livestream_id) });
|
||||
subscription_token(http, token, &channel_name(livestream_id)).await
|
||||
}
|
||||
|
||||
/// A token to subscribe to one of Kick's private channels, by name.
|
||||
pub async fn subscription_token(http: &reqwest::Client, token: &str, channel: &str) -> Result<String> {
|
||||
let body = serde_json::json!({ "channel": channel });
|
||||
let resp = post(http, token, &format!("{WEB_ROOT}/api/v1/realtime/auth/channel"), body)
|
||||
.await
|
||||
.context("asking Kick to let us watch")?;
|
||||
resp["data"]["token"].as_str().map(str::to_string).context("Kick sent no subscription token")
|
||||
}
|
||||
|
||||
/// Where Kick says what this account's points now are, in every channel at once.
|
||||
///
|
||||
/// Keyed by the *account*, not the streamer - Kick's own app subscribes to it
|
||||
/// the same way, once - and each event names the channel it is about.
|
||||
pub fn points_channel_name(user_id: &str) -> String {
|
||||
format!("private-channelpoints-{user_id}")
|
||||
}
|
||||
|
||||
/// What a points event says: which channel, and what the balance there now is.
|
||||
///
|
||||
/// Kick's app does exactly this with it - finds the channel by `channel_id`
|
||||
/// and replaces the balance it holds with `balance`. A balance is a whole
|
||||
/// number of points, though the number is read leniently: it has come as a
|
||||
/// float elsewhere on this API.
|
||||
pub fn points_update(data: &serde_json::Value) -> Option<(u64, i64)> {
|
||||
let number = |v: &serde_json::Value| v.as_i64().or_else(|| v.as_f64().map(|f| f as i64)).or_else(|| v.as_str().and_then(|s| s.parse().ok()));
|
||||
let channel = data.get("channel_id").and_then(|v| v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))?;
|
||||
let balance = number(data.get("balance")?)?;
|
||||
Some((channel, balance))
|
||||
}
|
||||
|
||||
/// The channel watch time is counted on.
|
||||
pub fn channel_name(livestream_id: u64) -> String {
|
||||
format!("private-livestream.{livestream_id}")
|
||||
@@ -112,16 +138,17 @@ async fn post(http: &reqwest::Client, token: &str, url: &str, body: serde_json::
|
||||
///
|
||||
/// Deliberately the smallest possible client. This subscribes and then listens
|
||||
/// to nothing: the point is to be present, not to read what the channel
|
||||
/// publishes - chat already arrives on the Pusher socket, and duplicating it
|
||||
/// publishes - chat already arrives on the chat socket, and duplicating it
|
||||
/// here would mean two sources for one conversation.
|
||||
pub mod frames {
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Opens the connection. No token: the negotiation answers with a URL and
|
||||
/// nothing else, so the connection is anonymous and the per-channel token
|
||||
/// below is what identifies anybody.
|
||||
pub fn connect(id: u64) -> Value {
|
||||
json!({ "id": id, "connect": {} })
|
||||
/// Opens the connection, with the token the service gives this account for
|
||||
/// it. The service closes one that has none, and one whose token has run
|
||||
/// out - which is why a session here is ended and begun again well before
|
||||
/// that.
|
||||
pub fn connect(id: u64, token: &str) -> Value {
|
||||
json!({ "id": id, "connect": { "token": token, "name": "js" } })
|
||||
}
|
||||
|
||||
pub fn subscribe(id: u64, channel: &str, token: &str) -> Value {
|
||||
@@ -160,6 +187,17 @@ pub mod frames {
|
||||
/// two viewers, and merging them would credit one for the other's time.
|
||||
pub type Watching = std::collections::BTreeMap<String, u64>;
|
||||
|
||||
/// Tells the window what this account's points in a channel now are.
|
||||
fn announce_points(state: &crate::state::AppState, account_id: &str, data: &serde_json::Value) {
|
||||
let Some((channel_id, balance)) = points_update(data) else {
|
||||
tracing::debug!("kick[{account_id}]: a points event with no channel or balance: {data}");
|
||||
return;
|
||||
};
|
||||
// A channel this window has not opened has nowhere to show it.
|
||||
let Some(buffer_id) = state.runtime.kick_buffer_of_channel(account_id, channel_id) else { return };
|
||||
state.events.emit("kickPoints", serde_json::json!({ "bufferId": buffer_id, "points": balance }));
|
||||
}
|
||||
|
||||
/// Keeps one connection open for as long as anything is being watched.
|
||||
///
|
||||
/// Restarted rather than repaired when the socket dies: a subscription is
|
||||
@@ -200,15 +238,31 @@ async fn session(state: &crate::state::AppState, account_id: &str, user_id: &str
|
||||
next_id += 1;
|
||||
next_id
|
||||
};
|
||||
socket.send(Message::Text(frames::connect(id()).to_string())).await.context("opening the connection")?;
|
||||
let connection_token = super::realtime::token(&http, user_id, Some(token)).await?;
|
||||
socket.send(Message::Text(frames::connect(id(), &connection_token).to_string())).await.context("opening the connection")?;
|
||||
// The token is good for forty-five minutes. A session that has been up for
|
||||
// thirty is ended, and the next one opens with a new one.
|
||||
let begun = std::time::Instant::now();
|
||||
|
||||
// What is subscribed now, so the set on screen can be followed as it
|
||||
// changes without tearing the connection down.
|
||||
let mut subscribed: std::collections::BTreeMap<String, u64> = Default::default();
|
||||
|
||||
// The balance, pushed as it changes: the earn every five minutes, a bet, a
|
||||
// reward bought somewhere else. The window asks for it too, once a minute,
|
||||
// which is the fallback for a channel that is not live and so is not here.
|
||||
let points = points_channel_name(user_id);
|
||||
match subscription_token(&http, token, &points).await {
|
||||
Ok(jwt) => {
|
||||
socket.send(Message::Text(frames::subscribe(id(), &points, &jwt).to_string())).await?;
|
||||
tracing::info!("kick[{account_id}]: following {points}");
|
||||
}
|
||||
Err(e) => tracing::debug!("kick[{account_id}]: cannot follow points: {e:#}"),
|
||||
}
|
||||
|
||||
loop {
|
||||
let wanted = state.runtime.kick_watching(account_id);
|
||||
if wanted.is_empty() {
|
||||
if wanted.is_empty() || begun.elapsed() > std::time::Duration::from_secs(30 * 60) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -251,6 +305,12 @@ async fn session(state: &crate::state::AppState, account_id: &str, user_id: &str
|
||||
Message::Text(text) => {
|
||||
for line in text.lines().filter(|l| !l.trim().is_empty()) {
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else { continue };
|
||||
if let super::realtime::Said::Published { channel, event, data } = super::realtime::read(line).into_iter().next().unwrap_or(super::realtime::Said::Other) {
|
||||
if channel == points && event.ends_with("ChannelPointsUpdatedEvent") {
|
||||
announce_points(state, account_id, &data);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if frames::is_ping(&value) {
|
||||
socket.send(Message::Text(frames::pong().to_string())).await?;
|
||||
} else if let Some(why) = frames::error_in(&value) {
|
||||
@@ -291,6 +351,19 @@ mod tests {
|
||||
assert_eq!(frames::pong(), json!({}));
|
||||
}
|
||||
|
||||
/// As Kick's own app reads it: the channel, and the balance that replaces the one held.
|
||||
#[test]
|
||||
fn a_points_event_names_the_channel_and_the_new_balance() {
|
||||
assert_eq!(points_channel_name("1234"), "private-channelpoints-1234");
|
||||
assert_eq!(points_update(&json!({ "channel_id": 5312671, "balance": 1450 })), Some((5312671, 1450)));
|
||||
// However the number is written.
|
||||
assert_eq!(points_update(&json!({ "channel_id": "668", "balance": 12.0 })), Some((668, 12)));
|
||||
assert_eq!(points_update(&json!({ "channel_id": 668, "balance": "90" })), Some((668, 90)));
|
||||
// Without both it says nothing.
|
||||
assert_eq!(points_update(&json!({ "channel_id": 668 })), None);
|
||||
assert_eq!(points_update(&json!({ "balance": 5 })), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_subscription_carries_the_token_that_names_the_watcher() {
|
||||
let f = frames::subscribe(2, "private-livestream.1", "jwt");
|
||||
@@ -299,11 +372,10 @@ mod tests {
|
||||
assert_eq!(f["id"], json!(2));
|
||||
}
|
||||
|
||||
/// The connection itself is anonymous - the negotiation answers with a URL
|
||||
/// and no token, so nothing is sent here.
|
||||
/// The service closes a connection that is opened with no token.
|
||||
#[test]
|
||||
fn the_connection_carries_no_token() {
|
||||
assert_eq!(frames::connect(1), json!({ "id": 1, "connect": {} }));
|
||||
fn the_connection_carries_its_token() {
|
||||
assert_eq!(frames::connect(1, "jwt"), json!({ "id": 1, "connect": { "token": "jwt", "name": "js" } }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -595,21 +595,6 @@ async fn openid_again(state: &AppState, account_id: &str) -> anyhow::Result<Valu
|
||||
.context("asking the homeserver to vouch for this account")
|
||||
}
|
||||
|
||||
/// Who to call, and who to wait for./// Who to call, and who to wait for./// Who to call, and who to wait for.
|
||||
///
|
||||
/// Both ends of every pair see each other arrive, and if both call, both
|
||||
/// answer, and the call collides with itself. So the rule is one line and the
|
||||
/// same on both sides: the smaller id calls the larger. It has to be a
|
||||
/// property of the pair rather than of who arrived first, because "first" is
|
||||
/// not something two clients can agree on.
|
||||
///
|
||||
/// Kept for the tests that pin the rule down; nothing in the call path
|
||||
/// asks it yet, which is its own gap rather than a reason to lose it.
|
||||
#[cfg(test)]
|
||||
pub fn should_offer(own_key: &str, their_key: &str) -> bool {
|
||||
own_key < their_key
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod member_tests {
|
||||
use super::*;
|
||||
@@ -699,17 +684,6 @@ mod member_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exactly_one_end_of_every_pair_offers() {
|
||||
// Whichever way round the two clients ask, they must not agree.
|
||||
assert!(should_offer("@a:example.org|AAA", "@b:example.org|BBB"));
|
||||
assert!(!should_offer("@b:example.org|BBB", "@a:example.org|AAA"));
|
||||
// Including two devices of the same person, which is the case that
|
||||
// makes a user-id comparison alone wrong.
|
||||
assert!(should_offer("@a:example.org|AAA", "@a:example.org|ZZZ"));
|
||||
assert!(!should_offer("@a:example.org|ZZZ", "@a:example.org|AAA"));
|
||||
}
|
||||
|
||||
/// The fallback rides along whether or not the homeserver offered a relay:
|
||||
/// it is needed exactly when the relay is missing or unreachable, and the
|
||||
/// two look the same from here.
|
||||
|
||||
@@ -118,35 +118,6 @@ pub(super) async fn record(state: &AppState, account_id: &str, peer_user_id: &st
|
||||
}
|
||||
}
|
||||
|
||||
/// The list without a room, wherever it appears.
|
||||
///
|
||||
/// A person is dropped entirely when their last room goes: an empty array
|
||||
/// left behind is a person the account claims to have a DM list for and no
|
||||
/// DMs with, which other clients render as an empty conversation.
|
||||
///
|
||||
/// Only the tests call it. The live path rewrites the whole map rather than
|
||||
/// subtracting from it, which is a difference worth keeping an eye on: this
|
||||
/// is the rule the tests pin down, and nothing enforces that the live path
|
||||
/// still agrees with it.
|
||||
#[cfg(test)]
|
||||
pub fn without(content: &Value, room_id: &str) -> Value {
|
||||
let Some(by_user) = content.as_object() else { return json!({}) };
|
||||
let mut out = Map::new();
|
||||
for (user_id, rooms) in by_user {
|
||||
let kept: Vec<Value> = rooms
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|r| r.as_str() != Some(room_id))
|
||||
.cloned()
|
||||
.collect();
|
||||
if !kept.is_empty() {
|
||||
out.insert(user_id.clone(), Value::Array(kept));
|
||||
}
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -194,17 +165,4 @@ mod tests {
|
||||
let again = with(&after, "@anna:poa.st", "!new:poa.st");
|
||||
assert_eq!(again["@anna:poa.st"], json!(["!one:poa.st", "!new:poa.st"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_room_takes_the_person_with_it_when_it_was_the_last_one() {
|
||||
let before = json!({ "@anna:poa.st": ["!one:poa.st", "!two:poa.st"], "@bob:poa.st": ["!three:poa.st"] });
|
||||
let after = without(&before, "!three:poa.st");
|
||||
assert_eq!(after["@anna:poa.st"], json!(["!one:poa.st", "!two:poa.st"]));
|
||||
// Not an empty list: a person with no rooms reads to other clients
|
||||
// as a conversation with nothing in it.
|
||||
assert!(after.get("@bob:poa.st").is_none());
|
||||
|
||||
let one_left = without(&before, "!one:poa.st");
|
||||
assert_eq!(one_left["@anna:poa.st"], json!(["!two:poa.st"]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,123 +131,6 @@ mod live {
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Phase-3 manual live check: creates a real *encrypted* room, starts
|
||||
/// nobilis's own sync loop against it (as one "device" of the test
|
||||
/// account), then idles for up to 3 minutes printing every message
|
||||
/// this backend receives and decrypts. Meant to be run in the
|
||||
/// background while a second, independent Matrix client (e.g. Element
|
||||
/// Web, logged into the same account as a genuinely different device)
|
||||
/// joins the room and sends a real message into it - the room id is
|
||||
/// printed up front so it can be found there. This is the actual hard
|
||||
/// gate for "full E2EE from day one": a message this backend never
|
||||
/// touched the encryption of, decrypted correctly, proves the whole
|
||||
/// device-key-upload + key-claim + Megolm-session path works against
|
||||
/// a real second device, not just against itself.
|
||||
///
|
||||
/// MATRIX_USERNAME=... MATRIX_PASSWORD=... \
|
||||
/// cargo test --release -- --ignored --nocapture matrix_e2ee_read_probe
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn matrix_e2ee_read_probe() {
|
||||
let Ok(username) = std::env::var("MATRIX_USERNAME") else {
|
||||
println!("MATRIX_USERNAME not set, skipping");
|
||||
return;
|
||||
};
|
||||
let Ok(password) = std::env::var("MATRIX_PASSWORD") else {
|
||||
println!("MATRIX_PASSWORD not set, skipping");
|
||||
return;
|
||||
};
|
||||
let homeserver = std::env::var("MATRIX_HOMESERVER").unwrap_or_else(|_| "https://matrix.org".to_string());
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let login = auth::login(&homeserver, &username, &password, None).await.expect("login failed");
|
||||
println!("logged in as {} (device {})", login.user_id, login.device_id);
|
||||
|
||||
let create_resp = http::post_json(
|
||||
&format!("{}/_matrix/client/v3/createRoom", homeserver.trim_end_matches('/')),
|
||||
Some(&login.access_token),
|
||||
serde_json::json!({
|
||||
"preset": "private_chat",
|
||||
"name": "nobilis-matrix-phase3-probe",
|
||||
"initial_state": [
|
||||
{ "type": "m.room.encryption", "state_key": "", "content": { "algorithm": "m.megolm.v1.aes-sha2" } }
|
||||
],
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("createRoom failed");
|
||||
let room_id = create_resp["room_id"].as_str().expect("no room_id in createRoom response").to_string();
|
||||
println!("created ENCRYPTED test room {room_id} - join it with a second client (same account, different device) and send a message now.");
|
||||
|
||||
let data_dir = std::env::temp_dir().join(format!("nobilis-matrix-e2ee-probe-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&data_dir).unwrap();
|
||||
let state = AppState {
|
||||
store: std::sync::Arc::new(crate::store::Store::open(&data_dir.join("scrollback.db")).expect("opening store")),
|
||||
accounts: std::sync::Arc::new(crate::accounts::AccountStore::open(data_dir.join("accounts.toml")).expect("opening accounts")),
|
||||
events: crate::events::EventBus::new(),
|
||||
runtime: std::sync::Arc::new(crate::runtime::Runtime::new()),
|
||||
tor: std::sync::Arc::new(crate::net::tor::TorManager::new(&data_dir)),
|
||||
shutdown: std::sync::Arc::new(tokio::sync::Notify::new()),
|
||||
voice: std::sync::Arc::new(crate::backend::discord::voice::VoiceState::new()),
|
||||
voice_prefs: std::sync::Arc::new(crate::audio::VoicePrefsStore::open(data_dir.join("voice.toml"))),
|
||||
dcc_prefs: std::sync::Arc::new(crate::backend::irc::dcc::DccPrefsStore::open(data_dir.join("dcc.toml"))),
|
||||
irc_sts: std::sync::Arc::new(crate::backend::irc::sts::StsStore::open(data_dir.join("irc-sts.toml"))),
|
||||
highlights: std::sync::Arc::new(crate::highlights::HighlightStore::open(data_dir.join("highlights.toml"))),
|
||||
ignores: std::sync::Arc::new(crate::ignores::IgnoreStore::open(data_dir.join("ignores.toml"))),
|
||||
};
|
||||
|
||||
let config = MatrixAccountConfig {
|
||||
use_tor: false,
|
||||
strict_route: false,
|
||||
homeserver_url: homeserver.clone(),
|
||||
user_id: login.user_id.clone(),
|
||||
password: password.clone(),
|
||||
access_token: login.access_token.clone(),
|
||||
device_id: login.device_id.clone(),
|
||||
next_batch: None,
|
||||
used_sliding_sync: false,
|
||||
oauth_refresh_token: String::new(),
|
||||
oauth_client_id: String::new(),
|
||||
prefer_sliding_sync: false,
|
||||
dehydration_enabled: false,
|
||||
display_name: None,
|
||||
rtc_focus_url: None,
|
||||
};
|
||||
let saved = state.accounts.add_matrix(config).expect("add_matrix failed");
|
||||
spawn(state.clone(), saved);
|
||||
|
||||
let buffer_name = "nobilis-matrix-phase3-probe";
|
||||
let mut buffer_id = None;
|
||||
for _ in 0..30 {
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
if let Some(b) = state.runtime.list_buffers().into_iter().find(|b| b.name == buffer_name) {
|
||||
buffer_id = Some(b.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let buffer_id = buffer_id.expect("buffer for the test room never appeared within 30s");
|
||||
println!("buffer created: {buffer_id} - waiting up to 180s for a message from a second device...");
|
||||
|
||||
let mut seen_bodies: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
for i in 0..180 {
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
if let Ok(msgs) = state.store.get_backlog(&buffer_id, 0, 20) {
|
||||
for m in &msgs {
|
||||
if seen_bodies.insert(m.id.clone()) {
|
||||
println!("[{i}s] received from {}: {}", m.from, m.body);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = http::post_json(
|
||||
&format!("{}/_matrix/client/v3/rooms/{}/leave", homeserver.trim_end_matches('/'), room_id),
|
||||
Some(&login.access_token),
|
||||
serde_json::json!({}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Phase-3/phase-4 combined live check, fully automated (no manual
|
||||
/// second client needed): "device A" is nobilis's own real production
|
||||
/// connect loop (spawn/run_sync, same code path a real account uses);
|
||||
|
||||
@@ -48,6 +48,23 @@ fn pending() -> &'static std::sync::Mutex<HashMap<String, Vec<Locked>>> {
|
||||
PENDING.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// Adds a message to those waiting, and says whether it was new.
|
||||
///
|
||||
/// The same event twice is the same event: a sync that overlaps what was
|
||||
/// already seen would otherwise ask for one key repeatedly. And the list is
|
||||
/// held to `MAX_PENDING`, oldest first.
|
||||
fn hold(waiting: &mut Vec<Locked>, locked: Locked) -> bool {
|
||||
if waiting.iter().any(|l| l.event_id == locked.event_id) {
|
||||
return false;
|
||||
}
|
||||
waiting.push(locked);
|
||||
if waiting.len() > MAX_PENDING {
|
||||
let over = waiting.len() - MAX_PENDING;
|
||||
waiting.drain(..over);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Remembers a message that could not be read, and asks for its key.
|
||||
///
|
||||
/// Best-effort on both counts. A key request that cannot be sent is not worth
|
||||
@@ -67,20 +84,14 @@ pub async fn note_locked(
|
||||
{
|
||||
let mut all = pending().lock().unwrap();
|
||||
let waiting = all.entry(account_id.to_string()).or_default();
|
||||
// The same event twice is the same event: a sync that overlaps what
|
||||
// was already seen would otherwise ask for one key repeatedly.
|
||||
if waiting.iter().any(|l| l.event_id == event_id) {
|
||||
return;
|
||||
}
|
||||
waiting.push(Locked {
|
||||
let locked = Locked {
|
||||
buffer_id: buffer_id.to_string(),
|
||||
room_id: room_id.to_string(),
|
||||
event_id: event_id.to_string(),
|
||||
event: event.clone(),
|
||||
});
|
||||
if waiting.len() > MAX_PENDING {
|
||||
let over = waiting.len() - MAX_PENDING;
|
||||
waiting.drain(..over);
|
||||
};
|
||||
if !hold(waiting, locked) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,10 +169,9 @@ mod tests {
|
||||
/// neighbours, and what anybody can see is a screen's worth.
|
||||
#[test]
|
||||
fn the_oldest_are_dropped_rather_than_growing_without_end() {
|
||||
let mut waiting: Vec<Locked> = (0..MAX_PENDING + 50).map(|i| locked(&format!("${i}"))).collect();
|
||||
if waiting.len() > MAX_PENDING {
|
||||
let over = waiting.len() - MAX_PENDING;
|
||||
waiting.drain(..over);
|
||||
let mut waiting: Vec<Locked> = Vec::new();
|
||||
for i in 0..MAX_PENDING + 50 {
|
||||
assert!(hold(&mut waiting, locked(&format!("${i}"))));
|
||||
}
|
||||
assert_eq!(waiting.len(), MAX_PENDING);
|
||||
// The newest survive, which are the ones somebody is looking at.
|
||||
@@ -174,11 +184,8 @@ mod tests {
|
||||
#[test]
|
||||
fn the_same_message_is_only_waited_on_once() {
|
||||
let mut waiting = vec![locked("$a")];
|
||||
for id in ["$a", "$b", "$a"] {
|
||||
if !waiting.iter().any(|l| l.event_id == id) {
|
||||
waiting.push(locked(id));
|
||||
}
|
||||
}
|
||||
let news: Vec<bool> = ["$a", "$b", "$a"].iter().map(|id| hold(&mut waiting, locked(id))).collect();
|
||||
assert_eq!(news, [false, true, false], "only a message not already waiting is news");
|
||||
assert_eq!(waiting.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,6 +346,8 @@ mod place_tests {
|
||||
assert!(parse_place("the pub").is_none());
|
||||
assert!(parse_place("91.0, 0.0").is_none());
|
||||
assert!(parse_place("0.0, 181.0").is_none());
|
||||
// A latitude past the pole is somebody's phone number, not a place.
|
||||
assert!(parse_place("910, 20").is_none());
|
||||
}
|
||||
|
||||
/// Coordinates are written with a space after the comma as often as not,
|
||||
@@ -363,6 +365,11 @@ mod place_tests {
|
||||
assert!(label.is_empty());
|
||||
|
||||
assert!(split_place_and_label("just some words").is_none());
|
||||
|
||||
// A map link is a place in one word, and the name still follows it.
|
||||
let (place, label) = split_place_and_label("https://www.openstreetmap.org/#map=15/51.5074/-0.1278 home").expect("a place");
|
||||
assert_eq!(place, "https://www.openstreetmap.org/#map=15/51.5074/-0.1278");
|
||||
assert_eq!(label, "home");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -472,47 +479,6 @@ mod tests {
|
||||
assert!(read_pack(&serde_json::json!({ "images": [] }), "x").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_place_is_read_from_what_people_actually_paste() {
|
||||
assert_eq!(parse_place("51.5074, -0.1278"), Some((51.5074, -0.1278)));
|
||||
assert_eq!(parse_place("geo:51.5074,-0.1278;u=35"), Some((51.5074, -0.1278)));
|
||||
assert_eq!(
|
||||
parse_place("https://www.openstreetmap.org/#map=15/51.5074/-0.1278"),
|
||||
Some((51.5074, -0.1278))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_place("https://www.google.com/maps/@51.5074,-0.1278,15z"),
|
||||
Some((51.5074, -0.1278))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_numbers_that_are_not_a_place_are_refused() {
|
||||
// A latitude past the pole is somebody's phone number, not a place.
|
||||
assert_eq!(parse_place("910, 20"), None);
|
||||
assert_eq!(parse_place("hello"), None);
|
||||
assert_eq!(parse_place(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_place_and_its_name_come_apart() {
|
||||
// The comma-space form is how people write coordinates, so the place
|
||||
// is two words and the name is the rest.
|
||||
assert_eq!(
|
||||
split_place_and_label("51.5074, -0.1278 the pub"),
|
||||
Some(("51.5074, -0.1278".to_string(), "the pub".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
split_place_and_label("51.5074,-0.1278"),
|
||||
Some(("51.5074,-0.1278".to_string(), String::new()))
|
||||
);
|
||||
assert_eq!(
|
||||
split_place_and_label("https://www.openstreetmap.org/#map=15/51.5074/-0.1278 home"),
|
||||
Some(("https://www.openstreetmap.org/#map=15/51.5074/-0.1278".to_string(), "home".to_string()))
|
||||
);
|
||||
assert_eq!(split_place_and_label("where are you"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_location_event_carries_both_halves() {
|
||||
let event = location_event(51.5074, -0.1278, "the pub");
|
||||
|
||||
@@ -24,7 +24,7 @@ pub(super) fn relation_preview(state: &AppState, buffer_id: &str, content: &Valu
|
||||
let in_thread = thread.is_some();
|
||||
let target = thread.or_else(|| protocol::reply_target(content))?;
|
||||
match state.store.get_message(buffer_id, target) {
|
||||
Ok(Some(m)) => Some(crate::model::ReplyPreview { id: target.to_string(), from: m.from, body: m.body, thread: in_thread, forwarded: false }),
|
||||
Ok(Some(m)) => Some(crate::model::ReplyPreview { id: target.to_string(), from: m.from, body: m.body, thread: in_thread, forwarded: false, thread_root: None, chat: None }),
|
||||
_ => Some(crate::model::ReplyPreview { id: target.to_string(), thread: in_thread, ..Default::default() }),
|
||||
}
|
||||
}
|
||||
@@ -348,7 +348,7 @@ pub(super) async fn handle_timeline_event(
|
||||
badges: vec![crate::backend::kick::api::Badge {
|
||||
kind: "unverified".to_string(),
|
||||
text: "Sent from a device you have not verified".to_string(),
|
||||
count: None,
|
||||
..Default::default()
|
||||
}],
|
||||
}),
|
||||
_ => None,
|
||||
|
||||
@@ -15,3 +15,6 @@ pub mod irc;
|
||||
pub mod kick;
|
||||
pub mod matrix;
|
||||
pub mod sneedchat;
|
||||
pub mod steam;
|
||||
pub mod telegram;
|
||||
pub mod twitch;
|
||||
|
||||
@@ -237,7 +237,7 @@ pub(super) async fn handle_frame(state: &AppState, http: &http::HttpClient, host
|
||||
let resp = protocol::ServerResponse::parse(frame);
|
||||
|
||||
if !resp.users_joined.is_empty() || !resp.users_left.is_empty() {
|
||||
update_roster(state, account_id, buffer_name, &resp.users_joined, &resp.users_left);
|
||||
update_roster(state, http, host, account_id, buffer_name, &resp.users_joined, &resp.users_left);
|
||||
}
|
||||
|
||||
if let Some(text) = &resp.plaintext {
|
||||
|
||||
+425
-12
@@ -13,9 +13,18 @@ use super::*;
|
||||
/// migrate_caches_to_xdg_cache_dir moves any pre-existing directory here
|
||||
/// once at startup.
|
||||
pub(super) fn avatar_cache_dir() -> std::path::PathBuf {
|
||||
// A test that fetches a picture must not leave it in somebody's real cache.
|
||||
#[cfg(test)]
|
||||
if let Some(dir) = TEST_CACHE.lock().unwrap().clone() {
|
||||
return dir;
|
||||
}
|
||||
dirs::cache_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".cache")).join("nobilis").join("sneedchat-avatars")
|
||||
}
|
||||
|
||||
/// Where tests keep avatars instead, when one has said so.
|
||||
#[cfg(test)]
|
||||
pub(super) static TEST_CACHE: std::sync::Mutex<Option<std::path::PathBuf>> = std::sync::Mutex::new(None);
|
||||
|
||||
/// Evicts the oldest-written files in `dir` until it's back under
|
||||
/// `max_bytes`. Oldest-by-mtime rather than true LRU (a cache hit doesn't
|
||||
/// bump a file's timestamp) - simpler, no extra dependency, and "the files
|
||||
@@ -382,6 +391,112 @@ pub(super) fn find_attachment_url(text: &str) -> Option<(&str, &str, &str)> {
|
||||
|
||||
pub const KIWIFARMS_CLEARNET_HOST: &str = "kiwifarms.st";
|
||||
|
||||
/// The forum's direct file links: what an `[img]` tag or a pasted address holds
|
||||
/// when the picture is served from its uploads host rather than through an
|
||||
/// attachment page.
|
||||
///
|
||||
/// `https://uploads.kiwifarms.st/data/attachments/9671/9671535-<hash>-alt.avif?hash=...`
|
||||
/// for a picture - the `-alt` is XenForo's AVIF copy of one - and
|
||||
/// `.../data/video/9667/9667943-<hash>.mp4?hash=...` for a video. The `hash` is
|
||||
/// what the host asks for in place of a login, so the address is fetched with it
|
||||
/// and as written.
|
||||
///
|
||||
/// Recognised so they can be fetched the way an attachment page is: through the
|
||||
/// account's own route, to a file kept here. Left to the window they are asked
|
||||
/// for from the reader's own address, which the host turns away and which, for
|
||||
/// somebody on Tor, is the leak routing was meant to prevent.
|
||||
///
|
||||
/// Returns the matched substring, the attachment's id and extension, and the
|
||||
/// path and query to ask the forum for.
|
||||
pub(super) fn find_upload_url(text: &str) -> Option<(&str, String, String, String)> {
|
||||
let mut best: Option<(&str, String, String, String)> = None;
|
||||
for prefix in [
|
||||
format!("https://uploads.{KIWIFARMS_CLEARNET_HOST}/data/"),
|
||||
format!("https://{DEFAULT_ONION}/data/"),
|
||||
format!("http://{DEFAULT_ONION}/data/"),
|
||||
] {
|
||||
let Some(start) = text.find(&prefix) else { continue };
|
||||
let tail_start = start + prefix.len();
|
||||
let rest = &text[tail_start..];
|
||||
let len = rest.find(|c: char| c.is_whitespace() || matches!(c, '<' | '[' | ']' | '"' | '\'')).unwrap_or(rest.len());
|
||||
let tail = &rest[..len];
|
||||
let (path, query) = tail.split_once('?').map_or((tail, ""), |(p, q)| (p, q));
|
||||
// `attachments/<bucket>/<id>-<hash>[-alt].<ext>` or `video/<bucket>/...`.
|
||||
let mut parts = path.split('/');
|
||||
let (Some(kind), Some(bucket), Some(file), None) = (parts.next(), parts.next(), parts.next(), parts.next()) else { continue };
|
||||
if !matches!(kind, "attachments" | "video") || bucket.is_empty() || !bucket.bytes().all(|b| b.is_ascii_digit()) {
|
||||
continue;
|
||||
}
|
||||
let Some((stem, ext)) = file.rsplit_once('.') else { continue };
|
||||
let ext = ext.to_ascii_lowercase();
|
||||
if !ATTACHMENT_EXTS.contains(&ext.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let id: String = stem.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
if id.is_empty() || !stem[id.len()..].starts_with('-') {
|
||||
continue;
|
||||
}
|
||||
let matched_end = tail_start + path.len() + if query.is_empty() { 0 } else { 1 + query.len() };
|
||||
let ask = format!("/data/{path}{}", if query.is_empty() { String::new() } else { format!("?{query}") });
|
||||
if best.as_ref().is_none_or(|(m, ..)| start < text.find(m).unwrap_or(usize::MAX)) {
|
||||
best = Some((&text[start..matched_end], id, ext, ask));
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// What a message links to that is fetched and kept for the window, by either
|
||||
/// kind of link, and where to ask for it.
|
||||
struct Resolvable {
|
||||
/// The link as written, to be taken out of the text once the file is kept.
|
||||
matched: String,
|
||||
id: String,
|
||||
ext: String,
|
||||
fetch_url: String,
|
||||
/// Where the same thing is on the open internet, for "open in browser".
|
||||
page_url: Option<String>,
|
||||
}
|
||||
|
||||
/// The earliest link in `body` of either kind, with where this account asks
|
||||
/// for it: the address it reaches the forum at, which through Tor is the onion
|
||||
/// address, and on the open internet is the forum's own - its uploads host for
|
||||
/// a direct file link.
|
||||
fn find_resolvable(body: &str, host: &str) -> Option<Resolvable> {
|
||||
let page = find_attachment_url(body).and_then(|(matched, id, ext)| {
|
||||
let at = matched.find("/attachments/")?;
|
||||
Some((
|
||||
body.find(matched)?,
|
||||
Resolvable {
|
||||
matched: matched.to_string(),
|
||||
id: id.to_string(),
|
||||
ext: ext.to_ascii_lowercase(),
|
||||
fetch_url: format!("https://{host}{}", &matched[at..]),
|
||||
page_url: Some(format!("https://{KIWIFARMS_CLEARNET_HOST}{}", &matched[at..])),
|
||||
},
|
||||
))
|
||||
});
|
||||
let upload = find_upload_url(body).and_then(|(matched, id, ext, ask)| {
|
||||
let fetch_host = upload_host(host);
|
||||
Some((
|
||||
body.find(matched)?,
|
||||
Resolvable { matched: matched.to_string(), id, ext, fetch_url: format!("https://{fetch_host}{ask}"), page_url: Some(matched.to_string()) },
|
||||
))
|
||||
});
|
||||
match (page, upload) {
|
||||
(Some((a, page)), Some((b, upload))) => Some(if a <= b { page } else { upload }),
|
||||
(Some((_, page)), None) => Some(page),
|
||||
(None, Some((_, upload))) => Some(upload),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The text with a link taken out, and the `[img]` tags that held it with it:
|
||||
/// left behind they are an empty tag, which draws as nothing and still takes a
|
||||
/// line.
|
||||
fn without_link(body: &str, link: &str) -> String {
|
||||
body.replace(&format!("[img]{link}[/img]"), "").replace(link, "").trim().to_string()
|
||||
}
|
||||
|
||||
/// Writes the forum's short attachment form out in full.
|
||||
///
|
||||
/// People type only the path - `/attachments/seal-png.9609686/` - and the
|
||||
@@ -396,6 +511,89 @@ pub const KIWIFARMS_CLEARNET_HOST: &str = "kiwifarms.st";
|
||||
/// with a media extension) - so `https://example.com/attachments/...`, or a
|
||||
/// path somebody is merely talking about, is left as it is.
|
||||
pub(super) fn expand_attachment_paths<'a>(body: &'a str, host: &str) -> std::borrow::Cow<'a, str> {
|
||||
let pages = expand_attachment_pages(body, host);
|
||||
match expand_data_links(&pages, host) {
|
||||
std::borrow::Cow::Borrowed(_) => pages,
|
||||
std::borrow::Cow::Owned(done) => std::borrow::Cow::Owned(done),
|
||||
}
|
||||
}
|
||||
|
||||
/// The address a direct file link is asked for at: the onion address through
|
||||
/// Tor, and on the open internet the forum's uploads host, which is where its
|
||||
/// files are served from.
|
||||
fn upload_host(host: &str) -> String {
|
||||
if host == KIWIFARMS_CLEARNET_HOST { format!("uploads.{KIWIFARMS_CLEARNET_HOST}") } else { host.to_string() }
|
||||
}
|
||||
|
||||
/// The forum's direct file links - `/data/attachments/...` and `/data/video/...`
|
||||
/// - written for this account, whatever way they arrived.
|
||||
///
|
||||
/// Said bare, they are given the address this account reaches the forum at,
|
||||
/// as `/attachments/...` is. Said with a domain - the uploads host, the
|
||||
/// forum's own, or its onion - the domain is taken off first and the same is
|
||||
/// done, so a link copied out of a browser is the same thing as one typed
|
||||
/// short: it goes through the account's own route, and the address of a site
|
||||
/// the account is routed away from never reaches the window at all. Written
|
||||
/// with the domain left on, the window asked the clearnet host for the picture
|
||||
/// from the reader's own address, which it refuses and which is the leak that
|
||||
/// routing exists to prevent.
|
||||
///
|
||||
/// Only a link shaped like one of the forum's files, on one of the forum's
|
||||
/// own addresses (or with none), so a link to anywhere else - or a path
|
||||
/// somebody is merely talking about - is left as it is.
|
||||
fn expand_data_links<'a>(body: &'a str, host: &str) -> std::borrow::Cow<'a, str> {
|
||||
if !body.contains("/data/") {
|
||||
return std::borrow::Cow::Borrowed(body);
|
||||
}
|
||||
let origins: [String; 6] = [
|
||||
format!("https://uploads.{KIWIFARMS_CLEARNET_HOST}"),
|
||||
format!("https://{KIWIFARMS_CLEARNET_HOST}"),
|
||||
format!("http://{KIWIFARMS_CLEARNET_HOST}"),
|
||||
format!("https://www.{KIWIFARMS_CLEARNET_HOST}"),
|
||||
format!("https://{DEFAULT_ONION}"),
|
||||
format!("http://{DEFAULT_ONION}"),
|
||||
];
|
||||
let mut out = String::with_capacity(body.len() + 80);
|
||||
let mut cursor = 0usize;
|
||||
let mut search = 0usize;
|
||||
let mut changed = false;
|
||||
while let Some(found) = body[search..].find("/data/") {
|
||||
let at = search + found;
|
||||
search = at + "/data/".len();
|
||||
let tail = &body[at..];
|
||||
let len = tail.find(|c: char| c.is_whitespace() || matches!(c, '<' | '[' | ']' | '"' | '\'' | ')')).unwrap_or(tail.len());
|
||||
let path = &tail[..len];
|
||||
// Which domain, if any, is written in front of it.
|
||||
let before = &body[..at];
|
||||
let origin = origins.iter().find(|o| before.ends_with(o.as_str()));
|
||||
let start = at - origin.map_or(0, |o| o.len());
|
||||
if origin.is_none() {
|
||||
// Bare: only standing on its own, as the short attachment form must.
|
||||
let standing = matches!(before.chars().next_back(), None | Some(' ' | '\n' | '\t' | ']' | '(' | '>'));
|
||||
if !standing {
|
||||
continue;
|
||||
}
|
||||
} else if start < cursor {
|
||||
continue;
|
||||
}
|
||||
let probe = format!("https://uploads.{KIWIFARMS_CLEARNET_HOST}{path}");
|
||||
if !find_upload_url(&probe).is_some_and(|(matched, ..)| matched == probe) {
|
||||
continue;
|
||||
}
|
||||
out.push_str(&body[cursor..start]);
|
||||
out.push_str(&format!("https://{}{path}", upload_host(host)));
|
||||
cursor = at + len;
|
||||
search = cursor;
|
||||
changed = true;
|
||||
}
|
||||
if !changed {
|
||||
return std::borrow::Cow::Borrowed(body);
|
||||
}
|
||||
out.push_str(&body[cursor..]);
|
||||
std::borrow::Cow::Owned(out)
|
||||
}
|
||||
|
||||
fn expand_attachment_pages<'a>(body: &'a str, host: &str) -> std::borrow::Cow<'a, str> {
|
||||
const MARKER: &str = "/attachments/";
|
||||
if !body.contains(MARKER) {
|
||||
return std::borrow::Cow::Borrowed(body);
|
||||
@@ -440,12 +638,7 @@ pub(super) fn expand_attachment_paths<'a>(body: &'a str, host: &str) -> std::bor
|
||||
/// the same message with a working embed a moment later instead of
|
||||
/// stalling the whole room's message processing on one slow Tor fetch.
|
||||
pub(super) fn spawn_attachment_resolve(state: AppState, http: http::HttpClient, host: &str, buffer_id: String, msg_id: String, body: String) {
|
||||
let Some((matched, id, ext)) = find_attachment_url(&body) else { return };
|
||||
let (matched, id, ext) = (matched.to_string(), id.to_string(), ext.to_ascii_lowercase());
|
||||
let fetch_url = match matched.find("/attachments/") {
|
||||
Some(at) => format!("https://{host}{}", &matched[at..]),
|
||||
None => return,
|
||||
};
|
||||
let Some(Resolvable { matched, id, ext, fetch_url, page_url: page }) = find_resolvable(&body, host) else { return };
|
||||
|
||||
tokio::spawn(async move {
|
||||
let dir = attachment_cache_dir();
|
||||
@@ -464,15 +657,15 @@ pub(super) fn spawn_attachment_resolve(state: AppState, http: http::HttpClient,
|
||||
}
|
||||
}
|
||||
Ok(Ok((status, _))) => {
|
||||
tracing::debug!("sneedchat: attachment {id} fetch returned HTTP {status}");
|
||||
tracing::info!("sneedchat: attachment {id} ({fetch_url}) answered HTTP {status}");
|
||||
None
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::debug!("sneedchat: fetching attachment {id}: {e}");
|
||||
tracing::info!("sneedchat: fetching attachment {id}: {e}");
|
||||
None
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::debug!("sneedchat: attachment {id} fetch timed out");
|
||||
tracing::info!("sneedchat: attachment {id} fetch timed out");
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -484,7 +677,6 @@ pub(super) fn spawn_attachment_resolve(state: AppState, http: http::HttpClient,
|
||||
// and the link it replaces leaves the text, as the old rewrite did.
|
||||
if let Some(local_url) = local_url {
|
||||
let kind = if matches!(ext.as_str(), "mp4" | "webm" | "mov" | "mkv") { "video" } else { "image" };
|
||||
let page = matched.find("/attachments/").map(|at| format!("https://{KIWIFARMS_CLEARNET_HOST}{}", &matched[at..]));
|
||||
let attachment = crate::model::Attachment {
|
||||
kind: kind.to_string(),
|
||||
filename: Some(format!("{id}.{ext}")),
|
||||
@@ -492,8 +684,7 @@ pub(super) fn spawn_attachment_resolve(state: AppState, http: http::HttpClient,
|
||||
url: page,
|
||||
..Default::default()
|
||||
};
|
||||
let new_body = body.replace(&matched, "");
|
||||
state.runtime.update_message_body_only(&state, &buffer_id, &msg_id, new_body.trim());
|
||||
state.runtime.update_message_body_only(&state, &buffer_id, &msg_id, &without_link(&body, &matched));
|
||||
let attachments = vec![attachment];
|
||||
if let Err(e) = state.store.update_message_attachments(&buffer_id, &msg_id, &attachments) {
|
||||
tracing::debug!("sneedchat: recording attachment {id}: {e}");
|
||||
@@ -657,4 +848,226 @@ mod tests {
|
||||
assert_eq!(cached_avatar_file(&dir, "8").await.as_deref(), Some(jpg.as_path()));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_direct_picture_link_from_the_uploads_host_is_found() {
|
||||
let body = "[img]https://uploads.kiwifarms.st/data/attachments/9671/9671535-a2e5ae0d55aadd338162770b3fa79f97-alt.avif?hash=-d_TarFo41[/img]";
|
||||
let (matched, id, ext, ask) = find_upload_url(body).expect("a link");
|
||||
assert_eq!(matched, "https://uploads.kiwifarms.st/data/attachments/9671/9671535-a2e5ae0d55aadd338162770b3fa79f97-alt.avif?hash=-d_TarFo41");
|
||||
assert_eq!((id.as_str(), ext.as_str()), ("9671535", "avif"));
|
||||
assert_eq!(ask, "/data/attachments/9671/9671535-a2e5ae0d55aadd338162770b3fa79f97-alt.avif?hash=-d_TarFo41");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_video_link_with_or_without_its_hash_is_found() {
|
||||
let (matched, id, ext, ask) = find_upload_url("look https://uploads.kiwifarms.st/data/video/9667/9667943-6f0fa527b905c385fe7c60efee19011c.mp4?hash=pj-hdmJ9mQ so good").unwrap();
|
||||
assert!(matched.ends_with("hash=pj-hdmJ9mQ"));
|
||||
assert_eq!((id.as_str(), ext.as_str()), ("9667943", "mp4"));
|
||||
assert!(ask.starts_with("/data/video/9667/"));
|
||||
let (matched, ..) = find_upload_url("@skrod, https://uploads.kiwifarms.st/data/video/9663/9663232-d3e363a800d4828bc2fd29edbde6dfbd.mp4").unwrap();
|
||||
assert!(matched.ends_with(".mp4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_but_the_forums_own_uploads_host_and_shape_is_taken() {
|
||||
for not in [
|
||||
"https://example.com/data/attachments/9671/9671535-abc-alt.avif",
|
||||
"https://uploads.kiwifarms.st/data/attachments/9671/9671535-abc.exe",
|
||||
"https://uploads.kiwifarms.st/data/other/9671/9671535-abc.avif",
|
||||
"https://uploads.kiwifarms.st/data/attachments/x/9671535-abc.avif",
|
||||
"https://uploads.kiwifarms.st/data/attachments/9671/notanumber-abc.avif",
|
||||
"https://uploads.kiwifarms.st/data/attachments/9671/9671535.avif",
|
||||
"https://uploads.kiwifarms.st/data/attachments/9671/../9671535-abc.avif",
|
||||
] {
|
||||
assert!(find_upload_url(not).is_none(), "{not}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_earliest_of_the_two_kinds_is_the_one_fetched() {
|
||||
let page = "https://kiwifarms.st/attachments/cat-png.123/";
|
||||
let upload = "https://uploads.kiwifarms.st/data/attachments/9671/9671535-abc-alt.avif?hash=x";
|
||||
let r = find_resolvable(&format!("{upload} then {page}"), "kiwifarms.st").unwrap();
|
||||
assert_eq!(r.id, "9671535");
|
||||
let r = find_resolvable(&format!("{page} then {upload}"), "kiwifarms.st").unwrap();
|
||||
assert_eq!(r.id, "123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_is_asked_for_where_this_account_reaches_the_forum() {
|
||||
let upload = "https://uploads.kiwifarms.st/data/attachments/9671/9671535-abc-alt.avif?hash=x";
|
||||
assert_eq!(find_resolvable(upload, "kiwifarms.st").unwrap().fetch_url, upload, "on the open internet, its uploads host");
|
||||
let through_tor = find_resolvable(upload, DEFAULT_ONION).unwrap().fetch_url;
|
||||
assert_eq!(through_tor, format!("https://{DEFAULT_ONION}/data/attachments/9671/9671535-abc-alt.avif?hash=x"), "through Tor, the onion address");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tags_that_held_a_link_go_with_it() {
|
||||
let link = "https://uploads.kiwifarms.st/data/attachments/9671/9671535-abc-alt.avif?hash=x";
|
||||
assert_eq!(without_link(&format!("[img]{link}[/img]"), link), "");
|
||||
assert_eq!(without_link(&format!("reminds me of him [img]{link}[/img]"), link), "reminds me of him");
|
||||
assert_eq!(without_link(&format!("look {link}"), link), "look");
|
||||
}
|
||||
|
||||
const UPLOAD: &str = "/data/attachments/9671/9671535-a2e5ae0d55aadd338162770b3fa79f97-alt.avif?hash=-d_TarFo41";
|
||||
|
||||
#[test]
|
||||
fn a_direct_file_link_loses_its_domain_and_is_given_the_one_this_account_reaches_the_forum_at() {
|
||||
// As the chat has it: wrapped, with the uploads host.
|
||||
let theirs = format!("[img]https://uploads.kiwifarms.st{UPLOAD}[/img]");
|
||||
assert_eq!(expand_attachment_paths(&theirs, KIWIFARMS_CLEARNET_HOST), theirs, "on the open internet it is already the right one");
|
||||
// Through Tor, the onion address: the clearnet one is not asked.
|
||||
assert_eq!(expand_attachment_paths(&theirs, DEFAULT_ONION), format!("[img]https://{DEFAULT_ONION}{UPLOAD}[/img]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whichever_address_the_link_was_copied_with_it_comes_out_as_this_accounts_own() {
|
||||
let want_clear = format!("https://uploads.kiwifarms.st{UPLOAD}");
|
||||
let want_onion = format!("https://{DEFAULT_ONION}{UPLOAD}");
|
||||
for origin in ["https://uploads.kiwifarms.st", "https://kiwifarms.st", "https://www.kiwifarms.st", &format!("https://{DEFAULT_ONION}"), &format!("http://{DEFAULT_ONION}")] {
|
||||
let text = format!("look {origin}{UPLOAD} lol");
|
||||
assert_eq!(expand_attachment_paths(&text, KIWIFARMS_CLEARNET_HOST), format!("look {want_clear} lol"), "{origin}");
|
||||
assert_eq!(expand_attachment_paths(&text, DEFAULT_ONION), format!("look {want_onion} lol"), "{origin}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_short_it_is_the_same() {
|
||||
assert_eq!(expand_attachment_paths(UPLOAD, KIWIFARMS_CLEARNET_HOST), format!("https://uploads.kiwifarms.st{UPLOAD}"));
|
||||
assert_eq!(expand_attachment_paths(&format!("[img]{UPLOAD}[/img]"), DEFAULT_ONION), format!("[img]https://{DEFAULT_ONION}{UPLOAD}[/img]"));
|
||||
assert_eq!(
|
||||
expand_attachment_paths("/data/video/9667/9667943-6f0fa527b905c385fe7c60efee19011c.mp4?hash=pj-hdmJ9mQ", DEFAULT_ONION),
|
||||
format!("https://{DEFAULT_ONION}/data/video/9667/9667943-6f0fa527b905c385fe7c60efee19011c.mp4?hash=pj-hdmJ9mQ")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_forums_own_files_on_its_own_addresses_are_touched() {
|
||||
for left_alone in [
|
||||
"https://example.com/data/attachments/9671/9671535-abc-alt.avif",
|
||||
"https://kiwifarms.st/threads/some-thread.123/",
|
||||
"https://kiwifarms.st/data/other/9671/9671535-abc.avif",
|
||||
"see site/data/attachments/9671/9671535-abc-alt.avif",
|
||||
"/data/attachments/9671/9671535-abc.exe",
|
||||
"the /data/ folder",
|
||||
] {
|
||||
assert_eq!(expand_attachment_paths(left_alone, DEFAULT_ONION), left_alone, "{left_alone}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_comes_out_is_something_the_resolver_fetches_through_the_account() {
|
||||
let text = expand_attachment_paths(&format!("[img]https://uploads.kiwifarms.st{UPLOAD}[/img]"), DEFAULT_ONION).into_owned();
|
||||
let r = find_resolvable(&text, DEFAULT_ONION).expect("fetchable");
|
||||
assert_eq!(r.id, "9671535");
|
||||
assert_eq!(r.fetch_url, format!("https://{DEFAULT_ONION}{UPLOAD}"));
|
||||
// And both kinds of link in one message are each written for the account.
|
||||
let both = expand_attachment_paths("/attachments/seal-png.9609686/ and https://uploads.kiwifarms.st/data/video/9667/9667943-abc.mp4", DEFAULT_ONION);
|
||||
assert!(both.contains(&format!("https://{DEFAULT_ONION}/attachments/seal-png.9609686/")));
|
||||
assert!(both.contains(&format!("https://{DEFAULT_ONION}/data/video/9667/9667943-abc.mp4")));
|
||||
}
|
||||
|
||||
fn scratch(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("nobilis-sneed-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// A file of `size` bytes whose last write was `age` seconds ago.
|
||||
fn aged(dir: &std::path::Path, name: &str, size: usize, age: u64) {
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, vec![b'x'; size]).unwrap();
|
||||
let when = std::time::SystemTime::now() - std::time::Duration::from_secs(age);
|
||||
std::fs::File::options().write(true).open(&path).unwrap().set_modified(when).unwrap();
|
||||
}
|
||||
|
||||
fn names(dir: &std::path::Path) -> Vec<String> {
|
||||
let mut found: Vec<String> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
found.sort();
|
||||
found
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_cache_over_its_cap_loses_its_oldest_files_first_and_no_more_than_it_must() {
|
||||
let dir = scratch("sweep");
|
||||
// Fifty bytes in five files, the numbers saying how old they are.
|
||||
for (name, age) in [("e", 50), ("d", 40), ("c", 30), ("b", 20), ("a", 10)] {
|
||||
aged(&dir, name, 10, age);
|
||||
}
|
||||
std::fs::create_dir(dir.join("subdirectory")).unwrap();
|
||||
// Twenty-five over: the three oldest go (thirty bytes), because two
|
||||
// (twenty) would still leave it over.
|
||||
sweep_cache_dir(&dir, 25, "test").await;
|
||||
assert_eq!(names(&dir), vec!["a", "b", "subdirectory"]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_cache_at_its_cap_is_left_alone() {
|
||||
let dir = scratch("sweep-exact");
|
||||
aged(&dir, "old", 10, 100);
|
||||
aged(&dir, "new", 10, 1);
|
||||
sweep_cache_dir(&dir, 20, "test").await;
|
||||
assert_eq!(names(&dir), vec!["new", "old"], "exactly at the cap is not over it");
|
||||
sweep_cache_dir(&dir, 19, "test").await;
|
||||
assert_eq!(names(&dir), vec!["new"], "one byte over costs the oldest");
|
||||
// A directory that is not there is not an error.
|
||||
sweep_cache_dir(&dir.join("missing"), 1, "test").await;
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_container_brands_are_told_apart() {
|
||||
let boxed = |brand: &[u8; 4]| {
|
||||
let mut bytes = vec![0, 0, 0, 24, b'f', b't', b'y', b'p'];
|
||||
bytes.extend_from_slice(brand);
|
||||
bytes.extend_from_slice(&[0; 4]);
|
||||
bytes
|
||||
};
|
||||
assert_eq!(sniff_image_ext(&boxed(b"avif")), Some("avif"));
|
||||
assert_eq!(sniff_image_ext(&boxed(b"avis")), Some("avif"));
|
||||
assert_eq!(sniff_image_ext(&boxed(b"heic")), Some("heic"));
|
||||
assert_eq!(sniff_image_ext(&boxed(b"heix")), Some("heic"));
|
||||
assert_eq!(sniff_image_ext(&boxed(b"isom")), Some("mp4"));
|
||||
assert_eq!(sniff_image_ext(b"not an image at all"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_cached_avatar_is_what_its_name_says_it_is_or_it_is_gone() {
|
||||
let dir = scratch("avatars");
|
||||
let png = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0];
|
||||
let jpg = [0xFF, 0xD8, 0xFF, 0xE0, 0, 0, 0, 0];
|
||||
// Right: a PNG called png; a JPEG called either of its names.
|
||||
std::fs::write(dir.join("1.png"), png).unwrap();
|
||||
assert_eq!(cached_avatar_file(&dir, "1").await, Some(dir.join("1.png")));
|
||||
std::fs::write(dir.join("2.jpeg"), jpg).unwrap();
|
||||
assert_eq!(cached_avatar_file(&dir, "2").await, Some(dir.join("2.jpeg")));
|
||||
// Wrong: a PNG called jpg is from before sniffing, and is removed so it is fetched again.
|
||||
std::fs::write(dir.join("3.jpg"), png).unwrap();
|
||||
assert_eq!(cached_avatar_file(&dir, "3").await, None);
|
||||
assert!(!dir.join("3.jpg").exists(), "the mislabelled file is gone");
|
||||
// Bytes nobody recognises are kept - nothing better to go on.
|
||||
std::fs::write(dir.join("4.png"), b"???").unwrap();
|
||||
assert_eq!(cached_avatar_file(&dir, "4").await, Some(dir.join("4.png")));
|
||||
// Somebody never cached, and somebody whose id only starts the same.
|
||||
assert_eq!(cached_avatar_file(&dir, "5").await, None);
|
||||
assert_eq!(cached_avatar_file(&dir, "").await, None);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_picture_changing_changes_the_address_it_is_known_by() {
|
||||
let dir = scratch("versioned");
|
||||
let path = dir.join("1.png");
|
||||
std::fs::write(&path, b"x").unwrap();
|
||||
let at = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
|
||||
std::fs::File::options().write(true).open(&path).unwrap().set_modified(at).unwrap();
|
||||
assert_eq!(versioned(&path).await, format!("file://{}#1700000000", path.display()));
|
||||
// One that is not there still has an address, and a stamp of nothing.
|
||||
let gone = dir.join("2.png");
|
||||
assert_eq!(versioned(&gone).await, format!("file://{}#0", gone.display()));
|
||||
assert_eq!(avatar_key(&dir, "7"), dir.join("7"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,12 @@ pub(super) mod live_probe {
|
||||
println!("{i}: {}", line.trim().chars().take(400).collect::<String>());
|
||||
}
|
||||
}
|
||||
|
||||
// And the part that can fail: the reader this client really uses must
|
||||
// still find the rooms on the page the site really serves.
|
||||
let rooms = crate::backend::sneedchat::rooms::parse_rooms(&body);
|
||||
println!("{} room(s) read: {:?}", rooms.len(), rooms.iter().map(|r| r.name.as_str()).collect::<Vec<_>>());
|
||||
assert!(!rooms.is_empty(), "the room switcher is no longer where parse_rooms looks for it");
|
||||
}
|
||||
|
||||
/// Whether the session this account already holds is still good.
|
||||
@@ -146,13 +152,8 @@ pub(super) mod live_probe {
|
||||
pow::clear(&http, &login, 8).await.expect("failed to clear the gate");
|
||||
page = http.get(&login).await.expect("GET after clearing gate failed");
|
||||
}
|
||||
let sitekey = page
|
||||
.body
|
||||
.split("data-sitekey=\"")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split('"').next())
|
||||
.expect("no data-sitekey on the login page")
|
||||
.to_string();
|
||||
let sitekey = crate::backend::sneedchat::captcha::site_key(&page.body)
|
||||
.expect("site_key finds no captcha widget on the login page - the markup has changed");
|
||||
println!("sitekey: {sitekey}");
|
||||
|
||||
// The widget opens with a GET of `<apiBase>/start?key=...`; every
|
||||
@@ -228,7 +229,7 @@ pub(super) mod live_probe {
|
||||
println!(" {name} = {shown:?}");
|
||||
}
|
||||
}
|
||||
None => println!("!! no /login/login form section found - the layout has changed"),
|
||||
None => panic!("no /login/login form section found - the layout has changed"),
|
||||
}
|
||||
|
||||
println!("--- captcha markers anywhere on the page ---");
|
||||
|
||||
+152
-17
@@ -279,52 +279,100 @@ pub(super) const SITE_OWNER_ID: &str = "1";
|
||||
|
||||
pub(super) fn update_roster(
|
||||
state: &AppState,
|
||||
http: &super::http::HttpClient,
|
||||
host: &str,
|
||||
account_id: &str,
|
||||
buffer_name: &str,
|
||||
joined: &[protocol::WireUser],
|
||||
left: &[String],
|
||||
) {
|
||||
let buffer_id = crate::model::buffer_id(account_id, buffer_name);
|
||||
let mut by_id: std::collections::BTreeMap<String, String> = state
|
||||
let mut by_id = published_roster(state, &buffer_id);
|
||||
|
||||
for user in joined {
|
||||
// Whatever picture they already had stays until a new one is fetched.
|
||||
let known = by_id.get(&user.id).and_then(|(_, a)| a.clone());
|
||||
by_id.insert(user.id.clone(), (user.username.clone(), known));
|
||||
}
|
||||
for id in left {
|
||||
by_id.remove(id);
|
||||
}
|
||||
publish_roster(state, &buffer_id, &by_id);
|
||||
|
||||
// The pictures, after the list is already on screen: fetching one goes over
|
||||
// the site's own route and is slow the first time, and neither the list nor
|
||||
// the room's messages should wait for a face. Fetched together, then laid
|
||||
// over the roster as it is by then and said again.
|
||||
let wanted: Vec<(String, String)> = joined
|
||||
.iter()
|
||||
.filter_map(|u| Some((u.id.clone(), u.avatar_url.clone().filter(|a| !a.is_empty())?)))
|
||||
.collect();
|
||||
if !wanted.is_empty() {
|
||||
tokio::spawn({
|
||||
let (state, http, host) = (state.clone(), http.clone(), host.to_string());
|
||||
async move { add_pictures(&state, &http, &host, &buffer_id, wanted).await }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The roster as last published: who is there, and the picture each has.
|
||||
fn published_roster(state: &AppState, buffer_id: &str) -> std::collections::BTreeMap<String, (String, Option<String>)> {
|
||||
state
|
||||
.runtime
|
||||
.get_presence(&buffer_id)
|
||||
.get_presence(buffer_id)
|
||||
.and_then(|v| serde_json::from_value::<Vec<serde_json::Value>>(v).ok())
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|m| {
|
||||
let id = m.get("userId")?.as_str()?.to_string();
|
||||
let nick = m.get("nick")?.as_str()?.to_string();
|
||||
Some((id, nick))
|
||||
let avatar = m.get("avatarUrl").and_then(|a| a.as_str()).map(str::to_string);
|
||||
Some((id, (nick, avatar)))
|
||||
})
|
||||
.collect();
|
||||
.collect()
|
||||
}
|
||||
|
||||
for user in joined {
|
||||
by_id.insert(user.id.clone(), user.username.clone());
|
||||
/// Fetches pictures and puts them on whoever is still in the room.
|
||||
async fn add_pictures(state: &AppState, http: &super::http::HttpClient, host: &str, buffer_id: &str, wanted: Vec<(String, String)>) {
|
||||
let found = futures::future::join_all(wanted.iter().map(|(id, raw)| async move { (id.clone(), super::media::cached_avatar_path(http, host, id, raw).await) })).await;
|
||||
// Read afresh: people came and went while these were being fetched, and a
|
||||
// roster built from before would bring back whoever has left.
|
||||
let mut now = published_roster(state, buffer_id);
|
||||
let mut changed = false;
|
||||
for (id, path) in found {
|
||||
if let (Some(path), Some(entry)) = (path, now.get_mut(&id)) {
|
||||
if entry.1.as_deref() != Some(path.as_str()) {
|
||||
entry.1 = Some(path);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
for id in left {
|
||||
by_id.remove(id);
|
||||
if changed {
|
||||
publish_roster(state, buffer_id, &now);
|
||||
}
|
||||
}
|
||||
|
||||
let mut members: Vec<(String, String)> = by_id.into_iter().collect();
|
||||
members.sort_by(|(_, a), (_, b)| a.to_lowercase().cmp(&b.to_lowercase()).then_with(|| a.cmp(b)));
|
||||
/// Orders and publishes a roster: sorted here rather than left to the client,
|
||||
/// since every other backend hands over a sorted one and a five-hundred-name
|
||||
/// list in map order would be unreadable.
|
||||
fn publish_roster(state: &AppState, buffer_id: &str, by_id: &std::collections::BTreeMap<String, (String, Option<String>)>) {
|
||||
let mut members: Vec<(&String, &(String, Option<String>))> = by_id.iter().collect();
|
||||
members.sort_by(|(_, (a, _)), (_, (b, _))| a.to_lowercase().cmp(&b.to_lowercase()).then_with(|| a.cmp(b)));
|
||||
let member_list = serde_json::json!(members
|
||||
.into_iter()
|
||||
.map(|(id, nick)| {
|
||||
.map(|(id, (nick, avatar))| {
|
||||
// "~" is the owner prefix the frontend already ranks by, shared
|
||||
// with IRC rather than inventing a Sneedchat-only convention.
|
||||
let prefix = if id == SITE_OWNER_ID { "~" } else { "" };
|
||||
serde_json::json!({ "nick": nick, "userId": id, "prefix": prefix, "away": false })
|
||||
serde_json::json!({ "nick": nick, "userId": id, "prefix": prefix, "away": false, "avatarUrl": avatar })
|
||||
})
|
||||
.collect::<Vec<_>>());
|
||||
|
||||
// Persisted as well as broadcast, so a client that subscribes later gets
|
||||
// the roster from subscribe's replay rather than waiting for the next
|
||||
// arrival or departure - which in a quiet room could be a long wait.
|
||||
state.runtime.set_presence(&buffer_id, member_list.clone());
|
||||
state.events.emit(
|
||||
"presenceChange",
|
||||
serde_json::json!({ "bufferId": buffer_id, "members": member_list }),
|
||||
);
|
||||
state.runtime.set_presence(buffer_id, member_list.clone());
|
||||
state.events.emit("presenceChange", serde_json::json!({ "bufferId": buffer_id, "members": member_list }));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -363,3 +411,90 @@ pub(super) mod buffer_name_tests {
|
||||
assert_eq!(room_name_of("fishtank"), "fishtank");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod roster_tests {
|
||||
use super::*;
|
||||
use crate::backend::kick::testkit::simulated_daemon;
|
||||
use crate::backend::sneedchat::http::{CookieJar, HttpClient};
|
||||
use crate::net::tor::Transport;
|
||||
|
||||
const ACCOUNT: &str = "sneedchat:test";
|
||||
|
||||
fn client() -> HttpClient {
|
||||
HttpClient::new(Transport::Direct, CookieJar::new(), "test".to_string())
|
||||
}
|
||||
|
||||
fn user(id: &str, name: &str, avatar: Option<&str>) -> protocol::WireUser {
|
||||
serde_json::from_value(serde_json::json!({ "id": id, "username": name, "avatar_url": avatar })).unwrap()
|
||||
}
|
||||
|
||||
fn members(state: &AppState) -> Vec<(String, Option<String>)> {
|
||||
let list = state.runtime.get_presence(&crate::model::buffer_id(ACCOUNT, "general")).unwrap();
|
||||
list.as_array().unwrap().iter().map(|m| (m["nick"].as_str().unwrap().to_string(), m["avatarUrl"].as_str().map(str::to_string))).collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_list_is_published_at_once_with_nobody_waiting_on_a_picture() {
|
||||
let state = simulated_daemon("sneedchat-roster-plain");
|
||||
update_roster(&state, &client(), "example.invalid", ACCOUNT, "general", &[user("2", "bob", None), user("1", "Amy", None)], &[]);
|
||||
assert_eq!(members(&state), [("Amy".to_string(), None), ("bob".to_string(), None)], "sorted, and no picture is no picture");
|
||||
update_roster(&state, &client(), "example.invalid", ACCOUNT, "general", &[], &["2".to_string()]);
|
||||
assert_eq!(members(&state).len(), 1);
|
||||
}
|
||||
|
||||
/// A tiny picture on a local server, fetched into a scratch cache.
|
||||
async fn picture_server() -> String {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
// A PNG's signature and a few more bytes: enough to be sniffed as one.
|
||||
let png: Vec<u8> = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0x0d, b'I', b'H', b'D', b'R', 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0];
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut sock, _)) = listener.accept().await else { return };
|
||||
let png = png.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut buf = [0u8; 2048];
|
||||
let _ = sock.read(&mut buf).await;
|
||||
let head = format!("HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", png.len());
|
||||
let _ = sock.write_all(head.as_bytes()).await;
|
||||
let _ = sock.write_all(&png).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pictures_follow_the_list_and_stay_through_later_arrivals() {
|
||||
let dir = std::env::temp_dir().join(format!("nobilis-roster-avatars-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
*crate::backend::sneedchat::media::TEST_CACHE.lock().unwrap() = Some(dir.clone());
|
||||
let base = picture_server().await;
|
||||
let state = simulated_daemon("sneedchat-roster-pictures");
|
||||
|
||||
update_roster(&state, &client(), "example.invalid", ACCOUNT, "general", &[user("7", "Amy", Some(&format!("{base}/a.png")))], &[]);
|
||||
// On screen first, without the picture.
|
||||
assert_eq!(members(&state), [("Amy".to_string(), None)]);
|
||||
let mut with = None;
|
||||
for _ in 0..100 {
|
||||
with = members(&state)[0].1.clone();
|
||||
if with.is_some() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
}
|
||||
let path = with.expect("the picture arrives after the list");
|
||||
assert!(path.contains("7."), "a file named for the user: {path}");
|
||||
|
||||
// Somebody arrives, and Amy keeps hers.
|
||||
update_roster(&state, &client(), "example.invalid", ACCOUNT, "general", &[user("8", "Bob", None)], &[]);
|
||||
let list = members(&state);
|
||||
assert_eq!(list.iter().find(|m| m.0 == "Amy").unwrap().1.as_deref().map(|p| p.contains("7.")), Some(true));
|
||||
assert_eq!(list.iter().find(|m| m.0 == "Bob").unwrap().1, None);
|
||||
|
||||
*crate::backend::sneedchat::media::TEST_CACHE.lock().unwrap() = None;
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
//! What the account is, and what Steam holds back from it.
|
||||
//!
|
||||
//! A person signing in to a chat client wants to know why something does not
|
||||
//! work as much as that it does not: an account Steam calls *limited* cannot
|
||||
//! write to everybody, Family View can hold the friends list back, the text
|
||||
//! filter changes what is read, and the sessions listed on the account are the
|
||||
//! places it is signed in. This reads those, shows them, and acts on the ones
|
||||
//! that are the client's to act on.
|
||||
//!
|
||||
//! Also here: keeping the account's sign-in alive. The token that signs it in
|
||||
//! lasts months, and a client that waits for it to run out finds out by being
|
||||
//! signed out; Steam will give a fresh one while the old one is still good.
|
||||
|
||||
use super::link::Link;
|
||||
use super::proto::*;
|
||||
use crate::accounts::SteamAccountConfig;
|
||||
use crate::state::AppState;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
|
||||
/// `EAccountFlags`: a limited account, and one Steam keeps limited.
|
||||
const LIMITED: u32 = 4096 | 8192;
|
||||
|
||||
/// Family View's feature for friends and chat: a bit in the settings, which
|
||||
/// when the account is locked is the answer to "is this allowed".
|
||||
const FRIENDS_FEATURE: u32 = 4;
|
||||
|
||||
pub fn is_limited(account_flags: u32) -> bool {
|
||||
account_flags & LIMITED != 0
|
||||
}
|
||||
|
||||
/// Steam's own lists of what is rude, as patterns, one to a line: what the client itself fetches
|
||||
/// from the community site. `profanity` is dropped by the setting that allows it, and `clean` is
|
||||
/// what is let through whatever else says.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Dictionary {
|
||||
pub banned: Vec<String>,
|
||||
pub profanity: Vec<String>,
|
||||
pub clean: Vec<String>,
|
||||
}
|
||||
|
||||
/// The words the account has asked to have hidden, and the patterns they and Steam's lists make.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct TextFilter {
|
||||
/// `ETextFilterSetting`: 0 and 3 use none of Steam's lists, 2 only the banned one, 1 both.
|
||||
pub setting: i32,
|
||||
pub ignore_friends: bool,
|
||||
pub banned: Vec<String>,
|
||||
pub clean: Vec<String>,
|
||||
pub compiled: Compiled,
|
||||
}
|
||||
|
||||
/// What the lists and the account's own words make: one pattern for what is hidden and one for
|
||||
/// what is let through, each only as good as the lines in them that are patterns at all.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Compiled {
|
||||
hidden: Option<regex::Regex>,
|
||||
allowed: Option<regex::Regex>,
|
||||
}
|
||||
|
||||
impl PartialEq for Compiled {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.hidden.as_ref().map(|r| r.as_str()) == other.hidden.as_ref().map(|r| r.as_str()) && self.allowed.as_ref().map(|r| r.as_str()) == other.allowed.as_ref().map(|r| r.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// The lines that are patterns this can read, as one alternation. A line that is not one is left
|
||||
/// out, as the client leaves out one its own engine cannot compile.
|
||||
fn alternation(lines: impl IntoIterator<Item = String>) -> Option<String> {
|
||||
let usable: Vec<String> = lines.into_iter().map(|l| l.trim().trim_start_matches('\u{feff}').to_string()).filter(|l| !l.is_empty() && regex::Regex::new(&format!(r"(?i)\b({l})\b")).is_ok()).collect();
|
||||
(!usable.is_empty()).then(|| usable.join("|"))
|
||||
}
|
||||
|
||||
impl TextFilter {
|
||||
/// Builds the patterns the client builds: Steam's lists as the setting says, and the account's own
|
||||
/// words on top whatever the setting is.
|
||||
pub fn compile(setting: i32, banned: &[String], clean: &[String], dictionary: &Dictionary) -> Compiled {
|
||||
let mut hidden: Vec<String> = match setting {
|
||||
0 | 3 => Vec::new(),
|
||||
2 => dictionary.banned.clone(),
|
||||
_ => dictionary.profanity.iter().chain(&dictionary.banned).cloned().collect(),
|
||||
};
|
||||
hidden.extend(banned.iter().cloned());
|
||||
let allowed: Vec<String> = dictionary.clean.iter().chain(clean).cloned().collect();
|
||||
Compiled {
|
||||
hidden: alternation(hidden).and_then(|a| regex::Regex::new(&format!(r"(?i)\b({a})\b")).ok()),
|
||||
allowed: alternation(allowed).and_then(|a| regex::Regex::new(&format!(r"(?i)^(\b({a})\b)$")).ok()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Steam's lists, asked of the community site once a run.
|
||||
static DICTIONARY: LazyLock<Mutex<Option<Arc<Dictionary>>>> = LazyLock::new(Default::default);
|
||||
|
||||
async fn dictionary(key: &str) -> Arc<Dictionary> {
|
||||
if let Some(held) = DICTIONARY.lock().unwrap().clone() {
|
||||
return held;
|
||||
}
|
||||
let router = crate::net::route::router();
|
||||
let http = router.client_if("steam", router.routed(key), |b| b.timeout(std::time::Duration::from_secs(30)).user_agent(concat!("moho/", env!("CARGO_PKG_VERSION"))));
|
||||
let fetch = |kind: &'static str| {
|
||||
let http = http.clone();
|
||||
async move {
|
||||
let url = format!("https://community.fastly.steamstatic.com/textfilter/gettextfilterdictionary?type={kind}&language=english&v=1");
|
||||
let raw = http.get(url).send().await.ok().filter(|r| r.status().is_success())?.bytes().await.ok()?;
|
||||
Some(super::web::plain(&raw).lines().map(str::to_string).collect::<Vec<_>>())
|
||||
}
|
||||
};
|
||||
let (banned, profanity, clean) = tokio::join!(fetch("banned"), fetch("profanity"), fetch("clean_public"));
|
||||
let dictionary = Arc::new(Dictionary { banned: banned.unwrap_or_default(), profanity: profanity.unwrap_or_default(), clean: clean.unwrap_or_default() });
|
||||
// Kept only when it came: a list that did not is asked for again next time.
|
||||
if !dictionary.banned.is_empty() {
|
||||
*DICTIONARY.lock().unwrap() = Some(dictionary.clone());
|
||||
}
|
||||
dictionary
|
||||
}
|
||||
|
||||
/// What is known about the account, as the window reads it.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Facts {
|
||||
pub limited: bool,
|
||||
/// Whether Family View is on, and if it holds friends and chat back.
|
||||
pub family_view: Option<(bool, bool)>,
|
||||
pub filter: Option<TextFilter>,
|
||||
pub privacy: Option<PrivacySettings>,
|
||||
}
|
||||
|
||||
fn privacy_word(state: Option<i32>) -> Value {
|
||||
match state {
|
||||
Some(1) => json!("private"),
|
||||
Some(2) => json!("friends"),
|
||||
Some(3) => json!("public"),
|
||||
_ => Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
impl Facts {
|
||||
pub fn to_json(&self) -> Value {
|
||||
json!({
|
||||
"limited": self.limited,
|
||||
"familyView": self.family_view.map(|(enabled, holds_friends)| json!({ "enabled": enabled, "holdsFriendsBack": holds_friends })),
|
||||
"textFilter": self.filter.as_ref().map(|f| json!({
|
||||
"on": f.compiled.hidden.is_some(),
|
||||
"setting": match f.setting { 1 => "on", 2 => "on, profanity allowed", 3 => "off", _ => "not in use" },
|
||||
"ignoreFriends": f.ignore_friends,
|
||||
"words": f.banned.len(),
|
||||
})),
|
||||
"privacy": self.privacy.as_ref().map(|p| json!({
|
||||
"profile": privacy_word(p.privacy_state),
|
||||
"inventory": privacy_word(p.privacy_state_inventory),
|
||||
"games": privacy_word(p.privacy_state_ownedgames),
|
||||
"playtime": privacy_word(p.privacy_state_playtime),
|
||||
"friends": privacy_word(p.privacy_state_friendslist),
|
||||
})),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
static HELD: LazyLock<Mutex<HashMap<String, Arc<Facts>>>> = LazyLock::new(Default::default);
|
||||
|
||||
pub fn held(account_id: &str) -> Facts {
|
||||
HELD.lock().unwrap().get(account_id).map(|f| (**f).clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn clear(account_id: &str) {
|
||||
HELD.lock().unwrap().remove(account_id);
|
||||
}
|
||||
|
||||
fn keep(state: &AppState, account_id: &str, facts: Facts) {
|
||||
let changed = HELD.lock().unwrap().insert(account_id.to_string(), Arc::new(facts.clone())).map(|old| *old != facts).unwrap_or(true);
|
||||
if changed {
|
||||
state.events.emit("steamAccountFacts", json!({ "accountId": account_id, "facts": facts.to_json() }));
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads what Steam says of the account. Each part on its own: one that is
|
||||
/// refused leaves the rest.
|
||||
pub async fn load(state: AppState, link: Link, account_id: String, steamid: u64, account_flags: u32) {
|
||||
// What is known at once, before the rest is asked for.
|
||||
keep(&state, &account_id, Facts { limited: is_limited(account_flags), ..Default::default() });
|
||||
let parental_request = GetParentalSettingsRequest { steamid: Some(steamid) };
|
||||
let (preferences, words, privacy, parental) = tokio::join!(
|
||||
link.call::<_, GetCommunityPreferencesResponse>("Player.GetCommunityPreferences#1", &GetCommunityPreferencesRequest {}, true),
|
||||
link.call::<_, GetTextFilterWordsResponse>("Player.GetTextFilterWords#1", &GetTextFilterWordsRequest {}, true),
|
||||
link.call::<_, GetPrivacySettingsResponse>("Player.GetPrivacySettings#1", &GetPrivacySettingsRequest {}, true),
|
||||
link.call::<_, GetParentalSettingsResponse>("Parental.GetParentalSettings#1", &parental_request, true),
|
||||
);
|
||||
let filter = match preferences.ok().and_then(|p| p.preferences) {
|
||||
Some(p) => {
|
||||
let words = words.ok().and_then(|w| w.words).unwrap_or_default();
|
||||
let setting = p.text_filter_setting.unwrap_or(0);
|
||||
// Steam's own lists are needed only where the setting uses them.
|
||||
let lists = if matches!(setting, 1 | 2) { (*dictionary(&account_id).await).clone() } else { Dictionary::default() };
|
||||
let compiled = TextFilter::compile(setting, &words.text_filter_custom_banned_words, &words.text_filter_custom_clean_words, &lists);
|
||||
Some(TextFilter { setting, ignore_friends: p.text_filter_ignore_friends.unwrap_or(true), banned: words.text_filter_custom_banned_words, clean: words.text_filter_custom_clean_words, compiled })
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let family_view = parental.ok().and_then(|p| p.settings).map(|s| {
|
||||
let enabled = s.is_enabled.unwrap_or(false);
|
||||
(enabled, enabled && s.enabled_features.unwrap_or(0) & (1 << FRIENDS_FEATURE) == 0)
|
||||
});
|
||||
keep(&state, &account_id, Facts { limited: is_limited(account_flags), family_view, filter, privacy: privacy.ok().and_then(|p| p.privacy_settings) });
|
||||
}
|
||||
|
||||
/// A message with the words the account hides masked, for somebody the filter applies to: a stranger
|
||||
/// always, and a friend unless the account says friends are not filtered.
|
||||
pub fn filtered(account_id: &str, from_friend: bool, text: &str) -> String {
|
||||
let facts = HELD.lock().unwrap().get(account_id).cloned();
|
||||
let Some(filter) = facts.as_ref().and_then(|f| f.filter.as_ref()) else { return text.to_string() };
|
||||
if (from_friend && filter.ignore_friends) || filter.compiled.hidden.is_none() {
|
||||
return text.to_string();
|
||||
}
|
||||
mask(text, &filter.compiled)
|
||||
}
|
||||
|
||||
/// Replaces each whole word the patterns hide with a heart for every letter, unless the word is one the
|
||||
/// allowed pattern says is clean, as Steam does.
|
||||
pub fn mask(text: &str, patterns: &Compiled) -> String {
|
||||
let Some(hidden) = &patterns.hidden else { return text.to_string() };
|
||||
hidden
|
||||
.replace_all(text, |found: ®ex::Captures| {
|
||||
let word = &found[0];
|
||||
if patterns.allowed.as_ref().is_some_and(|a| a.is_match(word)) {
|
||||
word.to_string()
|
||||
} else {
|
||||
"\u{2665}".repeat(word.chars().count())
|
||||
}
|
||||
})
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
/// A place the account is signed in.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Session {
|
||||
pub id: u64,
|
||||
pub description: String,
|
||||
/// `EAuthTokenPlatformType`: 1 launcher, 2 browser, 3 phone.
|
||||
pub platform: i32,
|
||||
pub last_seen: u32,
|
||||
pub place: String,
|
||||
pub current: bool,
|
||||
}
|
||||
|
||||
fn place(event: Option<&TokenUsageEvent>) -> String {
|
||||
event.map(|e| [&e.city, &e.state, &e.country].iter().filter_map(|p| p.as_deref()).filter(|p| !p.is_empty()).collect::<Vec<_>>().join(", ")).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn read_sessions(answer: &EnumerateTokensResponse) -> Vec<Session> {
|
||||
let mut sessions: Vec<Session> = answer
|
||||
.refresh_tokens
|
||||
.iter()
|
||||
.filter_map(|t| {
|
||||
let id = t.token_id?;
|
||||
Some(Session {
|
||||
id,
|
||||
description: t.token_description.clone().filter(|d| !d.is_empty()).unwrap_or_else(|| "a signed-in session".to_string()),
|
||||
platform: t.platform_type.unwrap_or(0),
|
||||
last_seen: t.last_seen.as_ref().and_then(|l| l.time).or(t.time_updated).unwrap_or(0),
|
||||
place: place(t.last_seen.as_ref()),
|
||||
current: answer.requesting_token == Some(id),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
sessions.sort_by_key(|s| std::cmp::Reverse(s.last_seen));
|
||||
sessions
|
||||
}
|
||||
|
||||
/// Where the account is signed in.
|
||||
pub async fn sessions(link: &Link) -> Result<Vec<Session>, String> {
|
||||
let answer: EnumerateTokensResponse = link
|
||||
.call("Authentication.EnumerateTokens#1", &EnumerateTokensRequest { include_revoked: Some(false) }, true)
|
||||
.await
|
||||
.map_err(|e| super::chat::refusal(&e))?;
|
||||
Ok(read_sessions(&answer))
|
||||
}
|
||||
|
||||
/// Signs one of them out, for good: whoever has it is no longer signed in.
|
||||
pub async fn revoke(link: &Link, steamid: u64, token_id: u64) -> Result<(), String> {
|
||||
let _: RevokeRefreshTokenResponse = link
|
||||
.call("Authentication.RevokeRefreshToken#1", &RevokeRefreshTokenRequest { token_id: Some(token_id), steamid: Some(steamid) }, true)
|
||||
.await
|
||||
.map_err(|e| super::chat::refusal(&e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// When a token stops working, in unix seconds, from its own claims.
|
||||
pub fn expires_at(token: &str) -> Option<u64> {
|
||||
use base64::Engine;
|
||||
let payload = token.split('.').nth(1)?;
|
||||
let raw = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload.trim_end_matches('=')).ok()?;
|
||||
serde_json::from_slice::<Value>(&raw).ok()?["exp"].as_u64()
|
||||
}
|
||||
|
||||
/// How close to running out is too close: Steam renews a token while it is good, and a
|
||||
/// month leaves a long time to try again after a failure.
|
||||
const RENEW_WITHIN_SECONDS: u64 = 30 * 24 * 3600;
|
||||
|
||||
pub fn due_for_renewal(token: &str, now: u64) -> bool {
|
||||
expires_at(token).is_some_and(|exp| exp.saturating_sub(now) < RENEW_WITHIN_SECONDS)
|
||||
}
|
||||
|
||||
/// Asks Steam for a fresh token if this one is near its end, and keeps it.
|
||||
pub async fn renew_if_due(state: &AppState, link: &Link, config: &SteamAccountConfig) {
|
||||
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
||||
if !due_for_renewal(&config.refresh_token, now) {
|
||||
return;
|
||||
}
|
||||
let request = GenerateAccessTokenRequest { refresh_token: Some(config.refresh_token.clone()), steamid: Some(config.steamid), renewal_type: Some(1) };
|
||||
match link.call::<_, GenerateAccessTokenResponse>("Authentication.GenerateAccessTokenForApp#1", &request, true).await {
|
||||
Ok(answer) => {
|
||||
if let Some(fresh) = answer.refresh_token.filter(|t| !t.is_empty() && *t != config.refresh_token) {
|
||||
let id = config.account_id();
|
||||
let _ = state.accounts.mutate_steam(&id, |a| a.refresh_token = fresh);
|
||||
tracing::info!("steam[{id}]: the sign-in token was renewed");
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::info!("steam[{}]: the sign-in token could not be renewed: {e}", config.account_id()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_limited_account_is_known_by_its_flags() {
|
||||
assert!(is_limited(4096));
|
||||
assert!(is_limited(8192 | 1));
|
||||
assert!(!is_limited(1 | 16384));
|
||||
}
|
||||
|
||||
fn lists() -> Dictionary {
|
||||
Dictionary { banned: vec!["bann?ed".into(), "[unclosed".into()], profanity: vec!["f+u+d+ge".into()], clean: vec!["banned together".into(), "bannedx".into()] }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_the_lists_and_the_account_hide_is_hidden_as_whole_words_with_a_heart_each() {
|
||||
let own = ["heck".to_string(), "Darn".to_string()];
|
||||
let on = TextFilter::compile(1, &own, &["darn".to_string()], &lists());
|
||||
assert_eq!(mask("what the heck, a Heckle and DARN it", &on), "what the \u{2665}\u{2665}\u{2665}\u{2665}, a Heckle and DARN it", "the account's own words, and what it marked clean is let through");
|
||||
assert_eq!(mask("fuuudge and banned and bannedx", &on), "\u{2665}\u{2665}\u{2665}\u{2665}\u{2665}\u{2665}\u{2665} and \u{2665}\u{2665}\u{2665}\u{2665}\u{2665}\u{2665} and bannedx", "Steam's lists, with a line that is no pattern left out and a clean word let through");
|
||||
assert_eq!(mask("banned together", &on), "\u{2665}\u{2665}\u{2665}\u{2665}\u{2665}\u{2665} together", "as in the client, a clean entry lets through only a match that is the whole of it");
|
||||
assert_eq!(mask("nothing here", &on), "nothing here");
|
||||
// The setting that allows profanity uses only the banned list.
|
||||
let mild = TextFilter::compile(2, &[], &[], &lists());
|
||||
assert_eq!(mask("fudge banned", &mild), "fudge \u{2665}\u{2665}\u{2665}\u{2665}\u{2665}\u{2665}");
|
||||
// Switched off: Steam's lists are not used, but the account's own words still are, as in the client.
|
||||
let off = TextFilter::compile(3, &own, &[], &lists());
|
||||
assert_eq!(mask("banned heck", &off), "banned \u{2665}\u{2665}\u{2665}\u{2665}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_filter_applies_to_strangers_and_to_friends_only_if_the_account_says_so() {
|
||||
let id = "steam:filter-test";
|
||||
let words = || TextFilter { setting: 1, ignore_friends: true, banned: vec!["heck".into()], clean: vec![], compiled: TextFilter::compile(1, &["heck".to_string()], &[], &Dictionary::default()) };
|
||||
HELD.lock().unwrap().insert(id.into(), Arc::new(Facts { filter: Some(words()), ..Default::default() }));
|
||||
assert_eq!(filtered(id, false, "oh heck"), "oh \u{2665}\u{2665}\u{2665}\u{2665}");
|
||||
assert_eq!(filtered(id, true, "oh heck"), "oh heck", "friends are not filtered by default");
|
||||
HELD.lock().unwrap().insert(id.into(), Arc::new(Facts { filter: Some(TextFilter { ignore_friends: false, ..words() }), ..Default::default() }));
|
||||
assert_eq!(filtered(id, true, "oh heck"), "oh \u{2665}\u{2665}\u{2665}\u{2665}");
|
||||
assert_eq!(filtered("steam:nobody", false, "oh heck"), "oh heck", "no filter known");
|
||||
clear(id);
|
||||
}
|
||||
|
||||
/// Steam's real lists from the community site, and what they make of a sentence. The words used are the
|
||||
/// mildest on the list. Needs the network.
|
||||
#[tokio::test]
|
||||
#[ignore = "needs the network"]
|
||||
async fn steams_own_lists_are_fetched_and_hide_what_they_say() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let d = dictionary("steam:filter-probe").await;
|
||||
println!("FILTER lists: {} banned, {} profanity, {} clean", d.banned.len(), d.profanity.len(), d.clean.len());
|
||||
let on = TextFilter::compile(1, &[], &[], &d);
|
||||
let mild = TextFilter::compile(2, &[], &[], &d);
|
||||
println!("FILTER on: {:?}", mask("what the fuck, a fukushima trip and pussycat", &on));
|
||||
println!("FILTER mild: {:?}", mask("what the fuck, a fukushima trip and pussycat", &mild));
|
||||
assert!(d.banned.len() > 20 && d.profanity.len() > 20);
|
||||
assert_eq!(mask("what the fuck", &on), "what the \u{2665}\u{2665}\u{2665}\u{2665}");
|
||||
assert_eq!(mask("fukushima pussycat", &on), "fukushima pussycat", "clean words are let through");
|
||||
assert_eq!(mask("what the fuck", &mild), "what the fuck", "the setting that allows profanity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sessions_are_listed_newest_first_with_the_one_in_use_marked() {
|
||||
let answer = EnumerateTokensResponse {
|
||||
refresh_tokens: vec![
|
||||
RefreshTokenDescription { token_id: Some(1), token_description: Some("old phone".into()), platform_type: Some(3), last_seen: Some(TokenUsageEvent { time: Some(100), city: Some("Leeds".into()), country: Some("GB".into()), ..Default::default() }), ..Default::default() },
|
||||
RefreshTokenDescription { token_id: Some(2), token_description: Some("moho on host".into()), platform_type: Some(3), time_updated: Some(900), ..Default::default() },
|
||||
RefreshTokenDescription { token_description: Some("no id".into()), ..Default::default() },
|
||||
],
|
||||
requesting_token: Some(2),
|
||||
};
|
||||
let got = read_sessions(&answer);
|
||||
assert_eq!(got.iter().map(|s| (s.id, s.description.as_str(), s.current)).collect::<Vec<_>>(), [(2, "moho on host", true), (1, "old phone", false)]);
|
||||
assert_eq!(got[1].place, "Leeds, GB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_is_renewed_when_it_is_within_a_month_of_its_end() {
|
||||
use base64::Engine;
|
||||
let token = |exp: u64| format!("h.{}.s", base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!(r#"{{"sub":"1","exp":{exp}}}"#)));
|
||||
let now = 1_800_000_000;
|
||||
assert!(due_for_renewal(&token(now + 10 * 24 * 3600), now));
|
||||
assert!(due_for_renewal(&token(now - 5), now), "one that has run out is as due as one can be");
|
||||
assert!(!due_for_renewal(&token(now + 100 * 24 * 3600), now));
|
||||
assert!(!due_for_renewal("not a token", now), "nothing is asked for a token that cannot be read");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_facts_read_in_words_for_the_window() {
|
||||
let facts = Facts {
|
||||
limited: true,
|
||||
family_view: Some((true, true)),
|
||||
filter: Some(TextFilter { setting: 2, ignore_friends: true, banned: vec!["a".into(), "b".into()], clean: vec![], compiled: Compiled::default() }),
|
||||
privacy: Some(PrivacySettings { privacy_state: Some(3), privacy_state_friendslist: Some(1), ..Default::default() }),
|
||||
};
|
||||
let j = facts.to_json();
|
||||
assert_eq!((j["limited"].as_bool(), j["familyView"]["holdsFriendsBack"].as_bool(), j["textFilter"]["words"].as_u64()), (Some(true), Some(true), Some(2)));
|
||||
assert_eq!((j["privacy"]["profile"].as_str(), j["privacy"]["friends"].as_str(), j["privacy"]["games"].is_null()), (Some("public"), Some("private"), true));
|
||||
assert_eq!(Facts::default().to_json()["familyView"], Value::Null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! What a game is called.
|
||||
//!
|
||||
//! Steam tells a friend's client which game somebody is in by its number, and
|
||||
//! names it only for a game that is not on Steam. The name of one that is is
|
||||
//! Steam's own to say - the store has it - so it is asked for the first time a
|
||||
//! number is seen and kept for as long as moho runs. A game is not asked about
|
||||
//! twice, and one the store does not know stays a number: shown as "In game",
|
||||
//! which is true, rather than as a guess.
|
||||
//!
|
||||
//! It is the account's request and goes the account's way: through its route.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// How the store is asked.
|
||||
const STORE: &str = "https://store.steampowered.com/api/appdetails";
|
||||
|
||||
/// The name out of the store's answer, which is keyed by the number asked for.
|
||||
pub fn name_in(answer: &Value, app: u64) -> Option<String> {
|
||||
let entry = answer.get(app.to_string())?;
|
||||
if entry.get("success").and_then(|s| s.as_bool()) != Some(true) {
|
||||
return None;
|
||||
}
|
||||
let name = entry.get("data")?.get("name")?.as_str()?.trim();
|
||||
(!name.is_empty()).then(|| name.chars().take(80).collect())
|
||||
}
|
||||
|
||||
/// Asks the store what a game is called.
|
||||
pub async fn lookup(key: &str, app: u64) -> Option<String> {
|
||||
let router = crate::net::route::router();
|
||||
if router.routed(key) && router.ready_for_general().await.is_err() {
|
||||
return None;
|
||||
}
|
||||
let http = router.client_if("steam", router.routed(key), |b| b.timeout(std::time::Duration::from_secs(15)).user_agent(concat!("moho/", env!("CARGO_PKG_VERSION"))));
|
||||
let answer: Value = http.get(STORE).query(&[("appids", app.to_string()), ("filters", "basic".to_string())]).send().await.ok()?.json().await.ok()?;
|
||||
name_in(&answer, app)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn the_name_is_read_out_of_the_stores_answer() {
|
||||
let said = json!({ "730": { "success": true, "data": { "type": "game", "name": "Counter-Strike 2", "steam_appid": 730 } } });
|
||||
assert_eq!(name_in(&said, 730).as_deref(), Some("Counter-Strike 2"));
|
||||
assert_eq!(name_in(&said, 731), None, "another game is not this one");
|
||||
assert_eq!(name_in(&json!({ "99": { "success": false } }), 99), None, "a game the store does not know has no name");
|
||||
assert_eq!(name_in(&json!({ "5": { "success": true, "data": { "name": " " } } }), 5), None);
|
||||
assert_eq!(name_in(&json!([]), 5), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
//! Signing in: turning a name and a password into a token that can be used
|
||||
//! to sign in again without them.
|
||||
//!
|
||||
//! This is Steam's modern authentication service, and it happens on the same
|
||||
//! connection that will carry the chat, before the account has signed in:
|
||||
//!
|
||||
//! 1. Ask for a public key for the account name, and encrypt the password with
|
||||
//! it. The key changes, which is why it is asked for each time; the
|
||||
//! password never goes over the connection in any other form.
|
||||
//! 2. Begin a session with the name and the encrypted password. Steam answers
|
||||
//! with what it wants before it will let the session through: nothing, a
|
||||
//! code from the Steam Guard authenticator, a code it emailed, or an
|
||||
//! approval tapped on a phone.
|
||||
//! 3. Send the code if one is wanted, and poll the session. A phone approval
|
||||
//! needs nothing sent - polling is how the answer is found.
|
||||
//! 4. The poll eventually returns a **refresh token**. That, and not the
|
||||
//! password, is what is kept: a long-lived credential that signs the
|
||||
//! account in, and that can be revoked from Steam without changing the
|
||||
//! password.
|
||||
//!
|
||||
//! The password is held for the length of this exchange and no longer.
|
||||
|
||||
use super::link::{CallError, Link};
|
||||
use super::proto::*;
|
||||
use anyhow::{Context, Result};
|
||||
use base64::Engine;
|
||||
use rsa::{BigUint, Pkcs1v15Encrypt, RsaPublicKey};
|
||||
use std::time::Duration;
|
||||
|
||||
/// What moho signs in as: a desktop Steam client, the way every other third-party client does.
|
||||
///
|
||||
/// This is part of the token Steam issues and decides how Steam treats the connection that uses it. It was the
|
||||
/// mobile app's chat session (platform 3, app type 2, website "Mobile") until Steam raised an account alert for an
|
||||
/// unexpected device: a "phone" that reports a Linux desktop's operating system, has no machine id and logs on again
|
||||
/// every time its connection is closed does not look like any real device. SteamKit2, ArchiSteamFarm, node-steam-user,
|
||||
/// node-steam-session and steam-vent all sign in as the desktop client (platform 1, website "Client"), and so does this.
|
||||
///
|
||||
/// `EAuthTokenPlatformType` 1 is the desktop client. It has no app type.
|
||||
const PLATFORM_TYPE: i32 = 1;
|
||||
const WEBSITE_ID: &str = "Client";
|
||||
/// `EGamingDeviceType` 1 is an ordinary PC, as node-steam-session sends for the desktop client.
|
||||
const GAMING_DEVICE_TYPE: u32 = 1;
|
||||
|
||||
/// This install's own random number, made once and kept next to the daemon's other files, from which its machine
|
||||
/// id and logon id are drawn. One per install and not per account: a machine is one machine however many accounts
|
||||
/// sign in on it, and two installs (a test window, a second computer) are two.
|
||||
pub fn install_id() -> [u8; 16] {
|
||||
// A test never writes to the real data directory.
|
||||
#[cfg(test)]
|
||||
return [7u8; 16];
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
static ID: std::sync::OnceLock<[u8; 16]> = std::sync::OnceLock::new();
|
||||
*ID.get_or_init(|| {
|
||||
let path = crate::default_data_dir().join("steam-install-id");
|
||||
if let Some(read) = std::fs::read_to_string(&path).ok().and_then(|t| hex::decode(t.trim()).ok()).and_then(|b| <[u8; 16]>::try_from(b).ok()) {
|
||||
return read;
|
||||
}
|
||||
let made: [u8; 16] = rand::random();
|
||||
let _ = std::fs::create_dir_all(path.parent().unwrap_or(std::path::Path::new(".")));
|
||||
let _ = std::fs::write(&path, hex::encode(made));
|
||||
made
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The machine id Steam's client sends: a binary key-value object of three SHA-1 hashes (named BB3, FF2 and 3B3 in the
|
||||
/// client), here of strings made from the install's number, so it is the same each time and different for each install.
|
||||
pub fn machine_id_from(install: &[u8; 16]) -> Vec<u8> {
|
||||
use sha1::{Digest, Sha1};
|
||||
let mut out = vec![0u8];
|
||||
out.extend_from_slice(b"MessageObject\0");
|
||||
for name in ["BB3", "FF2", "3B3"] {
|
||||
let hash = hex::encode(Sha1::digest(format!("moho {name} {}", hex::encode(install)).as_bytes()));
|
||||
out.push(1);
|
||||
out.extend_from_slice(name.as_bytes());
|
||||
out.push(0);
|
||||
out.extend_from_slice(hash.as_bytes());
|
||||
out.push(0);
|
||||
}
|
||||
out.extend_from_slice(&[8, 8]);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn machine_id() -> Vec<u8> {
|
||||
machine_id_from(&install_id())
|
||||
}
|
||||
|
||||
/// What tells this install's sessions from another's on the same account: Steam ends a session when a later one with
|
||||
/// the same logon id arrives, and lets those with different ones live side by side. The desktop client sends its own
|
||||
/// private address here; this sends a number of its own, never zero (which is "none").
|
||||
pub fn logon_id_from(install: &[u8; 16]) -> u32 {
|
||||
(u32::from_le_bytes([install[0], install[1], install[2], install[3]]) | 1) ^ 0xBAAD_F00D
|
||||
}
|
||||
|
||||
pub fn logon_id() -> u32 {
|
||||
logon_id_from(&install_id())
|
||||
}
|
||||
|
||||
/// `EOSType` as the connection reports it: Linux 6.x is -184 and Windows 10
|
||||
/// is 16, sent as the unsigned number the field is.
|
||||
pub fn os_type() -> u32 {
|
||||
if cfg!(windows) {
|
||||
16
|
||||
} else {
|
||||
(-203i32) as u32
|
||||
}
|
||||
}
|
||||
|
||||
/// What the account's Steam Guard settings are asking for.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Guard {
|
||||
/// Nothing: the session goes through.
|
||||
None,
|
||||
/// A code Steam emailed to the account.
|
||||
EmailCode,
|
||||
/// A code from the Steam Guard authenticator on the person's phone.
|
||||
DeviceCode,
|
||||
/// An approval tapped in the Steam app on the person's phone.
|
||||
DeviceConfirmation,
|
||||
/// An approval by following a link in an email.
|
||||
EmailConfirmation,
|
||||
/// Something this client does not know how to answer.
|
||||
Other(i32),
|
||||
}
|
||||
|
||||
impl Guard {
|
||||
fn from_wire(kind: i32) -> Guard {
|
||||
match kind {
|
||||
1 => Guard::None,
|
||||
2 => Guard::EmailCode,
|
||||
3 => Guard::DeviceCode,
|
||||
4 => Guard::DeviceConfirmation,
|
||||
5 => Guard::EmailConfirmation,
|
||||
other => Guard::Other(other),
|
||||
}
|
||||
}
|
||||
|
||||
fn code_type(&self) -> Option<i32> {
|
||||
match self {
|
||||
Guard::EmailCode => Some(2),
|
||||
Guard::DeviceCode => Some(3),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One way Steam will accept for getting the session through, with the hint it
|
||||
/// gives (an email address with most of it hidden, for the emailed code).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Confirmation {
|
||||
pub guard: Guard,
|
||||
pub hint: Option<String>,
|
||||
}
|
||||
|
||||
/// A sign-in that has begun and is waiting on Steam Guard, or on the poll.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Begun {
|
||||
pub client_id: u64,
|
||||
pub request_id: Vec<u8>,
|
||||
/// How often Steam asks to be polled.
|
||||
pub interval: Duration,
|
||||
pub steamid: u64,
|
||||
pub confirmations: Vec<Confirmation>,
|
||||
}
|
||||
|
||||
impl Begun {
|
||||
/// Whether the session goes through with nothing more from the person.
|
||||
pub fn needs_nothing(&self) -> bool {
|
||||
self.confirmations.is_empty() || self.confirmations.iter().any(|c| c.guard == Guard::None)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a finished sign-in leaves.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Tokens {
|
||||
pub refresh_token: String,
|
||||
pub access_token: String,
|
||||
pub account_name: Option<String>,
|
||||
/// Steam's note of this machine having been approved, to send next time so
|
||||
/// it is not asked again.
|
||||
pub guard_data: Option<String>,
|
||||
}
|
||||
|
||||
/// The password, encrypted with the public key Steam gave for the account.
|
||||
pub fn encrypt_password(modulus_hex: &str, exponent_hex: &str, password: &str) -> Result<String> {
|
||||
let n = BigUint::parse_bytes(modulus_hex.trim().as_bytes(), 16).context("Steam's password key has no modulus")?;
|
||||
let e = BigUint::parse_bytes(exponent_hex.trim().as_bytes(), 16).context("Steam's password key has no exponent")?;
|
||||
let key = RsaPublicKey::new(n, e).context("Steam's password key is not a usable key")?;
|
||||
let encrypted = key.encrypt(&mut rand::thread_rng(), Pkcs1v15Encrypt, password.as_bytes()).context("encrypting the password")?;
|
||||
Ok(base64::engine::general_purpose::STANDARD.encode(encrypted))
|
||||
}
|
||||
|
||||
/// What Steam shows next to this session in the account's list of signed-in
|
||||
/// devices, and on the phone when it asks for an approval.
|
||||
pub fn device_name() -> String {
|
||||
let host = std::env::var("HOSTNAME")
|
||||
.or_else(|_| std::env::var("COMPUTERNAME"))
|
||||
.ok()
|
||||
.or_else(|| std::fs::read_to_string("/etc/hostname").ok())
|
||||
.map(|h| h.trim().to_string())
|
||||
.filter(|h| !h.is_empty());
|
||||
match host {
|
||||
Some(host) => format!("moho on {host}"),
|
||||
None => "moho".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn device_details() -> DeviceDetails {
|
||||
DeviceDetails {
|
||||
device_friendly_name: Some(device_name()),
|
||||
platform_type: Some(PLATFORM_TYPE),
|
||||
os_type: Some(os_type() as i32),
|
||||
gaming_device_type: Some(GAMING_DEVICE_TYPE),
|
||||
// Sent with a password sign-in, as the desktop client's is.
|
||||
machine_id: Some(machine_id()),
|
||||
..DeviceDetails::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// How often to poll, from what Steam asked for: never faster than twice a
|
||||
/// second, so a server that says zero is not hammered, and never slower than
|
||||
/// every thirty, so one that says an hour is not left alone. Five seconds when
|
||||
/// it said nothing.
|
||||
fn poll_interval(asked: Option<f32>) -> Duration {
|
||||
Duration::from_secs_f32(asked.filter(|s| s.is_finite()).unwrap_or(5.0).clamp(0.5, 30.0))
|
||||
}
|
||||
|
||||
/// Starts a sign-in with a name and a password.
|
||||
///
|
||||
/// `guard_data` is what a previous sign-in on this machine was given to
|
||||
/// remember it by, if there was one.
|
||||
pub async fn begin(link: &Link, account_name: &str, password: &str, guard_data: Option<&str>) -> Result<Begun, CallError> {
|
||||
let key: GetPasswordRsaPublicKeyResponse = link
|
||||
.call("Authentication.GetPasswordRSAPublicKey#1", &GetPasswordRsaPublicKeyRequest { account_name: Some(account_name.to_string()) }, false)
|
||||
.await?;
|
||||
let encrypted = encrypt_password(key.publickey_mod.as_deref().unwrap_or_default(), key.publickey_exp.as_deref().unwrap_or_default(), password)
|
||||
.map_err(|e| CallError::Unreadable(format!("{e:#}")))?;
|
||||
|
||||
let request = BeginAuthViaCredentialsRequest {
|
||||
device_friendly_name: Some(device_name()),
|
||||
account_name: Some(account_name.to_string()),
|
||||
encrypted_password: Some(encrypted),
|
||||
encryption_timestamp: key.timestamp,
|
||||
remember_login: Some(true),
|
||||
platform_type: Some(PLATFORM_TYPE),
|
||||
// Persistent: a token that outlives this run, which is the point.
|
||||
persistence: Some(1),
|
||||
website_id: Some(WEBSITE_ID.to_string()),
|
||||
device_details: Some(device_details()),
|
||||
guard_data: guard_data.filter(|g| !g.is_empty()).map(str::to_string),
|
||||
..BeginAuthViaCredentialsRequest::default()
|
||||
};
|
||||
let begun: BeginAuthViaCredentialsResponse = link.call("Authentication.BeginAuthSessionViaCredentials#1", &request, false).await?;
|
||||
|
||||
Ok(Begun {
|
||||
client_id: begun.client_id.unwrap_or_default(),
|
||||
request_id: begun.request_id.unwrap_or_default(),
|
||||
interval: poll_interval(begun.interval),
|
||||
steamid: begun.steamid.unwrap_or_default(),
|
||||
confirmations: begun
|
||||
.allowed_confirmations
|
||||
.into_iter()
|
||||
.map(|c| Confirmation { guard: Guard::from_wire(c.confirmation_type.unwrap_or_default()), hint: c.associated_message.filter(|m| !m.is_empty()) })
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Gives Steam the code that was asked for.
|
||||
pub async fn send_code(link: &Link, begun: &Begun, guard: &Guard, code: &str) -> Result<(), CallError> {
|
||||
let Some(code_type) = guard.code_type() else {
|
||||
return Err(CallError::Unreadable("that kind of confirmation takes no code".to_string()));
|
||||
};
|
||||
let _: UpdateAuthWithGuardCodeResponse = link
|
||||
.call(
|
||||
"Authentication.UpdateAuthSessionWithSteamGuardCode#1",
|
||||
&UpdateAuthWithGuardCodeRequest { client_id: Some(begun.client_id), steamid: Some(begun.steamid), code: Some(code.trim().to_string()), code_type: Some(code_type) },
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What a failure while waiting for a sign-in to be approved means: for this, Steam's "not found"
|
||||
/// and "expired" are the same - the sign-in ran out before anybody approved it - which the Steam
|
||||
/// client treats as one thing; the agreement page and the rate limit are said as the general table
|
||||
/// says them.
|
||||
pub fn waiting_failure(error: CallError) -> String {
|
||||
match error {
|
||||
CallError::Refused(super::eresult::FILE_NOT_FOUND | super::eresult::EXPIRED, _) => "that sign-in ran out before it was approved; start again".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a sign-in that a phone will approve by scanning a code. Answers with
|
||||
/// the address the code carries; the session is then polled as any other.
|
||||
pub async fn begin_qr(link: &Link) -> Result<(Begun, String), CallError> {
|
||||
let request = BeginAuthViaQrRequest { device_friendly_name: Some(device_name()), platform_type: Some(PLATFORM_TYPE), device_details: Some(device_details()), website_id: Some(WEBSITE_ID.to_string()) };
|
||||
let begun: BeginAuthViaQrResponse = link.call("Authentication.BeginAuthSessionViaQR#1", &request, false).await?;
|
||||
let url = begun.challenge_url.filter(|u| !u.is_empty()).ok_or_else(|| CallError::Unreadable("Steam gave no code to scan".to_string()))?;
|
||||
Ok((
|
||||
Begun { client_id: begun.client_id.unwrap_or_default(), request_id: begun.request_id.unwrap_or_default(), interval: poll_interval(begun.interval), steamid: 0, confirmations: Vec::new() },
|
||||
url,
|
||||
))
|
||||
}
|
||||
|
||||
/// Who a token is for: its subject, in the middle part of the token. A scan
|
||||
/// does not say, and an account is kept by its number.
|
||||
pub fn steamid_of_token(token: &str) -> Option<u64> {
|
||||
let payload = token.split('.').nth(1)?;
|
||||
let raw = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload.trim_end_matches('=')).ok()?;
|
||||
let claims: serde_json::Value = serde_json::from_slice(&raw).ok()?;
|
||||
claims["sub"].as_str()?.parse().ok()
|
||||
}
|
||||
|
||||
/// What one look at a session that is waiting for a scan found.
|
||||
pub struct Polled {
|
||||
pub tokens: Option<Tokens>,
|
||||
/// The code runs out and is replaced: the address to draw now.
|
||||
pub new_challenge: Option<String>,
|
||||
}
|
||||
|
||||
/// Asks once whether the session has gone through. `None` means not yet.
|
||||
pub async fn poll(link: &Link, begun: &mut Begun) -> Result<Option<Tokens>, CallError> {
|
||||
Ok(poll_session(link, begun).await?.tokens)
|
||||
}
|
||||
|
||||
/// The same, and what else the answer said.
|
||||
pub async fn poll_session(link: &Link, begun: &mut Begun) -> Result<Polled, CallError> {
|
||||
let answer: PollAuthSessionResponse = link
|
||||
.call(
|
||||
"Authentication.PollAuthSessionStatus#1",
|
||||
&PollAuthSessionRequest { client_id: Some(begun.client_id), request_id: Some(begun.request_id.clone()), token_to_revoke: None },
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
// A session that was handed a new id carries on under it.
|
||||
if let Some(id) = answer.new_client_id.filter(|id| *id != 0) {
|
||||
begun.client_id = id;
|
||||
}
|
||||
let new_challenge = answer.new_challenge_url.filter(|u| !u.is_empty());
|
||||
let tokens = answer.refresh_token.filter(|t| !t.is_empty()).map(|refresh_token| Tokens {
|
||||
refresh_token,
|
||||
access_token: answer.access_token.unwrap_or_default(),
|
||||
account_name: answer.account_name.filter(|n| !n.is_empty()),
|
||||
guard_data: answer.new_guard_data.filter(|g| !g.is_empty()),
|
||||
});
|
||||
Ok(Polled { tokens, new_challenge })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::backend::steam::link::{self, fake::*};
|
||||
|
||||
#[test]
|
||||
fn a_sign_in_that_ran_out_is_said_so_and_the_rest_by_the_general_table() {
|
||||
assert!(waiting_failure(CallError::Refused(9, None)).contains("ran out"));
|
||||
assert!(waiting_failure(CallError::Refused(27, None)).contains("ran out"));
|
||||
assert!(waiting_failure(CallError::Refused(84, None)).contains("limiting"));
|
||||
let page = waiting_failure(CallError::Refused(118, Some("https://login.steampowered.com/agree".into())));
|
||||
assert!(page.contains("agreement") && page.contains("https://login.steampowered.com/agree"), "{page}");
|
||||
}
|
||||
use crate::backend::steam::wire::Packet;
|
||||
use prost::Message;
|
||||
use rsa::traits::PublicKeyParts;
|
||||
use rsa::RsaPrivateKey;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
fn keypair() -> (RsaPrivateKey, String, String) {
|
||||
let private = RsaPrivateKey::new(&mut rand::thread_rng(), 1024).unwrap();
|
||||
let public = private.to_public_key();
|
||||
let (n, e) = (public.n().to_str_radix(16), public.e().to_str_radix(16));
|
||||
(private, n, e)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_password_is_encrypted_so_only_steams_key_can_read_it() {
|
||||
let (private, n, e) = keypair();
|
||||
let encrypted = encrypt_password(&n, &e, "hunter2").unwrap();
|
||||
assert!(!encrypted.contains("hunter2"));
|
||||
let bytes = base64::engine::general_purpose::STANDARD.decode(&encrypted).unwrap();
|
||||
assert_eq!(private.decrypt(Pkcs1v15Encrypt, &bytes).unwrap(), b"hunter2");
|
||||
// Encrypted afresh each time, so two sign-ins do not look the same.
|
||||
assert_ne!(encrypted, encrypt_password(&n, &e, "hunter2").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_steam_did_not_really_send_is_refused_not_used() {
|
||||
assert!(encrypt_password("", "010001", "x").is_err());
|
||||
assert!(encrypt_password("zz", "010001", "x").is_err());
|
||||
assert!(encrypt_password("0A", "010001", "x").is_err(), "too small to hold a password");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_steam_asks_for_is_told_apart() {
|
||||
assert_eq!(Guard::from_wire(3), Guard::DeviceCode);
|
||||
assert_eq!(Guard::from_wire(2), Guard::EmailCode);
|
||||
assert_eq!(Guard::from_wire(4), Guard::DeviceConfirmation);
|
||||
assert_eq!(Guard::from_wire(5), Guard::EmailConfirmation);
|
||||
assert_eq!(Guard::from_wire(1), Guard::None);
|
||||
assert_eq!(Guard::DeviceCode.code_type(), Some(3));
|
||||
assert_eq!(Guard::EmailCode.code_type(), Some(2));
|
||||
assert_eq!(Guard::DeviceConfirmation.code_type(), None, "an approval takes no code");
|
||||
assert_eq!(Guard::from_wire(99), Guard::Other(99));
|
||||
}
|
||||
|
||||
/// A Steam that wants a code from the authenticator, then issues tokens.
|
||||
fn steam_with_guard(private: &RsaPrivateKey, n: String, e: String, seen: Arc<Mutex<Vec<Packet>>>) -> Script {
|
||||
let _ = private;
|
||||
let mut polls = 0;
|
||||
Box::new(move |req| {
|
||||
seen.lock().unwrap().push(req.clone());
|
||||
let method = req.header.target_job_name.clone().unwrap_or_default();
|
||||
let reply = match method.as_str() {
|
||||
"Authentication.GetPasswordRSAPublicKey#1" => {
|
||||
GetPasswordRsaPublicKeyResponse { publickey_mod: Some(n.clone()), publickey_exp: Some(e.clone()), timestamp: Some(1790000005000) }.encode_to_vec()
|
||||
}
|
||||
"Authentication.BeginAuthSessionViaCredentials#1" => BeginAuthViaCredentialsResponse {
|
||||
client_id: Some(1234),
|
||||
request_id: Some(vec![9, 8, 7]),
|
||||
interval: Some(0.5),
|
||||
steamid: Some(76561197960287930),
|
||||
allowed_confirmations: vec![
|
||||
AllowedConfirmation { confirmation_type: Some(3), associated_message: None },
|
||||
AllowedConfirmation { confirmation_type: Some(4), associated_message: Some("Steam app".into()) },
|
||||
],
|
||||
..Default::default()
|
||||
}
|
||||
.encode_to_vec(),
|
||||
"Authentication.UpdateAuthSessionWithSteamGuardCode#1" => UpdateAuthWithGuardCodeResponse::default().encode_to_vec(),
|
||||
"Authentication.PollAuthSessionStatus#1" => {
|
||||
polls += 1;
|
||||
if polls < 2 {
|
||||
PollAuthSessionResponse::default().encode_to_vec()
|
||||
} else {
|
||||
PollAuthSessionResponse {
|
||||
refresh_token: Some("the-refresh-token".into()),
|
||||
access_token: Some("the-access-token".into()),
|
||||
account_name: Some("someone".into()),
|
||||
new_guard_data: Some("remember-me".into()),
|
||||
..Default::default()
|
||||
}
|
||||
.encode_to_vec()
|
||||
}
|
||||
}
|
||||
other => panic!("unexpected call {other}"),
|
||||
};
|
||||
Some(vec![answer(&req, 1, reply)])
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn signing_in_with_a_code_goes_through_every_step_in_order() {
|
||||
let (private, n, e) = keypair();
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let url = serve(steam_with_guard(&private, n, e, seen.clone())).await;
|
||||
let (link, _told) = link::open("steam:test", &url).await.unwrap();
|
||||
|
||||
let mut begun = begin(&link, "someone", "hunter2", Some("old-guard")).await.unwrap();
|
||||
assert_eq!(begun.steamid, 76561197960287930);
|
||||
assert_eq!(begun.interval, Duration::from_millis(500));
|
||||
assert_eq!(begun.confirmations[0].guard, Guard::DeviceCode);
|
||||
assert_eq!(begun.confirmations[1], Confirmation { guard: Guard::DeviceConfirmation, hint: Some("Steam app".into()) });
|
||||
assert!(!begun.needs_nothing());
|
||||
|
||||
send_code(&link, &begun, &Guard::DeviceCode, " AB12C ").await.unwrap();
|
||||
assert_eq!(poll(&link, &mut begun).await.unwrap(), None, "not yet");
|
||||
let tokens = poll(&link, &mut begun).await.unwrap().expect("tokens now");
|
||||
assert_eq!(tokens.refresh_token, "the-refresh-token");
|
||||
assert_eq!(tokens.account_name.as_deref(), Some("someone"));
|
||||
assert_eq!(tokens.guard_data.as_deref(), Some("remember-me"));
|
||||
|
||||
let seen = seen.lock().unwrap();
|
||||
let methods: Vec<_> = seen.iter().map(|p| p.header.target_job_name.clone().unwrap()).collect();
|
||||
assert_eq!(
|
||||
methods,
|
||||
[
|
||||
"Authentication.GetPasswordRSAPublicKey#1",
|
||||
"Authentication.BeginAuthSessionViaCredentials#1",
|
||||
"Authentication.UpdateAuthSessionWithSteamGuardCode#1",
|
||||
"Authentication.PollAuthSessionStatus#1",
|
||||
"Authentication.PollAuthSessionStatus#1",
|
||||
]
|
||||
);
|
||||
// None of it as the account: there is no account signed in yet.
|
||||
assert!(seen.iter().all(|p| p.header.steamid.is_none()));
|
||||
|
||||
let begin_req = BeginAuthViaCredentialsRequest::decode(seen[1].body.as_slice()).unwrap();
|
||||
assert_eq!(begin_req.account_name.as_deref(), Some("someone"));
|
||||
assert_eq!(begin_req.encryption_timestamp, Some(1790000005000));
|
||||
assert_eq!(begin_req.persistence, Some(1));
|
||||
assert_eq!(begin_req.guard_data.as_deref(), Some("old-guard"));
|
||||
assert_eq!(begin_req.platform_type, Some(1), "it signs in as a desktop client, like every other third-party client");
|
||||
assert_eq!(begin_req.website_id.as_deref(), Some("Client"));
|
||||
let details = begin_req.device_details.unwrap();
|
||||
assert_eq!((details.platform_type, details.app_type, details.gaming_device_type), (Some(1), None, Some(1)));
|
||||
assert_eq!(details.machine_id.as_deref(), Some(machine_id().as_slice()), "and with its install's machine id");
|
||||
assert!(!begin_req.encrypted_password.unwrap().contains("hunter2"));
|
||||
|
||||
let code_req = UpdateAuthWithGuardCodeRequest::decode(seen[2].body.as_slice()).unwrap();
|
||||
assert_eq!((code_req.client_id, code_req.steamid), (Some(1234), Some(76561197960287930)));
|
||||
assert_eq!(code_req.code.as_deref(), Some("AB12C"), "spaces around a pasted code are dropped");
|
||||
assert_eq!(code_req.code_type, Some(3));
|
||||
|
||||
let poll_req = PollAuthSessionRequest::decode(seen[3].body.as_slice()).unwrap();
|
||||
assert_eq!((poll_req.client_id, poll_req.request_id), (Some(1234), Some(vec![9, 8, 7])));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_wrong_password_says_so_and_a_code_where_none_is_wanted_is_refused() {
|
||||
let (_private, n, e) = keypair();
|
||||
let url = serve(Box::new(move |req| {
|
||||
let method = req.header.target_job_name.clone().unwrap_or_default();
|
||||
Some(vec![if method.contains("GetPasswordRSAPublicKey") {
|
||||
answer(&req, 1, GetPasswordRsaPublicKeyResponse { publickey_mod: Some(n.clone()), publickey_exp: Some(e.clone()), timestamp: Some(1) }.encode_to_vec())
|
||||
} else {
|
||||
answer(&req, 5, Vec::new())
|
||||
}])
|
||||
}))
|
||||
.await;
|
||||
let (link, _told) = link::open("steam:test", &url).await.unwrap();
|
||||
let err = begin(&link, "someone", "wrong", None).await.unwrap_err();
|
||||
assert!(matches!(err, CallError::Refused(5, _)), "{err:?}");
|
||||
assert!(err.to_string().contains("password"));
|
||||
|
||||
let begun = Begun { client_id: 1, request_id: vec![], interval: Duration::from_secs(1), steamid: 1, confirmations: vec![] };
|
||||
assert!(send_code(&link, &begun, &Guard::DeviceConfirmation, "123").await.is_err(), "an approval takes no code");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_account_without_steam_guard_needs_nothing_more() {
|
||||
let (_private, n, e) = keypair();
|
||||
let url = serve(Box::new(move |req| {
|
||||
let method = req.header.target_job_name.clone().unwrap_or_default();
|
||||
Some(vec![if method.contains("GetPasswordRSAPublicKey") {
|
||||
answer(&req, 1, GetPasswordRsaPublicKeyResponse { publickey_mod: Some(n.clone()), publickey_exp: Some(e.clone()), timestamp: Some(1) }.encode_to_vec())
|
||||
} else {
|
||||
answer(
|
||||
&req,
|
||||
1,
|
||||
BeginAuthViaCredentialsResponse {
|
||||
client_id: Some(1),
|
||||
request_id: Some(vec![1]),
|
||||
interval: Some(1.0),
|
||||
steamid: Some(2),
|
||||
allowed_confirmations: vec![AllowedConfirmation { confirmation_type: Some(1), associated_message: None }],
|
||||
..Default::default()
|
||||
}
|
||||
.encode_to_vec(),
|
||||
)
|
||||
}])
|
||||
}))
|
||||
.await;
|
||||
let (link, _told) = link::open("steam:test", &url).await.unwrap();
|
||||
assert!(begin(&link, "someone", "x", None).await.unwrap().needs_nothing());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_polling_interval_is_kept_to_something_sensible() {
|
||||
assert_eq!(poll_interval(Some(0.0)), Duration::from_millis(500));
|
||||
assert_eq!(poll_interval(Some(3600.0)), Duration::from_secs(30));
|
||||
assert_eq!(poll_interval(Some(2.0)), Duration::from_secs(2));
|
||||
assert_eq!(poll_interval(None), Duration::from_secs(5));
|
||||
assert_eq!(poll_interval(Some(f32::NAN)), Duration::from_secs(5), "not a number is not an answer");
|
||||
assert_eq!(poll_interval(Some(-3.0)), Duration::from_millis(500));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_session_is_named_for_the_machine_it_is_on() {
|
||||
assert!(device_name().starts_with("moho"));
|
||||
assert_eq!(os_type(), if cfg!(windows) { 16 } else { 4294967093 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_says_whose_it_is_in_its_subject() {
|
||||
use base64::Engine;
|
||||
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"iss":"steam","sub":"76561197960287930","aud":["mobile"]}"#);
|
||||
assert_eq!(steamid_of_token(&format!("eyJhbGciOiJFZERTQSJ9.{payload}.signature")), Some(76561197960287930));
|
||||
assert_eq!(steamid_of_token("not a token"), None);
|
||||
assert_eq!(steamid_of_token("a.b.c"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_machine_id_is_a_key_value_object_of_three_hashes_that_is_the_same_for_one_install_and_not_for_another() {
|
||||
let one = machine_id_from(&[1u8; 16]);
|
||||
assert_eq!(one, machine_id_from(&[1u8; 16]));
|
||||
assert_ne!(one, machine_id_from(&[2u8; 16]));
|
||||
// 0x00 "MessageObject" 0x00, then three strings named BB3, FF2 and 3B3 of forty hex digits, then 0x08 0x08.
|
||||
assert!(one.starts_with(b"\0MessageObject\0\x01BB3\0"));
|
||||
assert!(one.ends_with(&[0, 8, 8]));
|
||||
let text = String::from_utf8_lossy(&one).to_string();
|
||||
for name in ["BB3", "FF2", "3B3"] {
|
||||
assert!(text.contains(name));
|
||||
}
|
||||
assert_eq!(one.len(), 1 + 14 + 3 * (1 + 3 + 1 + 40 + 1) + 2);
|
||||
assert_ne!(logon_id_from(&[1u8; 16]), 0);
|
||||
assert_ne!(logon_id_from(&[1u8; 16]), logon_id_from(&[2u8; 16]));
|
||||
// Never the same as the zero a client with no id of its own sends.
|
||||
assert_ne!(logon_id_from(&[0xFF; 16]), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
//! Steam's BBCode, read into the markup the rest of moho already draws.
|
||||
//!
|
||||
//! A message on Steam is text with tags in it: `[b]bold[/b]`, `[url=...]`,
|
||||
//! `[emoticon]steamhappy[/emoticon]`. The window has no BBCode reader for
|
||||
//! Steam and does not need one: it draws Discord's markdown, spoilers and
|
||||
//! quotes, and a handful of stand-ins for pictures. So what is stored is the
|
||||
//! message as that dialect - `**bold**`, `[text](url)`, `[steam:emote:steamhappy]`
|
||||
//! - and everything after this point treats it like any other body.
|
||||
//!
|
||||
//! The text is the sender's and is never trusted to be well formed. An
|
||||
//! unknown tag is text; a tag that is never closed is text; a close with
|
||||
//! nothing open is text. Nothing here produces markup the window does not
|
||||
//! already treat as untrusted, and nothing is lost for being misread - what is
|
||||
//! unreadable is shown as it was written.
|
||||
|
||||
use crate::model::Embed;
|
||||
|
||||
/// A bracket that is only a bracket: followed by a zero-width space, which is
|
||||
/// what stops anything downstream taking `[b]` for formatting.
|
||||
pub const LITERAL_BRACKET: &str = "[\u{200b}";
|
||||
|
||||
/// What a tag becomes, given what was inside it already read. A card Steam
|
||||
/// describes goes in `cards` as well as being words.
|
||||
fn render(tag: &str, attr: Option<&str>, inner: &str, cards: &mut Vec<Embed>) -> String {
|
||||
let wrapped = |mark: &str| if inner.trim().is_empty() { inner.to_string() } else { format!("{mark}{inner}{mark}") };
|
||||
match tag {
|
||||
"b" | "h1" | "h2" | "h3" | "h4" | "h5" => wrapped("**"),
|
||||
"i" => wrapped("*"),
|
||||
// The window reads `[u]` itself; Markdown's `__` it draws as bold.
|
||||
"u" if inner.trim().is_empty() => inner.to_string(),
|
||||
"u" => format!("[u]{inner}[/u]"),
|
||||
"strike" | "s" => wrapped("~~"),
|
||||
"spoiler" => wrapped("||"),
|
||||
"quote" | "pullquote" => {
|
||||
let quoted: Vec<String> = inner.trim().lines().map(|l| format!("> {l}")).collect();
|
||||
format!("\n{}\n", quoted.join("\n"))
|
||||
}
|
||||
// Steam writes extra attributes after the address (`fromclient=1`), which
|
||||
// are not part of it.
|
||||
"url" => match attr.and_then(|a| a.split_whitespace().next()).filter(|u| is_web(u)) {
|
||||
Some(url) if inner.trim().is_empty() || inner.trim() == url => url.to_string(),
|
||||
Some(url) => format!("[{}]({url})", inner.replace(']', "\\]")),
|
||||
// `[url]address[/url]`: the address is what was written.
|
||||
None => inner.to_string(),
|
||||
},
|
||||
"emoticon" => picture("emote", inner).unwrap_or_else(|| inner.to_string()),
|
||||
// Named by its `type`, with nothing inside: `[sticker type="Nyan GLaDOS"
|
||||
// limit="0"][/sticker]`. A name inside is read too.
|
||||
"sticker" => {
|
||||
let named = attr.and_then(|a| attribute(a, "type")).filter(|n| !n.is_empty()).unwrap_or(inner.trim());
|
||||
if super::emoticons::valid_sticker(named) { format!("[steam:sticker:{named}]") } else { inner.to_string() }
|
||||
}
|
||||
// A web page Steam's client has looked at and is describing: `[og url="..."
|
||||
// title="..." desc="..."]words[/og]`. The words are a link; the description
|
||||
// is a card, drawn as any other link's is.
|
||||
"og" => match attr.and_then(|a| attribute(a, "url")).filter(|u| is_web(u)) {
|
||||
Some(url) => {
|
||||
let field = |name: &str| attr.and_then(|a| attribute(a, name)).map(str::trim).filter(|v| !v.is_empty()).map(str::to_string);
|
||||
let image_url = field("img").filter(|i| is_web(i));
|
||||
cards.push(Embed { url: Some(url.to_string()), title: field("title"), description: field("desc"), provider: host_of(url), image_url, ..Default::default() });
|
||||
let words = inner.trim();
|
||||
if words.is_empty() || words == url { url.to_string() } else { format!("[{}]({url})", words.replace(']', "\\]")) }
|
||||
}
|
||||
None => inner.to_string(),
|
||||
},
|
||||
// What Steam writes for a file sent through its website: the address
|
||||
// as an attribute, and again as a link inside, which is not read. A
|
||||
// marker the window draws as the picture or video it names.
|
||||
"img" | "video" | "looping_media" if attr.and_then(|a| attribute(a, "src")).is_some_and(is_web) => {
|
||||
let kind = if tag == "img" { "image" } else { "video" };
|
||||
format!("\n[steam:{kind}:{}]\n", attr.and_then(|a| attribute(a, "src")).unwrap_or_default())
|
||||
}
|
||||
// A picture is its address, which is what the link previews read.
|
||||
"img" | "docimg" => format!("\n{}\n", inner.trim()),
|
||||
"p" => format!("{inner}\n"),
|
||||
// Tags that only lay out or colour: what is inside is the message.
|
||||
"center" | "c" | "color" | "bgcolor" | "size" | "smalltext" | "list" | "olist" | "dynamiclink" | "doclink" | "expand" | "table" | "tr" | "td" | "th" => inner.to_string(),
|
||||
// A person named in a group: the words are the name, with its @.
|
||||
"mention" => inner.to_string(),
|
||||
// A friend's invitation to a group chat: `[invite chatgroupid="7" chatgroupname="Raiders"]`.
|
||||
// Said in words; what it offers is answered on the Invitations tile.
|
||||
"invite" => {
|
||||
let name = attr.and_then(|a| attribute(a, "chatgroupname")).unwrap_or_default().replace(['[', ']'], "");
|
||||
if name.trim().is_empty() { "Group chat invitation".to_string() } else { format!("Group chat invitation: {}", name.trim()) }
|
||||
}
|
||||
// What Steam's own servers wrap a store address in (`[steamstore app="220"]`):
|
||||
// the address is the message, and the window makes a card of it as of any link.
|
||||
"steamstore" => inner.to_string(),
|
||||
// Effects are an animation over the window, with nothing to say.
|
||||
"roomeffect" => String::new(),
|
||||
_ => inner.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The site an address is on, without a leading `www.`: what a card says it is from.
|
||||
fn host_of(url: &str) -> Option<String> {
|
||||
let rest = url.split_once("://")?.1;
|
||||
let host = rest.split(['/', '?', '#']).next()?.rsplit('@').next()?.split(':').next()?;
|
||||
let host = host.strip_prefix("www.").unwrap_or(host);
|
||||
(!host.is_empty()).then(|| host.to_string())
|
||||
}
|
||||
|
||||
/// Every tag that is read; anything else stays as the text it was typed as.
|
||||
const KNOWN: &[&str] = &[
|
||||
"b", "i", "u", "strike", "s", "spoiler", "h1", "h2", "h3", "h4", "h5", "quote", "pullquote", "url", "emoticon", "sticker", "img", "docimg", "p", "center", "c", "color", "bgcolor", "size",
|
||||
"video", "looping_media", "og", "smalltext", "list", "olist", "dynamiclink", "doclink", "expand", "table", "tr", "td", "th", "roomeffect", "code", "noparse", "hr", "*", "mention", "steamstore", "invite",
|
||||
];
|
||||
|
||||
/// Whose contents are not read: code is code, and noparse says so.
|
||||
const RAW: &[&str] = &["code", "noparse"];
|
||||
|
||||
/// An attribute written as `name="value"`, `name=value`, or among others
|
||||
/// (`src="..." width="..."`): the value, unquoted.
|
||||
pub(super) fn attribute<'a>(attrs: &'a str, name: &str) -> Option<&'a str> {
|
||||
let lower = attrs.to_ascii_lowercase();
|
||||
let mut from = 0;
|
||||
while let Some(at) = lower[from..].find(&format!("{name}=")) {
|
||||
let start = from + at;
|
||||
let boundary = start == 0 || lower.as_bytes()[start - 1].is_ascii_whitespace();
|
||||
let value = &attrs[start + name.len() + 1..];
|
||||
if boundary {
|
||||
return Some(match value.strip_prefix('"') {
|
||||
Some(quoted) => quoted.split('"').next().unwrap_or_default(),
|
||||
None => value.split_whitespace().next().unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
from = start + 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn is_web(url: &str) -> bool {
|
||||
let lower = url.to_ascii_lowercase();
|
||||
(lower.starts_with("http://") || lower.starts_with("https://")) && !url.contains(char::is_whitespace)
|
||||
}
|
||||
|
||||
/// The stand-in for a picture: an emoticon or a sticker by name. A name is
|
||||
/// letters, digits, underscore and hyphen, with the colons somebody typed
|
||||
/// dropped; anything else is not a picture's name.
|
||||
fn picture(kind: &str, name: &str) -> Option<String> {
|
||||
let name = name.trim().trim_matches(':');
|
||||
(!name.is_empty() && name.len() <= 64 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')).then(|| format!("[steam:{kind}:{name}]"))
|
||||
}
|
||||
|
||||
/// A tag read at the start of `s`: its name, its `=value` or attributes, whether it closes, and how
|
||||
/// much text it took.
|
||||
struct Tag<'a> {
|
||||
name: String,
|
||||
attr: Option<&'a str>,
|
||||
closing: bool,
|
||||
len: usize,
|
||||
}
|
||||
|
||||
fn read_tag(s: &str) -> Option<Tag<'_>> {
|
||||
let rest = s.strip_prefix('[')?;
|
||||
let end = rest.find(']')?;
|
||||
// Long enough for a picture's tag, which carries its address twice and
|
||||
// its size.
|
||||
if end > 1000 {
|
||||
return None;
|
||||
}
|
||||
let inside = &rest[..end];
|
||||
let (closing, inside) = match inside.strip_prefix('/') {
|
||||
Some(i) => (true, i),
|
||||
None => (false, inside),
|
||||
};
|
||||
let name_len = inside.find(|c: char| !(c.is_ascii_alphanumeric() || c == '*')).unwrap_or(inside.len());
|
||||
let name = inside[..name_len].to_ascii_lowercase();
|
||||
if name.is_empty() || !KNOWN.contains(&name.as_str()) {
|
||||
return None;
|
||||
}
|
||||
let after = &inside[name_len..];
|
||||
let attr = match after.chars().next() {
|
||||
None => None,
|
||||
Some('=') => Some(after[1..].trim_matches('"')),
|
||||
// `[sticker type="x" limit="1"]`: attributes, which are not read.
|
||||
Some(c) if c.is_whitespace() => Some(after.trim()),
|
||||
Some(_) => return None,
|
||||
};
|
||||
Some(Tag { name, attr, closing, len: end + 2 })
|
||||
}
|
||||
|
||||
/// The text of a message as moho draws it, and the cards Steam described in it.
|
||||
pub struct Rendered {
|
||||
pub text: String,
|
||||
pub cards: Vec<Embed>,
|
||||
}
|
||||
|
||||
/// The text of a message as moho draws it.
|
||||
pub fn to_markup(bbcode: &str) -> String {
|
||||
render_message(bbcode).text
|
||||
}
|
||||
|
||||
/// A message read into what moho draws, with its cards.
|
||||
pub fn render_message(bbcode: &str) -> Rendered {
|
||||
let mut cards = Vec::new();
|
||||
let text = convert(bbcode, &mut cards);
|
||||
Rendered { text, cards }
|
||||
}
|
||||
|
||||
fn convert(bbcode: &str, cards: &mut Vec<Embed>) -> String {
|
||||
struct Open {
|
||||
tag: String,
|
||||
attr: Option<String>,
|
||||
literal: String,
|
||||
out: String,
|
||||
}
|
||||
let mut stack: Vec<Open> = Vec::new();
|
||||
let mut top = String::new();
|
||||
let mut i = 0;
|
||||
let bytes = bbcode;
|
||||
|
||||
macro_rules! out {
|
||||
() => {
|
||||
stack.last_mut().map(|f| &mut f.out).unwrap_or(&mut top)
|
||||
};
|
||||
}
|
||||
|
||||
while i < bytes.len() {
|
||||
let rest = &bytes[i..];
|
||||
// `\[` is a bracket somebody typed, which Steam escapes so it is not a tag.
|
||||
// Drawn as a bracket with nothing visible after it, so that the window,
|
||||
// which reads `[b]` for itself, does not read it as a tag either.
|
||||
if let Some(r) = rest.strip_prefix("\\[") {
|
||||
out!().push_str(LITERAL_BRACKET);
|
||||
i = bytes.len() - r.len();
|
||||
continue;
|
||||
}
|
||||
if rest.starts_with('[') {
|
||||
if let Some(tag) = read_tag(rest) {
|
||||
if !tag.closing && RAW.contains(&tag.name.as_str()) {
|
||||
// Everything up to the matching close, unread.
|
||||
let lower = rest.to_ascii_lowercase();
|
||||
let close = format!("[/{}]", tag.name);
|
||||
if let Some(at) = lower[tag.len..].find(&close) {
|
||||
let inner = &rest[tag.len..tag.len + at];
|
||||
let drawn = if tag.name == "code" {
|
||||
code(inner)
|
||||
} else {
|
||||
inner.to_string()
|
||||
};
|
||||
out!().push_str(&drawn);
|
||||
i += tag.len + at + close.len();
|
||||
continue;
|
||||
}
|
||||
} else if !tag.closing && tag.name == "hr" {
|
||||
out!().push('\n');
|
||||
i += tag.len;
|
||||
continue;
|
||||
} else if !tag.closing && tag.name == "*" {
|
||||
out!().push_str("\n• ");
|
||||
i += tag.len;
|
||||
continue;
|
||||
} else if !tag.closing && !RAW.contains(&tag.name.as_str()) {
|
||||
stack.push(Open { tag: tag.name, attr: tag.attr.map(str::to_string), literal: rest[..tag.len].to_string(), out: String::new() });
|
||||
i += tag.len;
|
||||
continue;
|
||||
} else if tag.closing {
|
||||
if let Some(pos) = stack.iter().rposition(|f| f.tag == tag.name) {
|
||||
// Whatever was left open inside it is closed with it:
|
||||
// shown as the tag text it was, with what it held.
|
||||
while stack.len() > pos + 1 {
|
||||
let f = stack.pop().unwrap();
|
||||
let text = format!("{}{}", f.literal, f.out);
|
||||
out!().push_str(&text);
|
||||
}
|
||||
let f = stack.pop().unwrap();
|
||||
let drawn = render(&f.tag, f.attr.as_deref(), &f.out, cards);
|
||||
out!().push_str(&drawn);
|
||||
i += tag.len;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let c = rest.chars().next().unwrap();
|
||||
out!().push(c);
|
||||
i += c.len_utf8();
|
||||
}
|
||||
// Opened and never closed: the tag as typed, then what followed it.
|
||||
while let Some(f) = stack.pop() {
|
||||
let text = format!("{}{}", f.literal, f.out);
|
||||
out!().push_str(&text);
|
||||
}
|
||||
top.trim().to_string()
|
||||
}
|
||||
|
||||
/// The longest run of one character in some text.
|
||||
fn longest_run(text: &str, of: char) -> usize {
|
||||
text.split(|c| c != of).map(str::len).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Code, as the backticks the window reads: inline for a line, a block for
|
||||
/// several, and a fence longer than anything inside it so it cannot end early.
|
||||
fn code(inner: &str) -> String {
|
||||
let longest = longest_run(inner, '`');
|
||||
if inner.contains('\n') {
|
||||
let fence = "`".repeat(longest.max(2) + 1);
|
||||
format!("\n{fence}\n{}\n{fence}\n", inner.trim_matches('\n'))
|
||||
} else {
|
||||
let fence = "`".repeat(longest + 1);
|
||||
let pad = if inner.starts_with('`') || inner.ends_with('`') { " " } else { "" };
|
||||
format!("{fence}{pad}{inner}{pad}{fence}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_inline_formats_become_the_marks_the_window_draws() {
|
||||
assert_eq!(to_markup("[b]bold[/b] and [i]italic[/i] and [u]under[/u] and [strike]struck[/strike]"), "**bold** and *italic* and [u]under[/u] and ~~struck~~");
|
||||
assert_eq!(to_markup("a [spoiler]secret[/spoiler] b"), "a ||secret|| b");
|
||||
assert_eq!(to_markup("[B]shouted[/B]"), "**shouted**", "tags are not case sensitive");
|
||||
assert_eq!(to_markup("[h1]Title[/h1]"), "**Title**");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn they_nest_and_an_empty_one_says_nothing() {
|
||||
assert_eq!(to_markup("[b][i]both[/i][/b]"), "***both***");
|
||||
assert_eq!(to_markup("[b][/b]x"), "x");
|
||||
assert_eq!(to_markup("a[b] [/b]b"), "a b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_are_markdown_links_or_the_address_itself() {
|
||||
assert_eq!(to_markup("[url=https://example.com/a?b=1]the site[/url]"), "[the site](https://example.com/a?b=1)");
|
||||
assert_eq!(to_markup("see [url]https://example.com[/url] now"), "see https://example.com now");
|
||||
assert_eq!(to_markup("[url=https://example.com][/url]"), "https://example.com");
|
||||
// Not a web address, so not a link: what was said is shown.
|
||||
assert_eq!(to_markup("[url=javascript:alert(1)]click[/url]"), "click");
|
||||
// Steam's own writing of a link, with its extra attribute; the address is the first word.
|
||||
assert_eq!(to_markup("[url=https://example.com/x fromclient=1]a link[/url]"), "[a link](https://example.com/x)");
|
||||
assert_eq!(to_markup("see [url=https://example.com/x fromclient=1]https://example.com/x[/url]!"), "see https://example.com/x!", "a link that is its own label is just the address");
|
||||
assert_eq!(to_markup("[url=\"https://example.com\"]quoted[/url]"), "[quoted](https://example.com)");
|
||||
assert_eq!(to_markup("[url=https://e.com]a ] b[/url]"), "[a \\] b](https://e.com)", "a bracket in the label cannot end it early");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_invitation_is_said_in_words() {
|
||||
assert_eq!(to_markup("[invite chatgroupid=\"78995476\" chatgroupname=\"moho official test\"][/invite]"), "Group chat invitation: moho official test");
|
||||
assert_eq!(to_markup("[invite chatgroupid=\"7\"][/invite]"), "Group chat invitation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_store_address_wrapped_by_steam_is_the_address() {
|
||||
assert_eq!(to_markup("see [steamstore app=\"220\"]https://store.steampowered.com/app/220/[/steamstore]"), "see https://store.steampowered.com/app/220/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emoticons_and_stickers_are_named_pictures() {
|
||||
assert_eq!(to_markup("hi [emoticon]steamhappy[/emoticon]!"), "hi [steam:emote:steamhappy]!");
|
||||
assert_eq!(to_markup("[emoticon]:steamhappy:[/emoticon]"), "[steam:emote:steamhappy]", "colons are not part of the name");
|
||||
assert_eq!(to_markup("[sticker type=\"Nyan GLaDOS\" limit=\"0\"][/sticker]"), "[steam:sticker:Nyan GLaDOS]", "as Steam writes one: named by its type");
|
||||
assert_eq!(to_markup("[sticker type=\"Keyboard Headcrab\" limit=\"0\"][/sticker]"), "[steam:sticker:Keyboard Headcrab]");
|
||||
assert_eq!(to_markup("a [sticker type=\"Cats\" limit=\"1\"]CatSurprise[/sticker] b"), "a [steam:sticker:Cats] b", "the type is the name");
|
||||
assert_eq!(to_markup("[sticker][/sticker]"), "", "no name, no sticker");
|
||||
assert_eq!(to_markup("[sticker]CatSurprise[/sticker]"), "[steam:sticker:CatSurprise]", "or the name inside");
|
||||
// Anything that is not a name is not a picture, and is shown as it was.
|
||||
assert_eq!(to_markup("[emoticon]a b[/emoticon]"), "a b");
|
||||
assert_eq!(to_markup("[emoticon]x]y[/emoticon]"), "x]y");
|
||||
assert_eq!(to_markup("[emoticon][/emoticon]"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quotes_are_lines_of_their_own() {
|
||||
assert_eq!(to_markup("before [quote]one\ntwo[/quote] after"), "before \n> one\n> two\n after");
|
||||
assert_eq!(to_markup("[quote=somebody]said this[/quote]"), "> said this");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_and_noparse_are_not_read() {
|
||||
assert_eq!(to_markup("a [code][b]not bold[/b][/code] b"), "a `[b]not bold[/b]` b");
|
||||
assert_eq!(to_markup("[noparse][b]literally[/b][/noparse]"), "[b]literally[/b]");
|
||||
assert_eq!(to_markup("[code]line one\nline two[/code]"), "```\nline one\nline two\n```");
|
||||
// A backtick inside code cannot end it: the fence is longer than any run.
|
||||
assert_eq!(to_markup("[code]a `tick` b[/code]"), "``a `tick` b``");
|
||||
// An unclosed code is text, and what follows is still read.
|
||||
assert_eq!(to_markup("[code]open [b]x[/b]"), "[code]open **x**");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_cannot_be_read_is_shown_as_it_was_written() {
|
||||
assert_eq!(to_markup("[nosuchtag]x[/nosuchtag]"), "[nosuchtag]x[/nosuchtag]");
|
||||
assert_eq!(to_markup("[b]never closed"), "[b]never closed");
|
||||
assert_eq!(to_markup("closed [/b] with nothing open"), "closed [/b] with nothing open");
|
||||
assert_eq!(to_markup("[b]wrong [i]order[/b] here[/i]"), "**wrong [i]order** here[/i]", "closing an outer tag closes what was left open inside it");
|
||||
assert_eq!(to_markup("a [ lone bracket and [b"), "a [ lone bracket and [b");
|
||||
assert_eq!(to_markup(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_escaped_bracket_is_a_bracket_somebody_typed() {
|
||||
assert_eq!(to_markup("not \\[b]bold\\[/b]"), "not [\u{200b}b]bold[\u{200b}/b]", "a bracket the window will not take for a tag");
|
||||
assert_eq!(to_markup("\\[emoticon]x\\[/emoticon]"), "[\u{200b}emoticon]x[\u{200b}/emoticon]");
|
||||
assert_eq!(to_markup("\\[steam:emote:x]"), "[\u{200b}steam:emote:x]", "nor for a picture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_effects_and_layout_do_not_get_in_the_way() {
|
||||
assert_eq!(to_markup("[list][*]one[*]two[/list]"), "• one\n• two");
|
||||
assert_eq!(to_markup("[color=red]coloured[/color] [size=5]big[/size]"), "coloured big");
|
||||
assert_eq!(to_markup("[roomeffect]fireworks[/roomeffect]hello"), "hello");
|
||||
assert_eq!(to_markup("a[hr]b"), "a\nb");
|
||||
assert_eq!(to_markup("[img]https://example.com/a.png[/img]"), "https://example.com/a.png");
|
||||
let ugc = "https://images.steamusercontent.com/ugc/1269778906054997137/522C3130A7C6AE1FC52CB49820609316A34ECD01/";
|
||||
let video = "https://cdn.steamusercontent.com/ugc/9279387166147417773/0642C6E9889E1C2419DD7F85743538C67DD8CA36/";
|
||||
// As Steam writes them: the address unquoted, long, and again inside as a link.
|
||||
let picture = format!("[img src={ugc} thumbnail_src={ugc} srcset=\"\" width=479 height=450][url={ugc}]{ugc}[/url][/img]");
|
||||
assert_eq!(to_markup(&picture), format!("[steam:image:{ugc}]"));
|
||||
let clip = format!("[video src={video} type=video/webm steamvideo=true][url={video}]{video}[/url][/video]");
|
||||
assert_eq!(to_markup(&clip), format!("[steam:video:{video}]"));
|
||||
assert_eq!(to_markup(&format!("[spoiler]{clip}[/spoiler]")), format!("||\n[steam:video:{video}]\n||"), "hidden, as the sender chose");
|
||||
assert_eq!(to_markup(&format!("look {picture} nice")), format!("look \n[steam:image:{ugc}]\n nice"));
|
||||
assert_eq!(to_markup("[img src=\"https://x.test/a.png\" width=\"9\"][/img]"), "[steam:image:https://x.test/a.png]", "quoted too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_text_passes_through_untouched_including_what_is_not_ascii() {
|
||||
assert_eq!(to_markup("héllo wörld 日本語 😀 [not a tag"), "héllo wörld 日本語 😀 [not a tag");
|
||||
assert_eq!(to_markup("2 < 3 && <b>html</b> is text"), "2 < 3 && <b>html</b> is text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hostile_message_cannot_loop_or_blow_the_stack() {
|
||||
let nested = format!("{}x{}", "[b]".repeat(5000), "[/b]".repeat(5000));
|
||||
let drawn = to_markup(&nested);
|
||||
assert!(drawn.contains('x'));
|
||||
let unclosed = "[b][i][u]".repeat(2000);
|
||||
let _ = to_markup(&unclosed);
|
||||
let brackets = format!("{}{}", "[".repeat(10000), "]".repeat(10000));
|
||||
assert_eq!(to_markup(&brackets).len(), 20000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_steam_describes_is_words_and_a_card() {
|
||||
// As the official client sends one.
|
||||
let sent = "[og url=\"http://x.salastil.com\" title=\"Home\" desc=\"A privacy-focused Twitter/X frontend\"]x.salastil.com[/og]";
|
||||
let rendered = render_message(sent);
|
||||
assert_eq!(rendered.text, "[x.salastil.com](http://x.salastil.com)", "a link, on the words that were shown");
|
||||
assert_eq!(rendered.cards.len(), 1);
|
||||
let card = &rendered.cards[0];
|
||||
assert_eq!(
|
||||
(card.url.as_deref(), card.title.as_deref(), card.description.as_deref(), card.provider.as_deref()),
|
||||
(Some("http://x.salastil.com"), Some("Home"), Some("A privacy-focused Twitter/X frontend"), Some("x.salastil.com"))
|
||||
);
|
||||
// The words, with others around them; a repeat of the address; no description.
|
||||
let more = render_message("see [og url=\"https://www.example.com/a?b=1\" title=\"T\"]https://www.example.com/a?b=1[/og] ok");
|
||||
assert_eq!(more.text, "see https://www.example.com/a?b=1 ok");
|
||||
assert_eq!((more.cards[0].provider.as_deref(), more.cards[0].description.as_deref()), (Some("example.com"), None), "no leading www, and no description invented");
|
||||
// A picture on the card, where the page has one.
|
||||
let pictured = render_message("[og url=\"https://x.test/p\" img=\"https://community.steamstatic.com/chat/image/abc/share_image\" title=\"T\" desc=\"D\"]https://x.test/p[/og]");
|
||||
assert_eq!(pictured.cards[0].image_url.as_deref(), Some("https://community.steamstatic.com/chat/image/abc/share_image"));
|
||||
assert_eq!(pictured.text, "https://x.test/p");
|
||||
// Not a web address: nothing to make a card of.
|
||||
assert!(render_message("[og url=\"javascript:alert(1)\" title=\"x\"]words[/og]").cards.is_empty());
|
||||
assert_eq!(to_markup("[og title=\"x\"]just words[/og]"), "just words");
|
||||
assert!(render_message("plain https://example.com").cards.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_keeps_its_address_and_its_words() {
|
||||
assert_eq!(to_markup("[url=http://salastil.com]salastil.com[/url]"), "[salastil.com](http://salastil.com)");
|
||||
assert_eq!(to_markup("[url=https://salastil.com]https://salastil.com[/url]"), "https://salastil.com");
|
||||
assert_eq!(to_markup("[url=https://example.com/a fromclient=1]look[/url]"), "[look](https://example.com/a)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! The people an account has blocked.
|
||||
//!
|
||||
//! A blocked person is not a friend and has no conversation, so there is nowhere
|
||||
//! in the conversation list to lift a block from. They are listed on the page
|
||||
//! where friends are added, by name once Steam has said it, and unblocked there.
|
||||
|
||||
use super::friends::Roster;
|
||||
use super::steamid;
|
||||
use crate::state::AppState;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
static HELD: LazyLock<Mutex<HashMap<String, Value>>> = LazyLock::new(Default::default);
|
||||
|
||||
/// Who is blocked, as the window reads it: the 64-bit id as text and a name.
|
||||
pub fn snapshot(roster: &Roster) -> Value {
|
||||
Value::Array(
|
||||
roster
|
||||
.blocked()
|
||||
.into_iter()
|
||||
.map(|a| json!({ "steamid": steamid::text_of(a), "name": roster.persona(a).and_then(|p| p.name.clone()).unwrap_or_else(|| steamid::text_of(a)) }))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn held(account_id: &str) -> Value {
|
||||
HELD.lock().unwrap().get(account_id).cloned().unwrap_or_else(|| json!([]))
|
||||
}
|
||||
|
||||
pub fn clear(account_id: &str) {
|
||||
HELD.lock().unwrap().remove(account_id);
|
||||
}
|
||||
|
||||
/// Tells the window who is blocked, when that is different from last time.
|
||||
pub fn publish(state: &AppState, account_id: &str, roster: &Roster) {
|
||||
let now = snapshot(roster);
|
||||
{
|
||||
let mut all = HELD.lock().unwrap();
|
||||
if all.get(account_id) == Some(&now) || (now == json!([]) && !all.contains_key(account_id)) {
|
||||
return;
|
||||
}
|
||||
all.insert(account_id.to_string(), now.clone());
|
||||
}
|
||||
state.events.emit("steamBlocked", json!({ "accountId": account_id, "blocked": now }));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::backend::steam::proto::*;
|
||||
|
||||
#[test]
|
||||
fn the_blocked_are_listed_by_name_once_it_is_known_and_by_number_before() {
|
||||
let mut roster = Roster::default();
|
||||
roster.apply_friends_list(&ClientFriendsList {
|
||||
friends: vec![
|
||||
FriendsListEntry { ulfriendid: Some(steamid::steamid_of(100)), efriendrelationship: Some(1) },
|
||||
FriendsListEntry { ulfriendid: Some(steamid::steamid_of(200)), efriendrelationship: Some(3) },
|
||||
FriendsListEntry { ulfriendid: Some(steamid::steamid_of(300)), efriendrelationship: Some(6) },
|
||||
],
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(snapshot(&roster), json!([{ "steamid": steamid::text_of(100), "name": steamid::text_of(100) }, { "steamid": steamid::text_of(300), "name": steamid::text_of(300) }]));
|
||||
roster.apply_persona(&ClientPersonaState { friends: vec![PersonaFriend { friendid: Some(steamid::steamid_of(100)), player_name: Some("Spammer".into()), ..Default::default() }], ..Default::default() });
|
||||
assert_eq!(snapshot(&roster)[0]["name"], "Spammer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_is_told_once_and_again_only_when_it_changes() {
|
||||
let state = crate::backend::kick::testkit::simulated_daemon("steam-blocked");
|
||||
let mut rx = state.events.subscribe();
|
||||
let mut roster = Roster::default();
|
||||
publish(&state, "steam:blocked-test", &roster);
|
||||
assert!(rx.try_recv().is_err(), "nobody blocked, nothing to say");
|
||||
roster.apply_friends_list(&ClientFriendsList { friends: vec![FriendsListEntry { ulfriendid: Some(steamid::steamid_of(100)), efriendrelationship: Some(1) }], ..Default::default() });
|
||||
publish(&state, "steam:blocked-test", &roster);
|
||||
publish(&state, "steam:blocked-test", &roster);
|
||||
let mut told = 0;
|
||||
while let Ok(e) = rx.try_recv() {
|
||||
if e.name == "steamBlocked" {
|
||||
told += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(told, 1);
|
||||
assert_eq!(held("steam:blocked-test").as_array().map(Vec::len), Some(1));
|
||||
clear("steam:blocked-test");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
//! Direct messages: reading what arrives, and sending.
|
||||
//!
|
||||
//! A message to a friend is a call to Steam's `FriendMessages` service, and a
|
||||
//! message from one arrives as a notification from it. Steam stamps each with
|
||||
//! the second it was sent and an ordinal that orders messages sharing a
|
||||
//! second, and the pair is the message's identity here: the same message seen
|
||||
//! twice - once as the answer to our own send, once as the echo to another of
|
||||
//! our sessions, or replayed after a reconnect - is stored once.
|
||||
//!
|
||||
//! Text is shown as Steam holds it with its formatting codes taken off (it
|
||||
//! sends both). Drawing the formatting itself is a later piece of work; until
|
||||
//! then a link keeps its text and loses its address.
|
||||
|
||||
use super::eresult;
|
||||
use super::friends::Roster;
|
||||
use super::link::{CallError, Link};
|
||||
use super::proto::*;
|
||||
use super::publish::Conversations;
|
||||
use super::steamid;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// `EChatEntryType`: an ordinary message. Typing, invitations and the other
|
||||
/// kinds share the channel and are not messages.
|
||||
pub const CHAT_MESSAGE: i32 = 1;
|
||||
|
||||
/// How long a message may be. Steam's own limit is 2,000 characters per
|
||||
/// message in its client; longer is refused by the server with an error that
|
||||
/// says nothing of why, so it is said here first.
|
||||
pub const MAX_LENGTH: usize = 2000;
|
||||
|
||||
/// What a message is called here: when Steam stamped it, and where it came
|
||||
/// among those stamped the same second.
|
||||
pub fn message_id(timestamp: u32, ordinal: u32) -> String {
|
||||
format!("{timestamp}:{ordinal}")
|
||||
}
|
||||
|
||||
/// A message read out of a notification.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Incoming {
|
||||
/// The other person in the conversation.
|
||||
pub account: u32,
|
||||
pub text: String,
|
||||
pub timestamp: u32,
|
||||
pub id: String,
|
||||
/// Sent by us, from another of our sessions.
|
||||
pub from_us: bool,
|
||||
/// The web pages Steam's client described in it, which are cards.
|
||||
pub embeds: Vec<crate::model::Embed>,
|
||||
/// An invitation to a group chat, if that is what it is.
|
||||
pub invite: Option<(u64, Option<u64>, String)>,
|
||||
}
|
||||
|
||||
/// Reads a notification, or says it is not a message to show.
|
||||
pub fn read_incoming(n: &FriendMessagesIncoming) -> Option<Incoming> {
|
||||
if n.chat_entry_type != Some(CHAT_MESSAGE) {
|
||||
return None;
|
||||
}
|
||||
let account = n.steamid_friend.and_then(steamid::account_of)?;
|
||||
// The message as Steam formatted it, read into what moho draws; the plain
|
||||
// copy only if that leaves nothing, which is a message made of tags alone.
|
||||
let rendered = super::bbcode::render_message(n.message.as_deref().unwrap_or_default());
|
||||
let embeds = rendered.cards;
|
||||
let text = Some(rendered.text)
|
||||
.filter(|t| !t.trim().is_empty())
|
||||
.or_else(|| n.message_no_bbcode.as_deref().map(|t| t.trim_end().to_string()))
|
||||
.unwrap_or_default();
|
||||
if text.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let timestamp = n.rtime32_server_timestamp.unwrap_or_default();
|
||||
let invite = super::chatgroups::invite_in(n.message.as_deref().unwrap_or_default());
|
||||
Some(Incoming { account, text, timestamp, id: message_id(timestamp, n.ordinal.unwrap_or_default()), from_us: n.local_echo.unwrap_or(false), embeds, invite })
|
||||
}
|
||||
|
||||
/// Who is typing to us, from a notification that says so.
|
||||
pub fn read_typing(n: &FriendMessagesIncoming) -> Option<u32> {
|
||||
(n.chat_entry_type == Some(TYPING) && n.local_echo != Some(true)).then_some(n.steamid_friend).flatten().and_then(steamid::account_of)
|
||||
}
|
||||
|
||||
/// Puts a message in the conversation it belongs to, starting the
|
||||
/// conversation if this is the first word from somebody.
|
||||
///
|
||||
/// Returns whether anything was stored, and whether the person is new to the
|
||||
/// roster and so unknown - the caller asks Steam who they are.
|
||||
pub fn record_incoming(state: &AppState, account_id: &str, roster: &mut Roster, shown: &mut Conversations, own_name: &str, incoming: &Incoming) -> (bool, bool) {
|
||||
let stranger = roster.note_conversation(incoming.account) || (roster.persona(incoming.account).is_none() && !roster.is_friend(incoming.account));
|
||||
shown.publish(state, roster);
|
||||
let Some(conversation) = shown.name_of(incoming.account).map(str::to_string) else { return (false, stranger) };
|
||||
let sender = if incoming.from_us {
|
||||
own_name.to_string()
|
||||
} else {
|
||||
roster.persona(incoming.account).and_then(|p| p.name.clone()).unwrap_or_else(|| conversation.clone())
|
||||
};
|
||||
let stored = state.runtime.record_message_at(
|
||||
state,
|
||||
account_id,
|
||||
&conversation,
|
||||
"dm",
|
||||
&sender,
|
||||
// Read through the account's text filter, if it has one on: as Steam's own client does.
|
||||
&(if incoming.from_us { incoming.text.clone() } else { super::account::filtered(account_id, roster.is_friend(incoming.account), &incoming.text) }),
|
||||
false,
|
||||
"chat",
|
||||
None,
|
||||
Some(incoming.id.clone()),
|
||||
false,
|
||||
roster.persona(incoming.account).filter(|_| !incoming.from_us).and_then(|p| p.avatar.clone()),
|
||||
incoming.embeds.clone(),
|
||||
Vec::new(),
|
||||
(!incoming.from_us).then(|| steamid::text_of(incoming.account)),
|
||||
Some(i64::from(incoming.timestamp)).filter(|t| *t > 0),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
// What it offers is waiting on an answer, if it came from somebody else.
|
||||
if stored && !incoming.from_us {
|
||||
if let Some(found) = incoming.invite.clone() {
|
||||
super::chatgroups::note_invite(state, account_id, &sender, found);
|
||||
}
|
||||
}
|
||||
(stored, stranger)
|
||||
}
|
||||
|
||||
/// Why a message could not be sent, in words for a person.
|
||||
pub fn refusal(error: &CallError) -> String {
|
||||
match error {
|
||||
CallError::Refused(code, _) => eresult::describe(*code),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// How long the Steam client keeps trying to send one message before it gives up on it.
|
||||
const SEND_FOR: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
/// Whether a failure to send may be gone a moment later: a server that did not answer, was busy or
|
||||
/// had a problem of its own - and not one that is about the account, the person or the room.
|
||||
fn passing(error: &CallError) -> bool {
|
||||
match error {
|
||||
CallError::TimedOut => true,
|
||||
CallError::Refused(code, _) => matches!(*code, eresult::FAIL | eresult::BUSY | eresult::TIMEOUT | eresult::SERVICE_UNAVAILABLE | 32 | 33 | 35 | 36 | 37 | 55),
|
||||
CallError::Closed | CallError::Unreadable(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a message with the Steam client's own patience: a passing failure is tried again after one
|
||||
/// second, then two, then four, for up to a minute; being refused for good is said at once, in the
|
||||
/// words the client uses - not a friend (or not allowed here) and sending too fast.
|
||||
///
|
||||
/// `unsafe_to_repeat` is for a send that has no id of its own to stop Steam taking it twice: it is
|
||||
/// repeated only when Steam said it failed, never when it merely did not answer.
|
||||
pub async fn send_with_patience<Q: prost::Message, A: prost::Message + Default>(link: &Link, method: &str, request: &Q, in_group: bool, unsafe_to_repeat: bool) -> Result<A, String> {
|
||||
let began = std::time::Instant::now();
|
||||
let mut wait = 1;
|
||||
loop {
|
||||
match link.call::<Q, A>(method, request, true).await {
|
||||
Ok(answer) => return Ok(answer),
|
||||
Err(CallError::Refused(eresult::ACCESS_DENIED, _)) => {
|
||||
return Err(if in_group { "you may not write in this group right now; you may be muted there, or not allowed".to_string() } else { eresult::describe(eresult::ACCOUNT_NOT_FRIENDS) })
|
||||
}
|
||||
Err(CallError::Refused(eresult::RATE_LIMIT_EXCEEDED, _)) => return Err("you are sending too fast; wait a moment".to_string()),
|
||||
Err(error) if passing(&error) && !(unsafe_to_repeat && matches!(error, CallError::TimedOut)) && began.elapsed() < SEND_FOR => {
|
||||
tracing::info!("steam: {method} did not go ({error}); trying again in {wait}s");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(wait)).await;
|
||||
wait *= 2;
|
||||
}
|
||||
Err(error) => return Err(refusal(&error)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a message to the person a conversation is with.
|
||||
///
|
||||
/// `recipient` is their 64-bit id as the conversation holds it. Returns the
|
||||
/// id Steam gave the message and the second it stamped it, which the caller
|
||||
/// stores under so that an echo of it is recognised.
|
||||
pub async fn send(link: &Link, recipient: &str, text: &str) -> Result<Sent, String> {
|
||||
let steamid: u64 = recipient.parse().ok().filter(|id| steamid::account_of(*id).is_some()).ok_or("this conversation is not with a Steam account")?;
|
||||
let text = text.trim_end();
|
||||
if text.trim().is_empty() {
|
||||
return Err("there is nothing to send".to_string());
|
||||
}
|
||||
if text.chars().count() > MAX_LENGTH {
|
||||
return Err(format!("Steam takes messages of up to {MAX_LENGTH} characters; this one is {}", text.chars().count()));
|
||||
}
|
||||
let request = FriendMessagesSendRequest {
|
||||
steamid: Some(steamid),
|
||||
chat_entry_type: Some(CHAT_MESSAGE),
|
||||
message: Some(text.to_string()),
|
||||
// The text is BBCode, as Steam's own client sends it: the composer
|
||||
// writes the formatting as tags and escapes any bracket somebody typed
|
||||
// as `\[`, so a "[b]" they meant literally stays what they meant.
|
||||
contains_bbcode: Some(true),
|
||||
echo_to_sender: Some(false),
|
||||
low_priority: Some(false),
|
||||
client_message_id: Some(client_message_id()),
|
||||
};
|
||||
let response: FriendMessagesSendResponse = send_with_patience(link, "FriendMessages.SendMessage#1", &request, false, false).await.inspect_err(|why| tracing::info!("steam: SendMessage was refused: {why}"))?;
|
||||
tracing::debug!("steam: sent {text:?}; Steam stored {:?} ({:?} without tags)", response.modified_message, response.message_without_bb_code);
|
||||
let timestamp = response.server_timestamp.unwrap_or_default();
|
||||
// What Steam stored, which is the text with its emoticons found: ":steamhappy:"
|
||||
// comes back as a tag, and the log should hold what the other side will see.
|
||||
let said = response.modified_message.as_deref().filter(|m| !m.trim().is_empty()).unwrap_or(text);
|
||||
let rendered = super::bbcode::render_message(said);
|
||||
Ok(Sent { id: message_id(timestamp, response.ordinal.unwrap_or_default()), timestamp, shown: rendered.text, embeds: rendered.cards })
|
||||
}
|
||||
|
||||
/// A message that Steam took.
|
||||
#[derive(Debug)]
|
||||
pub struct Sent {
|
||||
/// The id Steam gave it, which an echo of it is recognised by.
|
||||
pub id: String,
|
||||
/// The second Steam stamped it.
|
||||
pub timestamp: u32,
|
||||
/// What to put in the log: the message as moho draws it.
|
||||
pub shown: String,
|
||||
/// The cards Steam made of any links in it, which the sender's own copy
|
||||
/// holds as the other side's does.
|
||||
pub embeds: Vec<crate::model::Embed>,
|
||||
}
|
||||
|
||||
/// What a typing notice is, in `chat_entry_type`.
|
||||
pub const TYPING: i32 = 2;
|
||||
|
||||
/// Tells somebody we are writing to them. A message of the other kind with no
|
||||
/// text, which is how Steam's own client says it; sent without waiting, since
|
||||
/// nothing depends on it arriving.
|
||||
pub fn send_typing(link: &Link, recipient: &str) {
|
||||
let Some(steamid) = recipient.parse::<u64>().ok().filter(|id| steamid::account_of(*id).is_some()) else { return };
|
||||
let link = link.clone();
|
||||
tokio::spawn(async move {
|
||||
let request = FriendMessagesSendRequest { steamid: Some(steamid), chat_entry_type: Some(TYPING), ..Default::default() };
|
||||
let _: Result<FriendMessagesSendResponse, _> = link.call("FriendMessages.SendMessage#1", &request, true).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// A fresh name for one send, so that sending it twice cannot say it twice.
|
||||
fn client_message_id() -> String {
|
||||
use rand::Rng;
|
||||
// Sixteen hex digits, as Steam's own client makes them.
|
||||
let n: u64 = rand::thread_rng().gen();
|
||||
format!("{n:016x}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn typing_is_a_message_of_the_other_kind_with_no_words_and_not_a_message() {
|
||||
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let url = serve({
|
||||
let seen = seen.clone();
|
||||
Box::new(move |p| {
|
||||
seen.lock().unwrap().push(p.clone());
|
||||
Some(vec![answer(&p, 1, FriendMessagesSendResponse::default().encode_to_vec())])
|
||||
})
|
||||
})
|
||||
.await;
|
||||
let (link, _told) = link::open_with("test", &url, Duration::from_secs(5)).await.unwrap();
|
||||
send_typing(&link, "76561197960265729");
|
||||
// Nothing to send to when it is not somebody's id.
|
||||
send_typing(&link, "not an id");
|
||||
for _ in 0..100 {
|
||||
if !seen.lock().unwrap().is_empty() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
let seen = seen.lock().unwrap();
|
||||
assert_eq!(seen.len(), 1);
|
||||
assert_eq!(seen[0].header.target_job_name.as_deref(), Some("FriendMessages.SendMessage#1"));
|
||||
let body = FriendMessagesSendRequest::decode(seen[0].body.as_slice()).unwrap();
|
||||
assert_eq!((body.steamid, body.chat_entry_type, body.message), (Some(76561197960265729), Some(2), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_incoming_message_is_read_from_its_tags() {
|
||||
let said = |message: &str, plain: &str| FriendMessagesIncoming {
|
||||
steamid_friend: Some(steamid::steamid_of(7)),
|
||||
chat_entry_type: Some(1),
|
||||
message: Some(message.into()),
|
||||
message_no_bbcode: Some(plain.into()),
|
||||
rtime32_server_timestamp: Some(5),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(read_incoming(&said("look [b]here[/b] [emoticon]steamhappy[/emoticon]", "look here :steamhappy:")).unwrap().text, "look **here** [steam:emote:steamhappy]");
|
||||
// Nothing but tags: the plain copy is what there is to show.
|
||||
assert_eq!(read_incoming(&said("[roomeffect]x[/roomeffect]", "sends an effect")).unwrap().text, "sends an effect");
|
||||
assert_eq!(read_incoming(&said(" ", " ")), None, "and nothing at all is not a message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn somebody_typing_is_told_from_a_message_and_from_ourselves_elsewhere() {
|
||||
let from = |kind, echo| FriendMessagesIncoming { steamid_friend: Some(steamid::steamid_of(7)), chat_entry_type: Some(kind), local_echo: Some(echo), ..Default::default() };
|
||||
assert_eq!(read_typing(&from(2, false)), Some(7));
|
||||
assert_eq!(read_typing(&from(1, false)), None, "a message is not typing");
|
||||
assert_eq!(read_typing(&from(2, true)), None, "our own typing, from another client, is not somebody else's");
|
||||
assert_eq!(read_typing(&FriendMessagesIncoming { chat_entry_type: Some(2), ..Default::default() }), None, "from nobody");
|
||||
}
|
||||
|
||||
/// Steam refuses a message whose id is the wrong shape - as "not an
|
||||
/// account", which says nothing of the id - and the shape that is accepted
|
||||
/// is what its own client makes: sixteen hex digits. Found against a live
|
||||
/// account, where thirty-two were refused and sixteen sent.
|
||||
#[test]
|
||||
fn a_message_id_is_sixteen_hex_digits_and_not_the_same_twice() {
|
||||
let a = client_message_id();
|
||||
let b = client_message_id();
|
||||
assert_eq!(a.len(), 16);
|
||||
assert!(a.bytes().all(|c| c.is_ascii_hexdigit()));
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
use crate::backend::kick::testkit::simulated_daemon;
|
||||
use crate::backend::steam::friends::REL_FRIEND;
|
||||
use crate::backend::steam::link::{self, fake::*};
|
||||
use crate::backend::steam::wire::emsg;
|
||||
use std::time::Duration;
|
||||
use prost::Message;
|
||||
|
||||
const ACCOUNT: &str = "steam:tester";
|
||||
|
||||
fn notice(friend: u32, text: &str, ts: u32, ordinal: u32) -> FriendMessagesIncoming {
|
||||
FriendMessagesIncoming {
|
||||
steamid_friend: Some(steamid::steamid_of(friend)),
|
||||
chat_entry_type: Some(1),
|
||||
message: Some(text.to_string()),
|
||||
message_no_bbcode: Some(text.to_string()),
|
||||
rtime32_server_timestamp: Some(ts),
|
||||
ordinal: Some(ordinal),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_message_is_named_by_when_steam_stamped_it_and_its_place_in_that_second() {
|
||||
assert_eq!(message_id(1790000000, 0), "1790000000:0");
|
||||
assert_ne!(message_id(1790000000, 0), message_id(1790000000, 1), "two in the same second are two messages");
|
||||
let read = read_incoming(¬ice(22202, "hello", 1790000000, 3)).unwrap();
|
||||
assert_eq!((read.account, read.text.as_str(), read.id.as_str(), read.from_us), (22202, "hello", "1790000000:3", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_is_not_a_message_to_show_is_not_one() {
|
||||
let mut typing = notice(22202, "x", 1, 0);
|
||||
typing.chat_entry_type = Some(2);
|
||||
assert_eq!(read_incoming(&typing), None, "typing is not a message");
|
||||
let mut other = notice(22202, "x", 1, 0);
|
||||
other.chat_entry_type = None;
|
||||
assert_eq!(read_incoming(&other), None);
|
||||
let mut empty = notice(22202, " ", 1, 0);
|
||||
empty.message = Some(" ".into());
|
||||
assert_eq!(read_incoming(&empty), None);
|
||||
let mut group = notice(22202, "x", 1, 0);
|
||||
group.steamid_friend = Some((1u64 << 56) | (7 << 52) | (1 << 32) | 5);
|
||||
assert_eq!(read_incoming(&group), None, "not from a person");
|
||||
let mut nobody = notice(22202, "x", 1, 0);
|
||||
nobody.steamid_friend = None;
|
||||
assert_eq!(read_incoming(&nobody), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_text_is_the_message_and_trailing_space_is_dropped() {
|
||||
assert_eq!(read_incoming(¬ice(1, "plain words", 1, 0)).unwrap().text, "plain words");
|
||||
let mut raw_only = notice(1, "", 1, 0);
|
||||
raw_only.message_no_bbcode = None;
|
||||
raw_only.message = Some("raw words ".into());
|
||||
assert_eq!(read_incoming(&raw_only).unwrap().text, "raw words");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_message_we_sent_from_another_session_is_ours() {
|
||||
let mut echo = notice(22202, "sent from my phone", 5, 0);
|
||||
echo.local_echo = Some(true);
|
||||
assert!(read_incoming(&echo).unwrap().from_us);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_steam_described_arrives_as_a_message_with_a_card() {
|
||||
let state = simulated_daemon("steam-chat-card");
|
||||
let mut roster = Roster::default();
|
||||
roster.apply_friends_list(&ClientFriendsList {
|
||||
friends: vec![FriendsListEntry { ulfriendid: Some(steamid::steamid_of(22202)), efriendrelationship: Some(REL_FRIEND) }],
|
||||
..Default::default()
|
||||
});
|
||||
roster.apply_persona(&ClientPersonaState { friends: vec![PersonaFriend { friendid: Some(steamid::steamid_of(22202)), player_name: Some("Gabe".into()), persona_state: Some(1), ..Default::default() }], ..Default::default() });
|
||||
let mut shown = Conversations::new(ACCOUNT);
|
||||
let raw = "[og url=\"http://x.salastil.com\" title=\"Home\" desc=\"A privacy-focused Twitter/X frontend\"]x.salastil.com[/og]";
|
||||
let incoming = read_incoming(¬ice(22202, raw, 1790000100, 0)).unwrap();
|
||||
assert_eq!(incoming.text, "[x.salastil.com](http://x.salastil.com)");
|
||||
assert!(record_incoming(&state, ACCOUNT, &mut roster, &mut shown, "Me", &incoming).0);
|
||||
let history = state.store.get_backlog(&crate::model::buffer_id(ACCOUNT, "Gabe"), 0, 10).unwrap();
|
||||
let card = &history[0].embeds[0];
|
||||
assert_eq!((card.url.as_deref(), card.title.as_deref(), card.provider.as_deref()), (Some("http://x.salastil.com"), Some("Home"), Some("x.salastil.com")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_friends_invitation_to_a_group_waits_to_be_answered() {
|
||||
let state = simulated_daemon("steam-chat-invite");
|
||||
let account = "steam:invitee";
|
||||
let mut roster = Roster::default();
|
||||
roster.apply_friends_list(&ClientFriendsList {
|
||||
friends: vec![FriendsListEntry { ulfriendid: Some(steamid::steamid_of(22202)), efriendrelationship: Some(REL_FRIEND) }],
|
||||
..Default::default()
|
||||
});
|
||||
roster.apply_persona(&ClientPersonaState { friends: vec![PersonaFriend { friendid: Some(steamid::steamid_of(22202)), player_name: Some("Gabe".into()), persona_state: Some(1), ..Default::default() }], ..Default::default() });
|
||||
let mut shown = Conversations::new(account);
|
||||
let raw = "[invite chatgroupid=\"78995476\" chatgroupname=\"Raiders\"][/invite]";
|
||||
let incoming = read_incoming(¬ice(22202, raw, 1790000200, 0)).unwrap();
|
||||
assert_eq!(incoming.text, "Group chat invitation: Raiders");
|
||||
assert_eq!(incoming.invite, Some((78995476, None, "Raiders".to_string())));
|
||||
assert!(record_incoming(&state, account, &mut roster, &mut shown, "Me", &incoming).0);
|
||||
let waiting = crate::backend::steam::chatgroups::invites_json(account);
|
||||
assert_eq!(waiting.len(), 1);
|
||||
assert_eq!((waiting[0]["roomId"].as_str(), waiting[0]["name"].as_str(), waiting[0]["inviter"].as_str()), (Some("78995476"), Some("Raiders"), Some("Gabe")));
|
||||
// The same one again is still one; what we sent ourselves is nobody's to answer.
|
||||
let again = read_incoming(¬ice(22202, raw, 1790000300, 0)).unwrap();
|
||||
record_incoming(&state, account, &mut roster, &mut shown, "Me", &again);
|
||||
assert_eq!(crate::backend::steam::chatgroups::invites_json(account).len(), 1);
|
||||
crate::backend::steam::chatgroups::clear(account);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_message_lands_in_the_friends_conversation_once() {
|
||||
let state = simulated_daemon("steam-chat-record");
|
||||
let mut roster = Roster::default();
|
||||
roster.apply_friends_list(&ClientFriendsList {
|
||||
friends: vec![FriendsListEntry { ulfriendid: Some(steamid::steamid_of(22202)), efriendrelationship: Some(REL_FRIEND) }],
|
||||
..Default::default()
|
||||
});
|
||||
roster.apply_persona(&ClientPersonaState { friends: vec![PersonaFriend { friendid: Some(steamid::steamid_of(22202)), player_name: Some("Gabe".into()), persona_state: Some(1), ..Default::default() }], ..Default::default() });
|
||||
let mut shown = Conversations::new(ACCOUNT);
|
||||
|
||||
let incoming = read_incoming(¬ice(22202, "hello there", 1790000000, 0)).unwrap();
|
||||
let (stored, stranger) = record_incoming(&state, ACCOUNT, &mut roster, &mut shown, "Me", &incoming);
|
||||
assert!(stored && !stranger);
|
||||
let (again, _) = record_incoming(&state, ACCOUNT, &mut roster, &mut shown, "Me", &incoming);
|
||||
assert!(!again, "the same message twice is one message");
|
||||
|
||||
let history = state.store.get_backlog(&crate::model::buffer_id(ACCOUNT, "Gabe"), 0, 10).unwrap();
|
||||
assert_eq!(history.len(), 1);
|
||||
assert_eq!((history[0].from.as_str(), history[0].body.as_str(), history[0].ts), ("Gabe", "hello there", 1790000000));
|
||||
assert_eq!(history[0].id, "1790000000:0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_we_sent_from_elsewhere_is_filed_under_the_friend_and_signed_by_us() {
|
||||
let state = simulated_daemon("steam-chat-echo");
|
||||
let mut roster = Roster::default();
|
||||
roster.apply_friends_list(&ClientFriendsList {
|
||||
friends: vec![FriendsListEntry { ulfriendid: Some(steamid::steamid_of(22202)), efriendrelationship: Some(REL_FRIEND) }],
|
||||
..Default::default()
|
||||
});
|
||||
roster.apply_persona(&ClientPersonaState { friends: vec![PersonaFriend { friendid: Some(steamid::steamid_of(22202)), player_name: Some("Gabe".into()), persona_state: Some(1), ..Default::default() }], ..Default::default() });
|
||||
let mut shown = Conversations::new(ACCOUNT);
|
||||
state.runtime.set_own_identity(ACCOUNT, "Me");
|
||||
|
||||
let mut echo = notice(22202, "from my phone", 1790000001, 0);
|
||||
echo.local_echo = Some(true);
|
||||
record_incoming(&state, ACCOUNT, &mut roster, &mut shown, "Me", &read_incoming(&echo).unwrap());
|
||||
let history = state.store.get_backlog(&crate::model::buffer_id(ACCOUNT, "Gabe"), 0, 10).unwrap();
|
||||
assert_eq!(history.len(), 1, "in Gabe's conversation, not a new one");
|
||||
assert_eq!(history[0].from, "Me");
|
||||
assert!(history[0].is_own);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn somebody_who_writes_first_gets_a_conversation_and_is_asked_after() {
|
||||
let state = simulated_daemon("steam-chat-stranger");
|
||||
let mut roster = Roster::default();
|
||||
let mut shown = Conversations::new(ACCOUNT);
|
||||
let incoming = read_incoming(¬ice(777, "hi, we have not met", 1790000002, 0)).unwrap();
|
||||
let (stored, stranger) = record_incoming(&state, ACCOUNT, &mut roster, &mut shown, "Me", &incoming);
|
||||
assert!(stored, "a message from a stranger is still a message");
|
||||
assert!(stranger, "and the caller should find out who they are");
|
||||
let name = shown.name_of(777).unwrap().to_string();
|
||||
assert_eq!(name, steamid::text_of(777));
|
||||
assert_eq!(state.store.get_backlog(&crate::model::buffer_id(ACCOUNT, &name), 0, 10).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sending_names_the_recipient_sends_the_tags_and_logs_what_steam_stored() {
|
||||
let seen = std::sync::Arc::new(std::sync::Mutex::new(None));
|
||||
let url = serve({
|
||||
let seen = seen.clone();
|
||||
Box::new(move |req| {
|
||||
*seen.lock().unwrap() = Some(req.clone());
|
||||
Some(vec![answer(&req, 1, FriendMessagesSendResponse { modified_message: Some("hi [b]there[/b] [emoticon]steamhappy[/emoticon]".into()), server_timestamp: Some(1790000003), ordinal: Some(2), message_without_bb_code: Some("hi there".into()) }.encode_to_vec())])
|
||||
})
|
||||
})
|
||||
.await;
|
||||
let (link, _told) = link::open("steam:test", &url).await.unwrap();
|
||||
link.set_identity(link::Identity { steamid: 76561197960287930, session: 4 });
|
||||
|
||||
let sent = send(&link, "76561197960265729", "hi [b]there[/b] :steamhappy: \n").await.unwrap();
|
||||
assert_eq!((sent.id.as_str(), sent.timestamp), ("1790000003:2", 1790000003));
|
||||
assert_eq!(sent.shown, "hi **there** [steam:emote:steamhappy]", "what is logged is what Steam stored, drawn as moho draws it");
|
||||
let req = seen.lock().unwrap().clone().unwrap();
|
||||
assert_eq!(req.emsg, emsg::SERVICE_METHOD_CALL_FROM_CLIENT, "as the signed-in account");
|
||||
assert_eq!(req.header.target_job_name.as_deref(), Some("FriendMessages.SendMessage#1"));
|
||||
let body = FriendMessagesSendRequest::decode(req.body.as_slice()).unwrap();
|
||||
assert_eq!(body.steamid, Some(76561197960265729));
|
||||
assert_eq!(body.message.as_deref(), Some("hi [b]there[/b] :steamhappy:"), "trailing space is dropped; the tags are sent as they came");
|
||||
assert_eq!((body.chat_entry_type, body.contains_bbcode), (Some(1), Some(true)));
|
||||
assert_eq!(body.client_message_id.as_deref().map(str::len), Some(16));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_link_sent_keeps_the_card_steam_made_of_it() {
|
||||
let url = serve(Box::new(|req| {
|
||||
let stored = "[og url=\"https://x.test/p\" img=\"https://community.steamstatic.com/chat/image/a/share_image\" title=\"A page\" desc=\"About it\"]https://x.test/p[/og]";
|
||||
Some(vec![answer(&req, 1, FriendMessagesSendResponse { modified_message: Some(stored.into()), server_timestamp: Some(1790000004), ordinal: Some(0), message_without_bb_code: None }.encode_to_vec())])
|
||||
}))
|
||||
.await;
|
||||
let (link, _told) = link::open("steam:test", &url).await.unwrap();
|
||||
link.set_identity(link::Identity { steamid: 76561197960287930, session: 4 });
|
||||
let sent = send(&link, "76561197960265729", "https://x.test/p").await.unwrap();
|
||||
assert_eq!(sent.shown, "https://x.test/p", "the words are what was sent");
|
||||
assert_eq!(sent.embeds.len(), 1, "and the card is the sender's too");
|
||||
assert_eq!((sent.embeds[0].title.as_deref(), sent.embeds[0].description.as_deref()), (Some("A page"), Some("About it")));
|
||||
assert!(sent.embeds[0].image_url.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn what_cannot_be_sent_is_said_before_it_is_tried() {
|
||||
let heard = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let url = serve({
|
||||
let heard = heard.clone();
|
||||
Box::new(move |req| {
|
||||
heard.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
Some(vec![answer(&req, 1, FriendMessagesSendResponse::default().encode_to_vec())])
|
||||
})
|
||||
})
|
||||
.await;
|
||||
let (link, _told) = link::open("steam:test", &url).await.unwrap();
|
||||
for (to, text, why) in [
|
||||
("not a number", "hi", "not with a Steam account"),
|
||||
("5", "hi", "not with a Steam account"),
|
||||
("76561197960265729", " ", "nothing to send"),
|
||||
] {
|
||||
let err = send(&link, to, text).await.unwrap_err();
|
||||
assert!(err.contains(why), "{to:?} {text:?}: {err}");
|
||||
}
|
||||
let err = send(&link, "76561197960265729", &"x".repeat(2001)).await.unwrap_err();
|
||||
assert!(err.contains("2000"), "{err}");
|
||||
assert_eq!(heard.load(std::sync::atomic::Ordering::SeqCst), 0, "none of those reached Steam");
|
||||
|
||||
// Exactly the limit is fine.
|
||||
send(&link, "76561197960265729", &"x".repeat(2000)).await.unwrap();
|
||||
assert_eq!(heard.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_send_that_fails_in_passing_is_tried_again_with_the_same_id_and_one_that_is_refused_is_not() {
|
||||
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let url = serve({
|
||||
let seen = seen.clone();
|
||||
Box::new(move |req| {
|
||||
let mut seen = seen.lock().unwrap();
|
||||
seen.push(FriendMessagesSendRequest::decode(req.body.as_slice()).unwrap().client_message_id);
|
||||
// Fails the first time, as a busy server does, then goes through.
|
||||
let code = if seen.len() == 1 { eresult::SERVICE_UNAVAILABLE } else { 1 };
|
||||
Some(vec![answer(&req, code, FriendMessagesSendResponse { server_timestamp: Some(5), ordinal: Some(0), ..Default::default() }.encode_to_vec())])
|
||||
})
|
||||
})
|
||||
.await;
|
||||
let (link, _told) = link::open("steam:test", &url).await.unwrap();
|
||||
let sent = send(&link, "76561197960265729", "hi").await.unwrap();
|
||||
assert_eq!(sent.timestamp, 5);
|
||||
let seen = seen.lock().unwrap();
|
||||
assert_eq!(seen.len(), 2, "tried again after the first failure");
|
||||
assert_eq!(seen[0], seen[1], "under the same id, so Steam cannot take it twice");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn being_refused_for_good_is_said_at_once_in_the_clients_words() {
|
||||
for (code, words, in_group) in [(eresult::ACCESS_DENIED, "friends", false), (eresult::ACCESS_DENIED, "may not write in this group", true), (eresult::RATE_LIMIT_EXCEEDED, "too fast", false), (eresult::LIMITED_USER_ACCOUNT, "limited account", false)] {
|
||||
let count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let url = serve({
|
||||
let count = count.clone();
|
||||
Box::new(move |req| {
|
||||
count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
Some(vec![answer(&req, code, Vec::new())])
|
||||
})
|
||||
})
|
||||
.await;
|
||||
let (link, _told) = link::open("steam:test", &url).await.unwrap();
|
||||
let err = send_with_patience::<FriendMessagesSendRequest, FriendMessagesSendResponse>(&link, "FriendMessages.SendMessage#1", &FriendMessagesSendRequest::default(), in_group, false).await.unwrap_err();
|
||||
assert!(err.contains(words), "{code}: {err}");
|
||||
assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 1, "{code} is not tried again");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn steams_refusals_are_said_in_words() {
|
||||
let url = serve(Box::new(|req| Some(vec![answer(&req, eresult::ACCOUNT_NOT_FRIENDS, Vec::new())]))).await;
|
||||
let (link, _told) = link::open("steam:test", &url).await.unwrap();
|
||||
let err = send(&link, "76561197960265729", "hello").await.unwrap_err();
|
||||
assert!(err.contains("friends"), "{err}");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,153 @@
|
||||
//! Finding a connection manager to talk to.
|
||||
//!
|
||||
//! Steam has no fixed address for its servers. A client asks a public
|
||||
//! directory for the current ones and connects to one: each answer names an
|
||||
//! endpoint, the datacentre it is in, and how loaded it is. The directory is
|
||||
//! plain HTTPS and needs no account, so it is asked through the same route the
|
||||
//! account will use - an account sent through Tor does not tell Valve its real
|
||||
//! address on the way to choosing a server.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use rand::seq::SliceRandom;
|
||||
use serde::Deserialize;
|
||||
use std::time::Duration;
|
||||
|
||||
const DIRECTORY: &str = "https://api.steampowered.com/ISteamDirectory/GetCMListForConnect/v1/";
|
||||
|
||||
/// One connection manager, as the directory describes it.
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq)]
|
||||
pub struct Cm {
|
||||
/// `host:port`, the address of its WebSocket.
|
||||
pub endpoint: String,
|
||||
/// The datacentre it is in.
|
||||
#[serde(default)]
|
||||
pub dc: String,
|
||||
/// How the directory describes its kind: `websockets` for these.
|
||||
#[serde(default, rename = "type")]
|
||||
pub kind: String,
|
||||
/// Connections, roughly, as a percentage of what it takes.
|
||||
#[serde(default)]
|
||||
pub load: f64,
|
||||
/// The same weighted by the datacentre's other servers; what to rank by.
|
||||
#[serde(default)]
|
||||
pub wtd_load: f64,
|
||||
}
|
||||
|
||||
impl Cm {
|
||||
/// Where its WebSocket is.
|
||||
pub fn url(&self) -> String {
|
||||
format!("wss://{}/cmsocket/", self.endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Directory {
|
||||
response: Inner,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Inner {
|
||||
#[serde(default)]
|
||||
serverlist: Vec<Cm>,
|
||||
#[serde(default)]
|
||||
success: Option<bool>,
|
||||
#[serde(default)]
|
||||
message: String,
|
||||
}
|
||||
|
||||
/// Reads the directory's answer.
|
||||
pub fn parse(json: &str) -> Result<Vec<Cm>> {
|
||||
let answer: Directory = serde_json::from_str(json).context("reading Steam's server list")?;
|
||||
if answer.response.success == Some(false) {
|
||||
bail!("Steam's server list said no{}", if answer.response.message.is_empty() { String::new() } else { format!(": {}", answer.response.message) });
|
||||
}
|
||||
let servers: Vec<Cm> = answer.response.serverlist.into_iter().filter(|cm| cm.kind.is_empty() || cm.kind == "websockets").collect();
|
||||
if servers.is_empty() {
|
||||
bail!("Steam's server list had no servers in it");
|
||||
}
|
||||
Ok(servers)
|
||||
}
|
||||
|
||||
/// The order to try them in: the least loaded first.
|
||||
///
|
||||
/// The best few are shuffled among themselves. Everybody who ranks by load
|
||||
/// would otherwise pick the very same server, which is how the quietest one
|
||||
/// stops being quiet, and a client that retries after a failure would walk
|
||||
/// the same list from the same start every time.
|
||||
pub fn ranked(mut servers: Vec<Cm>) -> Vec<Cm> {
|
||||
servers.sort_by(|a, b| a.wtd_load.partial_cmp(&b.wtd_load).unwrap_or(std::cmp::Ordering::Equal).then(a.load.partial_cmp(&b.load).unwrap_or(std::cmp::Ordering::Equal)));
|
||||
let head = servers.len().min(4);
|
||||
servers[..head].shuffle(&mut rand::thread_rng());
|
||||
servers
|
||||
}
|
||||
|
||||
/// Asks the directory, as `key` is routed.
|
||||
pub async fn discover(key: &str, cell: u32) -> Result<Vec<Cm>> {
|
||||
let router = crate::net::route::router();
|
||||
let http = router.client_if("steam", router.routed(key), |b| b.timeout(Duration::from_secs(20)).user_agent(concat!("moho/", env!("CARGO_PKG_VERSION"))));
|
||||
let body = http
|
||||
.get(DIRECTORY)
|
||||
.query(&[("cellid", cell.to_string()), ("cmtype", "websockets".to_string()), ("realm", "steamglobal".to_string()), ("maxcount", "20".to_string())])
|
||||
.send()
|
||||
.await
|
||||
.context("asking Steam for a server")?
|
||||
.error_for_status()
|
||||
.context("Steam's server list was refused")?
|
||||
.text()
|
||||
.await
|
||||
.context("reading Steam's server list")?;
|
||||
Ok(ranked(parse(&body)?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// As the directory answered when this was written.
|
||||
const REAL: &str = r#"{"response":{"serverlist":[
|
||||
{"endpoint":"cmp1-iad1.steamserver.net:443","legacy_endpoint":"cmp1-iad1.steamserver.net:443","type":"websockets","dc":"iad1","realm":"steamglobal","load":9,"wtd_load":0},
|
||||
{"endpoint":"cmp2-iad1.steamserver.net:443","legacy_endpoint":"cmp2-iad1.steamserver.net:443","type":"websockets","dc":"iad1","realm":"steamglobal","load":9,"wtd_load":3.76304769515991211}
|
||||
],"success":true,"message":""}}"#;
|
||||
|
||||
#[test]
|
||||
fn the_directorys_answer_is_read() {
|
||||
let servers = parse(REAL).unwrap();
|
||||
assert_eq!(servers.len(), 2);
|
||||
assert_eq!(servers[0].endpoint, "cmp1-iad1.steamserver.net:443");
|
||||
assert_eq!(servers[0].dc, "iad1");
|
||||
assert_eq!(servers[1].wtd_load, 3.763_047_695_159_912);
|
||||
assert_eq!(servers[0].url(), "wss://cmp1-iad1.steamserver.net:443/cmsocket/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refusal_or_an_empty_list_is_an_error_that_says_so() {
|
||||
let refused = parse(r#"{"response":{"serverlist":[],"success":false,"message":"rate limited"}}"#).unwrap_err();
|
||||
assert!(format!("{refused:#}").contains("rate limited"));
|
||||
assert!(parse(r#"{"response":{"serverlist":[],"success":true}}"#).is_err());
|
||||
assert!(parse("<html>not json</html>").is_err());
|
||||
// Only the kind this client speaks counts.
|
||||
let other = r#"{"response":{"serverlist":[{"endpoint":"1.2.3.4:27017","type":"netfilter"}],"success":true}}"#;
|
||||
assert!(parse(other).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_least_loaded_come_first_and_all_of_them_are_kept() {
|
||||
let cm = |name: &str, wtd: f64| Cm { endpoint: name.into(), dc: String::new(), kind: "websockets".into(), load: 0.0, wtd_load: wtd };
|
||||
let list: Vec<Cm> = (0..10).map(|i| cm(&format!("s{i}"), (10 - i) as f64)).collect();
|
||||
let order = ranked(list);
|
||||
assert_eq!(order.len(), 10);
|
||||
// The four best, in some order, then the rest strictly by load.
|
||||
let mut best: Vec<&str> = order[..4].iter().map(|c| c.endpoint.as_str()).collect();
|
||||
best.sort();
|
||||
assert_eq!(best, ["s6", "s7", "s8", "s9"]);
|
||||
let rest: Vec<&str> = order[4..].iter().map(|c| c.endpoint.as_str()).collect();
|
||||
assert_eq!(rest, ["s5", "s4", "s3", "s2", "s1", "s0"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn who_goes_first_among_the_best_is_not_always_the_same() {
|
||||
let cm = |name: &str| Cm { endpoint: name.into(), dc: String::new(), kind: "websockets".into(), load: 0.0, wtd_load: 0.0 };
|
||||
let firsts: std::collections::HashSet<String> = (0..200).map(|_| ranked((0..4).map(|i| cm(&format!("s{i}"))).collect())[0].endpoint.clone()).collect();
|
||||
assert!(firsts.len() > 1, "always {firsts:?}");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,284 @@
|
||||
//! What an account may put in a message beyond words: its emoticons, stickers
|
||||
//! and effects, which are a short list Steam holds for each account.
|
||||
//!
|
||||
//! Steam names an emoticon by a word between colons, and draws it from a
|
||||
//! picture at an address built from the name; a sticker likewise, and is sent as
|
||||
//! a command (`/sticker name`) that Steam turns into the picture. Which an
|
||||
//! account may use is what Steam says it has - the free ones every account gets,
|
||||
//! and what has been earned or bought - so it is asked for rather than listed
|
||||
//! here, and it is all the picker offers: there is no other set to choose from.
|
||||
//!
|
||||
//! In a message an emoticon is only its name; in the window it is a picture
|
||||
//! whose address is built from that name, so the same name works for an
|
||||
//! emoticon the account does not have when somebody else sent it.
|
||||
|
||||
use super::link::Link;
|
||||
use super::proto::{ClientEmoticonList, ClientGetEmoticonList, GetEmoticonListRequest, GetEmoticonListResponse};
|
||||
use super::wire::emsg;
|
||||
use crate::model::{EmojiEntry, EmojiSource};
|
||||
use crate::state::AppState;
|
||||
|
||||
/// The id of the source that holds the stickers. Kept in the same catalogue as the
|
||||
/// emoticons but never offered as a section of emoji: they are sent, not typed.
|
||||
pub const STICKERS: &str = "steam-stickers";
|
||||
|
||||
/// The picture for an emoticon, which is also what the window draws.
|
||||
pub fn picture(name: &str) -> String {
|
||||
format!("https://community.fastly.steamstatic.com/economy/emoticon/{name}")
|
||||
}
|
||||
|
||||
/// The picture for a sticker. Its name has spaces in it, so it is written the way
|
||||
/// an address takes them.
|
||||
pub fn sticker_picture(name: &str) -> String {
|
||||
let encoded: String = name.bytes().map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }).collect();
|
||||
format!("https://community.fastly.steamstatic.com/economy/sticker/{encoded}")
|
||||
}
|
||||
|
||||
/// A sticker's name as Steam holds it: words, with spaces and a little punctuation
|
||||
/// in them ("Nyan GLaDOS"), unlike an emoticon's, which is one word. Nothing that
|
||||
/// could end a tag, a quote or a command early.
|
||||
pub fn valid_sticker(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.chars().count() <= 64
|
||||
&& name == name.trim()
|
||||
&& name.chars().all(|c| c.is_alphanumeric() || " _-'.!&()+,".contains(c))
|
||||
}
|
||||
|
||||
/// Whether the account has this sticker: what it is asked to send must be one it
|
||||
/// holds, since a command naming any other would be sent as the words it is.
|
||||
pub fn is_owned_sticker(state: &AppState, account_id: &str, name: &str) -> bool {
|
||||
state.runtime.emoji_catalogue(account_id).iter().filter(|s| s.id == STICKERS).any(|s| s.emoji.iter().any(|e| e.name == name))
|
||||
}
|
||||
|
||||
fn valid(name: &str) -> bool {
|
||||
!name.is_empty() && name.len() <= 64 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
}
|
||||
|
||||
/// A name list in the order the picker offers it: most used first, then by
|
||||
/// name, each once, and only what is a name.
|
||||
fn ordered(mut have: Vec<(String, u32)>, valid: fn(&str) -> bool) -> Vec<String> {
|
||||
have.retain(|(n, _)| valid(n));
|
||||
have.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.to_lowercase().cmp(&b.0.to_lowercase())));
|
||||
have.dedup_by(|a, b| a.0 == b.0);
|
||||
have.into_iter().map(|(n, _)| n).collect()
|
||||
}
|
||||
|
||||
/// The emoticons the picker offers.
|
||||
fn emoticon_entries(list: &ClientEmoticonList) -> Vec<EmojiEntry> {
|
||||
let have = list.emoticons.iter().filter_map(|e| Some((e.name.as_deref()?.trim_matches(':').to_string(), e.use_count.unwrap_or(0)))).collect();
|
||||
// The id says which service's picture it is, so the window builds the
|
||||
// right token for it; the name is what is typed between colons.
|
||||
ordered(have, valid).into_iter().map(|name| EmojiEntry { id: format!("steam:{name}"), url: Some(picture(&name)), name, animated: false, locked: false }).collect()
|
||||
}
|
||||
|
||||
/// The stickers the picker offers.
|
||||
fn sticker_entries(list: &ClientEmoticonList) -> Vec<EmojiEntry> {
|
||||
let have = list.stickers.iter().filter_map(|e| Some((e.name.as_deref()?.to_string(), e.use_count.unwrap_or(0)))).collect();
|
||||
ordered(have, valid_sticker).into_iter().map(|name| EmojiEntry { id: name.clone(), url: Some(sticker_picture(&name)), name, animated: false, locked: false }).collect()
|
||||
}
|
||||
|
||||
fn source(id: &str, name: &str, emoji: Vec<EmojiEntry>) -> EmojiSource {
|
||||
EmojiSource { id: id.to_string(), name: name.to_string(), service: "steam".to_string(), kind: "text".to_string(), icon_url: None, buffers: Vec::new(), sendable_anywhere: true, emoji }
|
||||
}
|
||||
|
||||
/// Who is waiting to hear the list again, and when it was last asked for.
|
||||
static REFRESHES: std::sync::LazyLock<std::sync::Mutex<std::collections::HashMap<String, Heard>>> = std::sync::LazyLock::new(Default::default);
|
||||
|
||||
/// What is held about one account's list: who waits for it, and when it came.
|
||||
type Heard = (std::sync::Arc<tokio::sync::Notify>, Option<std::time::Instant>);
|
||||
|
||||
/// How long a list is taken to be current without asking again.
|
||||
const FRESH_FOR: std::time::Duration = std::time::Duration::from_secs(20);
|
||||
|
||||
/// How long to wait for Steam's answer before offering what is held.
|
||||
const WAIT_FOR: std::time::Duration = std::time::Duration::from_millis(2500);
|
||||
|
||||
/// Asks Steam again what the account has, and waits for the answer.
|
||||
///
|
||||
/// The list is asked for once at sign-in and changes when something is earned or
|
||||
/// bought - a sticker added in the Steam client while moho is open is not in
|
||||
/// what was heard. So the picker asks as it opens. A list heard in the last few
|
||||
/// seconds is not asked for again, and a Steam that does not answer costs only
|
||||
/// the wait, after which what is held is offered as it was.
|
||||
pub async fn refresh(state: &AppState, account_id: &str) {
|
||||
let Some(link) = state.runtime.steam_link(account_id) else { return };
|
||||
let notify = {
|
||||
let mut held = REFRESHES.lock().unwrap();
|
||||
let entry = held.entry(account_id.to_string()).or_insert_with(|| (Default::default(), None));
|
||||
if entry.1.is_some_and(|at| at.elapsed() < FRESH_FOR) {
|
||||
return;
|
||||
}
|
||||
entry.0.clone()
|
||||
};
|
||||
let heard = notify.notified();
|
||||
tokio::pin!(heard);
|
||||
heard.as_mut().enable();
|
||||
if link.send(emsg::CLIENT_GET_EMOTICON_LIST, &ClientGetEmoticonList {}).is_err() {
|
||||
return;
|
||||
}
|
||||
let _ = tokio::time::timeout(WAIT_FOR, heard).await;
|
||||
}
|
||||
|
||||
/// Keeps what Steam says the account has, as what the picker offers.
|
||||
pub fn keep(state: &AppState, account_id: &str, list: &ClientEmoticonList) {
|
||||
let (emoticons, stickers) = (emoticon_entries(list), sticker_entries(list));
|
||||
tracing::debug!("steam[{account_id}]: {} emoticon(s), {} sticker(s), {} effect(s)", emoticons.len(), stickers.len(), list.effects.len());
|
||||
state.runtime.set_emoji_source(account_id, source("steam", "Steam", emoticons));
|
||||
state.runtime.set_emoji_source(account_id, source(STICKERS, "Steam stickers", stickers));
|
||||
let mut held = REFRESHES.lock().unwrap();
|
||||
let entry = held.entry(account_id.to_string()).or_insert_with(|| (Default::default(), None));
|
||||
entry.1 = Some(std::time::Instant::now());
|
||||
entry.0.notify_waiters();
|
||||
}
|
||||
|
||||
/// Asks Steam for what the account has. The full list - stickers and effects as
|
||||
/// well - is asked for and arrives as a message of its own, which the
|
||||
/// connection hands to `keep`; the emoticons alone are asked for in the
|
||||
/// ordinary way at the same time, so there is a picker while that is awaited
|
||||
/// and one if it never comes. Quietly nothing on failure: no picker section is
|
||||
/// better than an error at sign-in.
|
||||
pub async fn load(state: AppState, link: Link, account_id: String) {
|
||||
let _ = link.send(emsg::CLIENT_GET_EMOTICON_LIST, &ClientGetEmoticonList {});
|
||||
match link.call::<_, GetEmoticonListResponse>("Player.GetEmoticonList#1", &GetEmoticonListRequest {}, true).await {
|
||||
Ok(l) => {
|
||||
// The full list may have come first; what it says is not replaced
|
||||
// by this, which knows less.
|
||||
if state.runtime.emoji_catalogue(&account_id).iter().all(|s| s.id != STICKERS) {
|
||||
let list = ClientEmoticonList {
|
||||
emoticons: l.emoticons.into_iter().map(|e| super::proto::EmoticonOwned { name: e.name, count: e.count, time_last_used: e.time_last_used, use_count: e.use_count, time_received: e.time_received, appid: e.appid }).collect(),
|
||||
..Default::default()
|
||||
};
|
||||
keep(&state, &account_id, &list);
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::info!("steam[{account_id}]: could not ask for emoticons: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::backend::steam::proto::{EmoticonOwned, StickerOwned};
|
||||
|
||||
fn have(name: &str, used: u32) -> EmoticonOwned {
|
||||
EmoticonOwned { name: Some(name.into()), use_count: Some(used), ..Default::default() }
|
||||
}
|
||||
|
||||
fn sticker(name: &str, used: u32) -> StickerOwned {
|
||||
StickerOwned { name: Some(name.into()), use_count: Some(used), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_picker_gets_the_most_used_first_then_by_name_and_each_once() {
|
||||
let list = ClientEmoticonList { emoticons: vec![have("zed", 0), have("Alpha", 0), have("steamhappy", 9), have("steamhappy", 9), have(":colon:", 3), have("bad name", 100), have("", 5)], ..Default::default() };
|
||||
let got = emoticon_entries(&list);
|
||||
assert_eq!(got.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(), ["steamhappy", "colon", "Alpha", "zed"], "a name that is not a name is not offered");
|
||||
assert_eq!(got[0].id, "steam:steamhappy");
|
||||
assert_eq!(got[0].url.as_deref(), Some("https://community.fastly.steamstatic.com/economy/emoticon/steamhappy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stickers_are_offered_by_name_with_their_own_pictures() {
|
||||
let list = ClientEmoticonList { stickers: vec![sticker("b", 0), sticker("CatSurprise", 3), sticker("Nyan GLaDOS", 9), sticker("bad[name]", 20), sticker(" padded", 8)], ..Default::default() };
|
||||
let got = sticker_entries(&list);
|
||||
assert_eq!(
|
||||
got.iter().map(|e| (e.id.as_str(), e.url.as_deref().unwrap())).collect::<Vec<_>>(),
|
||||
[
|
||||
("Nyan GLaDOS", "https://community.fastly.steamstatic.com/economy/sticker/Nyan%20GLaDOS"),
|
||||
("CatSurprise", "https://community.fastly.steamstatic.com/economy/sticker/CatSurprise"),
|
||||
("b", "https://community.fastly.steamstatic.com/economy/sticker/b")
|
||||
],
|
||||
"a name has spaces in it and an address does not; a name that could end a tag is not offered"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_sticker_the_account_has_may_be_sent() {
|
||||
let state = crate::backend::kick::testkit::simulated_daemon("steam-sticker-owned");
|
||||
keep(&state, "steam:me", &ClientEmoticonList { stickers: vec![sticker("CatSurprise", 1)], ..Default::default() });
|
||||
assert!(is_owned_sticker(&state, "steam:me", "CatSurprise"));
|
||||
assert!(!is_owned_sticker(&state, "steam:me", "Other"));
|
||||
assert!(!is_owned_sticker(&state, "steam:somebody", "CatSurprise"), "another account's is not this one's");
|
||||
assert!(!is_owned_sticker(&state, "steam:me", "Cat Surprise; and more"));
|
||||
keep(&state, "steam:me", &ClientEmoticonList { stickers: vec![sticker("Nyan GLaDOS", 1)], ..Default::default() });
|
||||
assert!(is_owned_sticker(&state, "steam:me", "Nyan GLaDOS"), "a name with spaces in it is one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_full_list_is_kept_as_two_sources() {
|
||||
let state = crate::backend::kick::testkit::simulated_daemon("steam-emoticons-keep");
|
||||
keep(&state, "steam:me", &ClientEmoticonList { emoticons: vec![have("steamsad", 1)], stickers: vec![sticker("CatSurprise", 1), sticker("Wave", 0)], effects: vec![] });
|
||||
let sources = state.runtime.emoji_catalogue("steam:me");
|
||||
let by = |id: &str| sources.iter().find(|s| s.id == id).map(|s| s.emoji.len());
|
||||
assert_eq!((by("steam"), by(STICKERS)), (Some(1), Some(2)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_list_is_asked_for_again_as_the_picker_opens_and_not_twice_in_a_row() {
|
||||
use crate::backend::steam::link::{self, fake::*};
|
||||
use prost::Message;
|
||||
let state = crate::backend::kick::testkit::simulated_daemon("steam-emoticons-refresh");
|
||||
let asked = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let url = serve(Box::new({
|
||||
let asked = asked.clone();
|
||||
move |p| {
|
||||
if p.emsg != emsg::CLIENT_GET_EMOTICON_LIST {
|
||||
return Some(vec![]);
|
||||
}
|
||||
asked.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
// Steam says what the account has now: three stickers it did not have at sign-in.
|
||||
let now = ClientEmoticonList { emoticons: vec![have("a", 0)], stickers: vec![sticker("A", 0), sticker("B", 0), sticker("C", 0)], effects: vec![] };
|
||||
Some(vec![tell(emsg::CLIENT_EMOTICON_LIST, now.encode_to_vec())])
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
let (link, mut told) = link::open_with("test", &url, std::time::Duration::from_secs(2)).await.unwrap();
|
||||
state.runtime.set_steam_link("steam:refreshing", link);
|
||||
// What the connection does with that message.
|
||||
let hold = state.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some(packet) = told.recv().await {
|
||||
if packet.emsg == emsg::CLIENT_EMOTICON_LIST {
|
||||
keep(&hold, "steam:refreshing", &ClientEmoticonList::decode(packet.body.as_slice()).unwrap());
|
||||
}
|
||||
}
|
||||
});
|
||||
let stickers = |state: &AppState| state.runtime.emoji_catalogue("steam:refreshing").iter().find(|s| s.id == STICKERS).map(|s| s.emoji.len());
|
||||
assert_eq!(stickers(&state), None);
|
||||
refresh(&state, "steam:refreshing").await;
|
||||
assert_eq!(stickers(&state), Some(3), "what was earned after sign-in is there");
|
||||
refresh(&state, "steam:refreshing").await;
|
||||
assert_eq!(asked.load(std::sync::atomic::Ordering::SeqCst), 1, "a list just heard is not asked for again");
|
||||
// An account that is not connected has nothing to ask.
|
||||
refresh(&state, "steam:nobody").await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_emoticons_alone_are_offered_when_the_full_list_never_comes_and_do_not_replace_it_when_it_does() {
|
||||
use crate::backend::steam::link::{self, fake::*};
|
||||
use prost::Message;
|
||||
let state = crate::backend::kick::testkit::simulated_daemon("steam-emoticons-fallback");
|
||||
let url = serve(Box::new(|p| {
|
||||
if p.emsg == emsg::CLIENT_GET_EMOTICON_LIST {
|
||||
// Not answered.
|
||||
return Some(vec![]);
|
||||
}
|
||||
assert_eq!(p.header.target_job_name.as_deref(), Some("Player.GetEmoticonList#1"));
|
||||
Some(vec![answer(&p, 1, GetEmoticonListResponse { emoticons: vec![crate::backend::steam::proto::EmoticonInfo { name: Some("steamsad".into()), ..Default::default() }] }.encode_to_vec())])
|
||||
}))
|
||||
.await;
|
||||
let (link, _told) = link::open_with("test", &url, std::time::Duration::from_secs(2)).await.unwrap();
|
||||
load(state.clone(), link, "steam:me".to_string()).await;
|
||||
let sources = state.runtime.emoji_catalogue("steam:me");
|
||||
assert_eq!(sources.iter().find(|s| s.id == "steam").map(|s| s.emoji.len()), Some(1));
|
||||
assert_eq!(sources.iter().find(|s| s.id == STICKERS).map(|s| s.emoji.len()), Some(0));
|
||||
|
||||
// The full list, once it has come, is not overwritten by a later fallback.
|
||||
keep(&state, "steam:other", &ClientEmoticonList { emoticons: vec![have("a", 0), have("b", 0)], stickers: vec![sticker("S", 0)], effects: vec![] });
|
||||
let (link, _told) = link::open_with("test", &serve(Box::new(|p| Some(vec![answer(&p, 1, GetEmoticonListResponse::default().encode_to_vec())]))).await, std::time::Duration::from_secs(2)).await.unwrap();
|
||||
load(state.clone(), link, "steam:other".to_string()).await;
|
||||
let other = state.runtime.emoji_catalogue("steam:other");
|
||||
assert_eq!(other.iter().find(|s| s.id == STICKERS).map(|s| s.emoji.len()), Some(1));
|
||||
assert_eq!(other.iter().find(|s| s.id == "steam").map(|s| s.emoji.len()), Some(2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
//! Steam's result codes, said in words.
|
||||
//!
|
||||
//! Every answer from Steam carries one of these, a number from a list of over
|
||||
//! a hundred. Most are only ever seen by the one team that sends them; what is
|
||||
//! here is the ones a person signing in or sending a message can reach, put as
|
||||
//! what it means for them, and everything else is named by its number so a
|
||||
//! report of it can be looked up.
|
||||
|
||||
pub const OK: i32 = 1;
|
||||
pub const FILE_NOT_FOUND: i32 = 9;
|
||||
pub const INVALID_STATE: i32 = 11;
|
||||
pub const COMMUNITY_COOLDOWN: i32 = 116;
|
||||
pub const MUST_AGREE_TO_SSA: i32 = 118;
|
||||
pub const FAIL: i32 = 2;
|
||||
pub const NO_CONNECTION: i32 = 3;
|
||||
pub const INVALID_PASSWORD: i32 = 5;
|
||||
pub const LOGGED_IN_ELSEWHERE: i32 = 6;
|
||||
pub const INVALID_PROTOCOL_VER: i32 = 7;
|
||||
pub const INVALID_PARAM: i32 = 8;
|
||||
pub const BUSY: i32 = 10;
|
||||
pub const ACCESS_DENIED: i32 = 15;
|
||||
pub const TIMEOUT: i32 = 16;
|
||||
pub const BANNED: i32 = 17;
|
||||
pub const ACCOUNT_NOT_FOUND: i32 = 18;
|
||||
pub const INVALID_STEAM_ID: i32 = 19;
|
||||
pub const SERVICE_UNAVAILABLE: i32 = 20;
|
||||
pub const NOT_LOGGED_ON: i32 = 21;
|
||||
pub const INSUFFICIENT_PRIVILEGE: i32 = 24;
|
||||
pub const LIMIT_EXCEEDED: i32 = 25;
|
||||
pub const REVOKED: i32 = 26;
|
||||
pub const DUPLICATE_REQUEST: i32 = 29;
|
||||
pub const EXPIRED: i32 = 27;
|
||||
pub const BLOCKED: i32 = 40;
|
||||
pub const IGNORED: i32 = 41;
|
||||
pub const NO_MATCH: i32 = 42;
|
||||
pub const ACCOUNT_DISABLED: i32 = 43;
|
||||
pub const TRY_ANOTHER_CM: i32 = 48;
|
||||
pub const SUSPENDED: i32 = 51;
|
||||
pub const ACCOUNT_LOGON_DENIED: i32 = 63;
|
||||
pub const INVALID_LOGIN_AUTH_CODE: i32 = 65;
|
||||
pub const EXPIRED_LOGIN_AUTH_CODE: i32 = 71;
|
||||
pub const IP_LOGIN_RESTRICTION_FAILED: i32 = 72;
|
||||
pub const ACCOUNT_LOCKED_DOWN: i32 = 73;
|
||||
pub const REQUIRE_PASSWORD_RE_ENTRY: i32 = 77;
|
||||
pub const RATE_LIMIT_EXCEEDED: i32 = 84;
|
||||
pub const ACCOUNT_LOGIN_DENIED_NEED_TWO_FACTOR: i32 = 85;
|
||||
pub const ACCOUNT_LOGIN_DENIED_THROTTLE: i32 = 87;
|
||||
pub const TWO_FACTOR_CODE_MISMATCH: i32 = 88;
|
||||
pub const ACCOUNT_NOT_FRIENDS: i32 = 111;
|
||||
pub const LIMITED_USER_ACCOUNT: i32 = 112;
|
||||
pub const ACCOUNT_HAS_BEEN_DELETED: i32 = 114;
|
||||
pub const CLIENT_NO_LONGER_SUPPORTED: i32 = 119;
|
||||
|
||||
/// What a result means to a person, in a sentence that stands on its own.
|
||||
///
|
||||
/// Every number Steam defines up to 130 has a sentence here, so a failure is never reported as a
|
||||
/// bare number; the numbers are the ones the Steam client's own code uses for the ones it names
|
||||
/// (checked against its constants), and the rest follow the public list. A number past the end of
|
||||
/// that list is named by its number so a report of it can be looked up.
|
||||
pub fn describe(code: i32) -> String {
|
||||
let said = match code {
|
||||
0 => "Steam gave no result",
|
||||
OK => "it worked",
|
||||
FAIL => "Steam reported a failure without saying why",
|
||||
NO_CONNECTION => "there is no connection to Steam just now",
|
||||
4 => "Steam gave no result",
|
||||
INVALID_PASSWORD => "Steam says that password is not right",
|
||||
LOGGED_IN_ELSEWHERE => "this account signed in somewhere else, which ended this session",
|
||||
INVALID_PROTOCOL_VER | CLIENT_NO_LONGER_SUPPORTED => "Steam no longer accepts this version of moho's connection",
|
||||
INVALID_PARAM => "Steam did not understand what was asked",
|
||||
FILE_NOT_FOUND => "Steam could not find what was asked for",
|
||||
BUSY | SERVICE_UNAVAILABLE | TIMEOUT => "Steam is not answering just now; try again in a moment",
|
||||
INVALID_STATE => "that cannot be done in the state it is in",
|
||||
12 => "Steam does not accept that name",
|
||||
13 => "Steam does not accept that email address",
|
||||
14 => "that name is already taken",
|
||||
ACCESS_DENIED => "Steam refused the request",
|
||||
BANNED | SUSPENDED => "this account is suspended",
|
||||
ACCOUNT_NOT_FOUND => "Steam has no account by that name",
|
||||
INVALID_STEAM_ID => "Steam does not recognise that as an account",
|
||||
NOT_LOGGED_ON => "the connection is not signed in",
|
||||
22 => "Steam is still working on that",
|
||||
23 => "Steam could not secure the connection",
|
||||
INSUFFICIENT_PRIVILEGE => "this account is not allowed to do that; on Steam that usually means it is a limited account, which has to spend $5 before it can send friend requests",
|
||||
LIMIT_EXCEEDED => "that is over a limit Steam keeps; wait a while or do less of it",
|
||||
REVOKED | EXPIRED | REQUIRE_PASSWORD_RE_ENTRY => "the saved sign-in is no longer valid; sign in again",
|
||||
28 => "that has already been used",
|
||||
DUPLICATE_REQUEST => "a request to this person is already waiting",
|
||||
30 => "this account already has that",
|
||||
31 => "Steam could not place this connection",
|
||||
32 | 33 => "Steam could not save that just now; try again in a moment",
|
||||
34 => "this session was replaced by another sign-in",
|
||||
35 | 36 => "Steam's server could not be reached just now; try again in a moment",
|
||||
37 => "Steam had a problem on its side; try again in a moment",
|
||||
38 => "Steam ended the connection",
|
||||
39 => "Steam could not find that cart",
|
||||
BLOCKED => "this person has blocked you",
|
||||
IGNORED => "this person is ignoring you",
|
||||
NO_MATCH => "Steam found nothing that matches",
|
||||
ACCOUNT_DISABLED | ACCOUNT_HAS_BEEN_DELETED => "this account is disabled",
|
||||
44 => "Steam is read-only just now; try again later",
|
||||
45 => "this account cannot use that feature",
|
||||
46 => "Steam gave an administrator's answer",
|
||||
47 => "Steam needs a newer version of the client",
|
||||
TRY_ANOTHER_CM => "that Steam server asked to be tried another way",
|
||||
49 => "a password is needed to end the other session",
|
||||
50 => "this account is already signed in elsewhere",
|
||||
52 => "that was cancelled",
|
||||
53 | 54 => "Steam could not store that",
|
||||
55 => "Steam's server could not complete the call; try again in a moment",
|
||||
56 => "this account has no password set",
|
||||
57..=60 => "Steam could not link that account",
|
||||
61 => "Steam does not accept that password",
|
||||
62 => "that is the same as before",
|
||||
ACCOUNT_LOGON_DENIED => "Steam wants the code it emailed to this account",
|
||||
64 => "that password has been used before",
|
||||
INVALID_LOGIN_AUTH_CODE => "that code is not right",
|
||||
66 => "Steam wants a code, and this account has no email to send it to",
|
||||
67 | 68 => "this device cannot do that",
|
||||
69 => "Family View is holding this back; unlock it in Steam to continue",
|
||||
70 => "Steam could not reach the other service",
|
||||
EXPIRED_LOGIN_AUTH_CODE => "that code has expired; ask for another",
|
||||
IP_LOGIN_RESTRICTION_FAILED => "Steam will not let this account sign in from this address",
|
||||
ACCOUNT_LOCKED_DOWN => "this account is locked until its owner secures it",
|
||||
74 => "this account must verify its email address first",
|
||||
75 | 76 => "Steam gave an answer that could not be used",
|
||||
78 => "that is out of the range Steam accepts",
|
||||
79 => "Steam reported an unexpected error",
|
||||
80 => "that feature is switched off",
|
||||
81 | 82 => "this device is not allowed to do that",
|
||||
83 => "that is not available where this account is",
|
||||
RATE_LIMIT_EXCEEDED | ACCOUNT_LOGIN_DENIED_THROTTLE => "Steam is limiting how often this can be tried; wait a while before trying again",
|
||||
ACCOUNT_LOGIN_DENIED_NEED_TWO_FACTOR => "Steam wants the code from the Steam Guard authenticator",
|
||||
86 => "that has been deleted",
|
||||
TWO_FACTOR_CODE_MISMATCH => "that Steam Guard code is not right",
|
||||
89 => "that activation code is not right",
|
||||
90 => "this account is linked to more than one partner",
|
||||
91 => "nothing changed",
|
||||
92 => "no phone is attached to this account",
|
||||
93 => "the clock on this computer is too far from Steam's; set it right and try again",
|
||||
94 => "Steam could not send the code to the phone",
|
||||
95..=97 => "this account or phone has done that too many times; wait a while",
|
||||
98..=100 => "Steam could not settle that",
|
||||
101 => "Steam wants a captcha answered, which only its own page can do",
|
||||
102..=104 => "Steam refused that",
|
||||
105 => "this address is banned from Steam",
|
||||
106 => "that has expired",
|
||||
107 => "this account does not have the funds",
|
||||
108 => "too many are waiting; try again later",
|
||||
109 => "no licence was found",
|
||||
110 => "Steam could not send that much at once",
|
||||
ACCOUNT_NOT_FRIENDS => "you can only message people who are your friends",
|
||||
LIMITED_USER_ACCOUNT => "a limited account cannot do that; Steam asks for a purchase first",
|
||||
113 => "that cannot be removed",
|
||||
115 => "a licence was cancelled",
|
||||
COMMUNITY_COOLDOWN => "Steam asks this account to wait before doing that again",
|
||||
117 => "no launcher was given",
|
||||
MUST_AGREE_TO_SSA => "Steam wants the subscriber agreement accepted first, which only its own page can do",
|
||||
120 => "this is a different Steam realm",
|
||||
121 | 122 => "Steam gave an answer that could not be read",
|
||||
123 => "this account needs a verified phone number first",
|
||||
124 | 125 => "the device's power does not allow that",
|
||||
126 => "the saved sign-in is no longer valid; sign in again",
|
||||
127 => "a phone number that is internet-only cannot be used",
|
||||
128 => "that is not supported",
|
||||
129 => "that is over the family's size limit",
|
||||
130 => "the offline copy is no longer good",
|
||||
other => return format!("Steam answered with error {other}"),
|
||||
};
|
||||
said.to_string()
|
||||
}
|
||||
|
||||
/// Whether the saved sign-in is dead, so only a fresh sign-in can help.
|
||||
pub fn means_sign_in_again(code: i32) -> bool {
|
||||
matches!(code, INVALID_PASSWORD | REVOKED | EXPIRED | REQUIRE_PASSWORD_RE_ENTRY | ACCOUNT_NOT_FOUND | ACCOUNT_LOGON_DENIED | ACCOUNT_LOGIN_DENIED_NEED_TWO_FACTOR | 126)
|
||||
}
|
||||
|
||||
/// Whether the answer is about the account or this client and so the same on every server and every
|
||||
/// try: signed in elsewhere, replaced, suspended, locked down, banned by address, a version Steam no
|
||||
/// longer takes, a clock that is wrong, a page only Steam's own site can show. Retrying only repeats
|
||||
/// it - and for the first two takes the other session back and forth.
|
||||
pub fn is_final(code: i32) -> bool {
|
||||
matches!(code, LOGGED_IN_ELSEWHERE | 34 | 50 | BANNED | SUSPENDED | ACCOUNT_DISABLED | ACCOUNT_HAS_BEEN_DELETED | ACCOUNT_LOCKED_DOWN | IP_LOGIN_RESTRICTION_FAILED | 105 | INVALID_PROTOCOL_VER | CLIENT_NO_LONGER_SUPPORTED | 69 | 74 | 93 | MUST_AGREE_TO_SSA)
|
||||
}
|
||||
|
||||
/// Whether Steam is limiting how often this may be tried, so the answer is to wait before trying
|
||||
/// again, not to try another server.
|
||||
pub fn is_rate_limit(code: i32) -> bool {
|
||||
matches!(code, RATE_LIMIT_EXCEEDED | ACCOUNT_LOGIN_DENIED_THROTTLE | LIMIT_EXCEEDED | COMMUNITY_COOLDOWN)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_ones_a_person_reaches_are_said_plainly() {
|
||||
assert!(describe(INVALID_PASSWORD).contains("password"));
|
||||
assert!(describe(RATE_LIMIT_EXCEEDED).contains("limiting"));
|
||||
assert!(describe(ACCOUNT_NOT_FRIENDS).contains("friends"));
|
||||
assert!(describe(INSUFFICIENT_PRIVILEGE).contains("limited account"));
|
||||
assert!(describe(NO_MATCH).contains("nothing"));
|
||||
assert!(describe(DUPLICATE_REQUEST).contains("already"));
|
||||
assert!(describe(LIMITED_USER_ACCOUNT).contains("limited account"));
|
||||
assert!(describe(TWO_FACTOR_CODE_MISMATCH).contains("Steam Guard"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_result_steam_defines_has_a_sentence_and_only_a_number_past_the_list_is_named_by_number() {
|
||||
for code in 0..=130 {
|
||||
let said = describe(code);
|
||||
assert!(!said.starts_with("Steam answered with error"), "{code} has no sentence");
|
||||
assert!(!said.is_empty());
|
||||
}
|
||||
assert_eq!(describe(131), "Steam answered with error 131");
|
||||
assert_eq!(describe(9999), "Steam answered with error 9999");
|
||||
assert_eq!(describe(-1), "Steam answered with error -1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_ends_a_session_for_good_what_waits_and_what_another_server_may_fix_are_told_apart() {
|
||||
for code in [LOGGED_IN_ELSEWHERE, 34, 50, SUSPENDED, ACCOUNT_LOCKED_DOWN, 105, CLIENT_NO_LONGER_SUPPORTED, 93, MUST_AGREE_TO_SSA] {
|
||||
assert!(is_final(code), "{code} is the same on every try");
|
||||
}
|
||||
for code in [RATE_LIMIT_EXCEEDED, ACCOUNT_LOGIN_DENIED_THROTTLE, LIMIT_EXCEEDED, COMMUNITY_COOLDOWN] {
|
||||
assert!(is_rate_limit(code) && !is_final(code));
|
||||
}
|
||||
assert!(means_sign_in_again(126));
|
||||
for code in [FAIL, TIMEOUT, SERVICE_UNAVAILABLE, 37, 55] {
|
||||
assert!(!is_final(code) && !is_rate_limit(code) && !means_sign_in_again(code), "{code} may be better elsewhere");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ones_the_clients_own_code_treats_specially_say_what_it_says() {
|
||||
// 15 is the client's "not friends" on a direct message, 84 its rate limit, 118 its agreement page
|
||||
assert!(describe(MUST_AGREE_TO_SSA).contains("agreement"));
|
||||
assert!(describe(COMMUNITY_COOLDOWN).contains("wait"));
|
||||
assert!(describe(69).contains("Family View"));
|
||||
assert!(describe(93).contains("clock"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn some_failures_are_the_servers_and_some_are_the_accounts() {
|
||||
assert!(means_sign_in_again(REVOKED));
|
||||
assert!(means_sign_in_again(INVALID_PASSWORD));
|
||||
assert!(!means_sign_in_again(RATE_LIMIT_EXCEEDED), "waiting helps, signing in again does not");
|
||||
assert!(!means_sign_in_again(SERVICE_UNAVAILABLE));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
//! Who the friends are, how they are doing, and what each conversation with
|
||||
//! them is called.
|
||||
//!
|
||||
//! Steam sends the friends list and the details of the people on it as two
|
||||
//! separate streams: a list of ids and how each stands with us (a friend, a
|
||||
//! request, blocked), and, as they become known and as they change, each
|
||||
//! person's name, state, game and picture. A change is a partial update -
|
||||
//! only what changed is in it - so a person is built up from many messages
|
||||
//! and this is where they are put together.
|
||||
//!
|
||||
//! What this does not do is talk to the rest of the daemon: it is plain data
|
||||
//! in and out, so the rules (who counts as a friend, what a conversation is
|
||||
//! called when two friends share a name) can be tested without a connection.
|
||||
|
||||
use super::proto::{ClientFriendsList, ClientPersonaState, PerFriendPreferences, PersonaFriend};
|
||||
use super::steamid;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// `EFriendRelationship`.
|
||||
pub const REL_NONE: u32 = 0;
|
||||
/// Somebody has asked to be our friend.
|
||||
pub const REL_REQUEST_RECIPIENT: u32 = 2;
|
||||
pub const REL_FRIEND: u32 = 3;
|
||||
/// We have asked somebody to be ours.
|
||||
pub const REL_REQUEST_INITIATOR: u32 = 4;
|
||||
|
||||
/// `ENotificationSetting` never: this friend's messages do not notify.
|
||||
pub const NOTIFY_NEVER: i32 = 2;
|
||||
|
||||
/// What is known about a person from their persona updates.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Persona {
|
||||
pub name: Option<String>,
|
||||
/// Steam's state number; see `presence`.
|
||||
pub state: u32,
|
||||
/// The game they are in, when they are in one and Steam has said its name.
|
||||
pub game: Option<String>,
|
||||
/// Its number, which is all Steam says of a game that is on Steam.
|
||||
pub app: Option<u64>,
|
||||
/// When this was first seen to be what they are playing, unix seconds. Steam does not
|
||||
/// say how long anybody has been in a game, so this is only since moho noticed.
|
||||
pub playing_since: Option<u32>,
|
||||
/// The avatar's hash as hex.
|
||||
pub avatar: Option<String>,
|
||||
/// `EPersonaStateFlag`: which kind of client they are on, among other things.
|
||||
pub flags: u32,
|
||||
/// Whether they are on a Steam Deck.
|
||||
pub on_deck: bool,
|
||||
/// What kind of device Steam says they are on (`EGamingDeviceType`).
|
||||
pub device: u32,
|
||||
/// The last time they were seen, unix seconds.
|
||||
pub last_seen: Option<u32>,
|
||||
/// What the game they are in says about what they are doing, where it says.
|
||||
pub rich: Option<String>,
|
||||
}
|
||||
|
||||
/// `EGamingDeviceType` as the Steam client reads it: a Steam Deck, and a Steam Machine.
|
||||
pub const DEVICE_STEAM_DECK: u32 = 544;
|
||||
pub const DEVICE_STEAM_MACHINE: u32 = 545;
|
||||
|
||||
/// The picture Steam shows for somebody who has not set one.
|
||||
const DEFAULT_AVATAR: &str = "fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb";
|
||||
|
||||
/// Where an avatar is served from. A hash of all zeros is how Steam says
|
||||
/// "none set", and is not an image - the default has a hash of its own.
|
||||
pub fn avatar_url(hash: &[u8]) -> String {
|
||||
let hex: String = hash.iter().map(|b| format!("{b:02x}")).collect();
|
||||
let hex = if hex.is_empty() || hash.iter().all(|b| *b == 0) { DEFAULT_AVATAR.to_string() } else { hex };
|
||||
format!("https://avatars.steamstatic.com/{hex}_full.jpg")
|
||||
}
|
||||
|
||||
/// A name made safe to be a conversation's name: no line breaks or other
|
||||
/// control characters, no padding.
|
||||
pub fn clean_name(name: &str) -> String {
|
||||
name.chars().filter(|c| !c.is_control()).collect::<String>().trim().to_string()
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Roster {
|
||||
relationships: HashMap<u32, u32>,
|
||||
personas: HashMap<u32, Persona>,
|
||||
/// What the account keeps about each friend: the name it calls them, and how they may notify it.
|
||||
preferences: HashMap<u32, PerFriendPreferences>,
|
||||
/// People with a conversation who are not friends: somebody who wrote to
|
||||
/// us first. They need a conversation of their own and a name that does
|
||||
/// not clash with anyone else's, but they are not on the friends list.
|
||||
talking: std::collections::HashSet<u32>,
|
||||
/// What the games people are in are called, as the store has told us.
|
||||
app_names: HashMap<u64, String>,
|
||||
/// What people were called when last seen, kept between runs: the name a conversation has from
|
||||
/// the start of a session, in place of the person's number, until Steam says who they are now.
|
||||
remembered: HashMap<u32, String>,
|
||||
}
|
||||
|
||||
impl Roster {
|
||||
/// Takes in what people were called last time.
|
||||
pub fn remember(&mut self, names: impl IntoIterator<Item = (u32, String)>) {
|
||||
self.remembered.extend(names);
|
||||
}
|
||||
|
||||
/// What somebody is called now, if Steam or the account has said: the name the account gives
|
||||
/// them, otherwise their own. Nothing for somebody not heard of yet - the number is not a name.
|
||||
pub fn live_name(&self, account: u32) -> Option<String> {
|
||||
self.nickname(account)
|
||||
.map(clean_name)
|
||||
.filter(|n| !n.is_empty())
|
||||
.or_else(|| self.personas.get(&account).and_then(|p| p.name.as_deref()).map(clean_name).filter(|n| !n.is_empty()))
|
||||
}
|
||||
|
||||
/// What somebody is called, for showing: what they are called now when that is known, and
|
||||
/// otherwise what they were called last time. Nothing for somebody never heard of.
|
||||
pub fn display_name(&self, account: u32) -> Option<String> {
|
||||
self.live_name(account).or_else(|| self.remembered.get(&account).map(|n| clean_name(n)).filter(|n| !n.is_empty()))
|
||||
}
|
||||
|
||||
/// The names that are not what is remembered of them: everybody Steam or the account has named,
|
||||
/// whether or not they are in a conversation, since a member of a group is as much shown by
|
||||
/// name. Taken as remembered from now, and handed back to be kept.
|
||||
pub fn names_to_keep(&mut self) -> Vec<(u32, String)> {
|
||||
let accounts: Vec<u32> = self.personas.keys().copied().chain(self.preferences.keys().copied()).collect();
|
||||
let mut fresh = Vec::new();
|
||||
for account in accounts {
|
||||
let Some(name) = self.live_name(account) else { continue };
|
||||
if self.remembered.get(&account) != Some(&name) {
|
||||
self.remembered.insert(account, name.clone());
|
||||
fresh.push((account, name));
|
||||
}
|
||||
}
|
||||
fresh
|
||||
}
|
||||
|
||||
/// Notes what a game is called.
|
||||
pub fn set_app_name(&mut self, app: u64, name: String) {
|
||||
self.app_names.insert(app, name);
|
||||
}
|
||||
|
||||
/// The game somebody is in, in words: the name Steam gave, or the one the
|
||||
/// store gave for its number, or - while that is being asked - only that it
|
||||
/// is a game.
|
||||
pub fn game_of(&self, account: u32) -> Option<String> {
|
||||
let persona = self.personas.get(&account)?;
|
||||
if let Some(game) = persona.game.clone() {
|
||||
return Some(game);
|
||||
}
|
||||
let app = persona.app?;
|
||||
Some(self.app_names.get(&app).cloned().unwrap_or_else(|| "In game".to_string()))
|
||||
}
|
||||
|
||||
/// Games people are in whose names are not known: what to ask the store.
|
||||
/// Only a game on Steam, whose number fits the store's; one that is not has
|
||||
/// a name from Steam, or none to be had.
|
||||
pub fn unnamed_apps(&self) -> Vec<u64> {
|
||||
let mut apps: Vec<u64> = self.personas.values().filter(|p| p.game.is_none()).filter_map(|p| p.app).filter(|a| *a <= u64::from(u32::MAX) && !self.app_names.contains_key(a)).collect();
|
||||
apps.sort_unstable();
|
||||
apps.dedup();
|
||||
apps
|
||||
}
|
||||
|
||||
/// Takes in the friends list. A full list replaces what was held; an
|
||||
/// incremental one is only the people whose standing changed. Returns the
|
||||
/// accounts whose standing is now different.
|
||||
pub fn apply_friends_list(&mut self, list: &ClientFriendsList) -> Vec<u32> {
|
||||
let mut changed = Vec::new();
|
||||
if !list.bincremental.unwrap_or(false) {
|
||||
// Anyone not mentioned any more is no longer on the list.
|
||||
let now: std::collections::HashSet<u32> = list.friends.iter().filter_map(|f| f.ulfriendid.and_then(steamid::account_of)).collect();
|
||||
let gone: Vec<u32> = self.relationships.keys().copied().filter(|a| !now.contains(a)).collect();
|
||||
for account in gone {
|
||||
self.relationships.remove(&account);
|
||||
changed.push(account);
|
||||
}
|
||||
}
|
||||
for entry in &list.friends {
|
||||
let Some(account) = entry.ulfriendid.and_then(steamid::account_of) else { continue };
|
||||
let relationship = entry.efriendrelationship.unwrap_or(REL_NONE);
|
||||
let before = self.relationships.get(&account).copied().unwrap_or(REL_NONE);
|
||||
if relationship == REL_NONE {
|
||||
self.relationships.remove(&account);
|
||||
} else {
|
||||
self.relationships.insert(account, relationship);
|
||||
}
|
||||
if before != relationship {
|
||||
changed.push(account);
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Takes in persona updates, which carry only what changed. Returns the
|
||||
/// accounts whose persona is now different.
|
||||
pub fn apply_persona(&mut self, update: &ClientPersonaState) -> Vec<u32> {
|
||||
let mut changed = Vec::new();
|
||||
for friend in &update.friends {
|
||||
let Some(account) = friend.friendid.and_then(steamid::account_of) else { continue };
|
||||
let held = self.personas.entry(account).or_default();
|
||||
let before = held.clone();
|
||||
merge(held, friend);
|
||||
if *held != before {
|
||||
changed.push(account);
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Notes that there is a conversation with this person, friend or not.
|
||||
/// Returns whether that is new.
|
||||
pub fn note_conversation(&mut self, account: u32) -> bool {
|
||||
!self.is_friend(account) && self.talking.insert(account)
|
||||
}
|
||||
|
||||
/// Everybody a conversation exists with: the friends, and whoever else
|
||||
/// has written. In a steady order.
|
||||
pub fn participants(&self) -> Vec<u32> {
|
||||
let mut out = self.friends();
|
||||
out.extend(self.talking.iter().copied().filter(|a| !self.is_friend(*a)));
|
||||
out.sort_unstable();
|
||||
out
|
||||
}
|
||||
|
||||
/// The people who are friends, in a steady order.
|
||||
pub fn friends(&self) -> Vec<u32> {
|
||||
let mut out: Vec<u32> = self.relationships.iter().filter(|(_, r)| **r == REL_FRIEND).map(|(a, _)| *a).collect();
|
||||
out.sort_unstable();
|
||||
out
|
||||
}
|
||||
|
||||
/// The people who have asked to be our friend, in a steady order.
|
||||
/// Everybody the account has blocked or ignored: Steam's "blocked" and the two
|
||||
/// ways it says "ignored".
|
||||
pub fn blocked(&self) -> Vec<u32> {
|
||||
let mut out: Vec<u32> = self.relationships.iter().filter(|(_, r)| matches!(**r, 1 | 5 | 6)).map(|(a, _)| *a).collect();
|
||||
out.sort_unstable();
|
||||
out
|
||||
}
|
||||
|
||||
pub fn asked_us(&self) -> Vec<u32> {
|
||||
self.with_relationship(REL_REQUEST_RECIPIENT)
|
||||
}
|
||||
|
||||
/// The people we have asked, who have not yet said yes.
|
||||
pub fn asked_by_us(&self) -> Vec<u32> {
|
||||
self.with_relationship(REL_REQUEST_INITIATOR)
|
||||
}
|
||||
|
||||
fn with_relationship(&self, wanted: u32) -> Vec<u32> {
|
||||
let mut out: Vec<u32> = self.relationships.iter().filter(|(_, r)| **r == wanted).map(|(a, _)| *a).collect();
|
||||
out.sort_unstable();
|
||||
out
|
||||
}
|
||||
|
||||
pub fn is_friend(&self, account: u32) -> bool {
|
||||
self.relationships.get(&account) == Some(&REL_FRIEND)
|
||||
}
|
||||
|
||||
/// Takes in what the account keeps about a friend. Returns whether the name
|
||||
/// it calls them changed, which is what moves a conversation.
|
||||
pub fn set_preferences(&mut self, prefs: &PerFriendPreferences) -> bool {
|
||||
let Some(account) = prefs.accountid else { return false };
|
||||
let before = self.nickname(account).map(str::to_string);
|
||||
let bare = PerFriendPreferences { accountid: Some(account), ..Default::default() };
|
||||
if *prefs == bare {
|
||||
self.preferences.remove(&account);
|
||||
} else {
|
||||
self.preferences.insert(account, prefs.clone());
|
||||
}
|
||||
before.as_deref() != self.nickname(account)
|
||||
}
|
||||
|
||||
/// Whether the account has muted this friend's messages, on any of its clients.
|
||||
pub fn muted(&self, account: u32) -> bool {
|
||||
self.preferences.get(&account).is_some_and(|p| p.notifications_showmessages == Some(NOTIFY_NEVER))
|
||||
}
|
||||
|
||||
/// Sets only what the account calls a friend, keeping the rest it holds.
|
||||
/// Returns whether that name changed.
|
||||
pub fn set_nickname(&mut self, account: u32, nickname: Option<&str>) -> bool {
|
||||
let mut prefs = self.preferences.get(&account).cloned().unwrap_or(PerFriendPreferences { accountid: Some(account), ..Default::default() });
|
||||
prefs.nickname = nickname.map(str::to_string);
|
||||
self.set_preferences(&prefs)
|
||||
}
|
||||
|
||||
/// What the account calls this friend, if it has said.
|
||||
pub fn nickname(&self, account: u32) -> Option<&str> {
|
||||
self.preferences.get(&account).and_then(|p| p.nickname.as_deref()).map(str::trim).filter(|n| !n.is_empty())
|
||||
}
|
||||
|
||||
pub fn persona(&self, account: u32) -> Option<&Persona> {
|
||||
self.personas.get(&account)
|
||||
}
|
||||
|
||||
/// What each friend's conversation is called.
|
||||
///
|
||||
/// Their name, since that is what a person recognises - but names are not
|
||||
/// unique on Steam, and a conversation's name is also its identity, so two
|
||||
/// friends called the same would share one and each message would go to
|
||||
/// whichever was first. Whenever two or more friends share a name (read
|
||||
/// without regard to case) every one of them has their account number
|
||||
/// added to it, which is stable and tells them apart; when only one is
|
||||
/// left the number is dropped again. Somebody not yet named is called by
|
||||
/// what they were called last time, and failing that by their id.
|
||||
///
|
||||
/// Distinct is the guarantee, not a hope: a friend can be called anything,
|
||||
/// including exactly what another friend's name becomes once decorated, so
|
||||
/// the result is checked and anyone still sharing a name is marked again,
|
||||
/// differently, until nobody is.
|
||||
/// Whether anything is known to call this person: a nickname the account
|
||||
/// gave them, or the name they have. Without either, `conversation_names`
|
||||
/// has only their number to offer.
|
||||
pub fn has_name(&self, account: u32) -> bool {
|
||||
self.nickname(account).map(clean_name).is_some_and(|n| !n.is_empty())
|
||||
|| self.personas.get(&account).and_then(|p| p.name.as_deref()).map(clean_name).is_some_and(|n| !n.is_empty())
|
||||
}
|
||||
|
||||
pub fn conversation_names(&self) -> HashMap<u32, String> {
|
||||
// What the account calls them, if it has said; otherwise what they
|
||||
// call themselves.
|
||||
let base = |account: u32| -> String {
|
||||
self.live_name(account)
|
||||
// Not yet said this time: what they were called last, which is far likelier to be right than a number.
|
||||
.or_else(|| self.remembered.get(&account).map(|n| clean_name(n)).filter(|n| !n.is_empty()))
|
||||
.unwrap_or_else(|| steamid::text_of(account))
|
||||
};
|
||||
let people = self.participants();
|
||||
let mut names: HashMap<u32, String> = people.iter().map(|a| (*a, base(*a))).collect();
|
||||
// Plain, then with the number in round brackets, in square, in curly;
|
||||
// and for the last resort a character no cleaned name can contain.
|
||||
let marks: [fn(&str, u32) -> String; 4] = [
|
||||
|n, a| format!("{n} ({a})"),
|
||||
|n, a| format!("{n} [{a}]"),
|
||||
|n, a| format!("{n} {{{a}}}"),
|
||||
|n, a| format!("{n}\u{1f}{a}"),
|
||||
];
|
||||
for mark in marks {
|
||||
let mut by_name: HashMap<String, Vec<u32>> = HashMap::new();
|
||||
for (account, name) in &names {
|
||||
by_name.entry(name.to_lowercase()).or_default().push(*account);
|
||||
}
|
||||
let clashing: Vec<u32> = by_name.into_values().filter(|group| group.len() > 1).flatten().collect();
|
||||
if clashing.is_empty() {
|
||||
break;
|
||||
}
|
||||
for account in clashing {
|
||||
names.insert(account, mark(&base(account), account));
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
}
|
||||
|
||||
fn merge(held: &mut Persona, update: &PersonaFriend) {
|
||||
if let Some(name) = update.player_name.as_deref().map(clean_name).filter(|n| !n.is_empty()) {
|
||||
held.name = Some(name);
|
||||
}
|
||||
if let Some(state) = update.persona_state {
|
||||
held.state = state;
|
||||
}
|
||||
// A game is cleared by saying there is none, not by leaving it out.
|
||||
if update.gameid == Some(0) || update.game_played_app_id == Some(0) {
|
||||
held.game = None;
|
||||
held.app = None;
|
||||
held.playing_since = None;
|
||||
}
|
||||
if let Some(app) = update.game_played_app_id.map(u64::from).filter(|a| *a > 0).or(update.gameid.filter(|g| *g > 0)) {
|
||||
if held.app != Some(app) {
|
||||
// A different game: what was said of the last is not said of this one.
|
||||
held.game = None;
|
||||
held.playing_since = Some(std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as u32).unwrap_or(0));
|
||||
}
|
||||
held.app = Some(app);
|
||||
}
|
||||
if let Some(game) = update.game_name.as_deref().filter(|g| !g.is_empty()) {
|
||||
held.game = Some(game.to_string());
|
||||
}
|
||||
if let Some(hash) = update.avatar_hash.as_deref().filter(|h| !h.is_empty()) {
|
||||
held.avatar = Some(avatar_url(hash));
|
||||
}
|
||||
if let Some(flags) = update.persona_state_flags {
|
||||
held.flags = flags;
|
||||
}
|
||||
// The Steam Deck is said two ways: a flag, and the kind of device it is (544), which is what
|
||||
// the Steam client itself goes by; and 545 is a Steam Machine.
|
||||
if let Some(deck) = update.on_steam_deck {
|
||||
held.on_deck = deck;
|
||||
}
|
||||
if let Some(kind) = update.gaming_device_type {
|
||||
held.device = kind;
|
||||
held.on_deck = held.on_deck || kind == DEVICE_STEAM_DECK;
|
||||
}
|
||||
if let Some(seen) = [update.last_seen_online, update.last_logoff, update.last_logon].into_iter().flatten().filter(|t| *t > 0).max() {
|
||||
held.last_seen = Some(held.last_seen.map_or(seen, |old| old.max(seen)));
|
||||
}
|
||||
// A game's own words for what is going on: its "status" line, which is
|
||||
// plain text where the game sets one. Cleared with the game.
|
||||
if !update.rich_presence.is_empty() {
|
||||
held.rich = update.rich_presence.iter().find(|kv| kv.key.as_deref() == Some("status")).and_then(|kv| kv.value.clone()).filter(|v| !v.trim().is_empty());
|
||||
}
|
||||
if update.gameid == Some(0) || update.game_played_app_id == Some(0) {
|
||||
held.rich = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::backend::steam::proto::{FriendsListEntry, KeyValue};
|
||||
|
||||
// The other standings a list carries, which matter here only as not being a friend.
|
||||
const REL_BLOCKED: u32 = 1;
|
||||
|
||||
#[test]
|
||||
fn a_person_is_called_what_they_were_until_steam_says_who_they_are_and_what_is_said_is_kept() {
|
||||
let mut r = Roster::default();
|
||||
r.apply_friends_list(&ClientFriendsList {
|
||||
friends: vec![FriendsListEntry { ulfriendid: Some(steamid::steamid_of(7)), efriendrelationship: Some(REL_FRIEND) }],
|
||||
..Default::default()
|
||||
});
|
||||
r.remember([(7, "Old Name".to_string())]);
|
||||
assert_eq!(r.conversation_names()[&7], "Old Name", "not the number, before Steam has said");
|
||||
assert_eq!(r.display_name(7).as_deref(), Some("Old Name"));
|
||||
assert!(r.names_to_keep().is_empty(), "nothing new to keep");
|
||||
r.apply_persona(&ClientPersonaState { friends: vec![PersonaFriend { friendid: Some(steamid::steamid_of(7)), player_name: Some("New Name".into()), persona_state: Some(1), ..Default::default() }], ..Default::default() });
|
||||
assert_eq!(r.conversation_names()[&7], "New Name", "and what they are called now once it is said");
|
||||
assert_eq!(r.names_to_keep(), vec![(7, "New Name".to_string())], "which is kept in place of the old");
|
||||
assert!(r.names_to_keep().is_empty());
|
||||
// Somebody never heard of is only a number.
|
||||
assert_eq!(r.display_name(8), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nickname_is_what_a_friend_is_called_and_goes_back_to_their_name_when_taken() {
|
||||
let mut r = Roster::default();
|
||||
r.apply_friends_list(&list(false, &[(10, REL_FRIEND), (20, REL_FRIEND)]));
|
||||
r.apply_persona(&ClientPersonaState { friends: vec![PersonaFriend { friendid: Some(steamid::steamid_of(10)), player_name: Some("Gabe".into()), ..Default::default() }], ..Default::default() });
|
||||
assert_eq!(r.conversation_names()[&10], "Gabe");
|
||||
assert!(r.set_nickname(10, Some("Buddy")), "a new name is a change");
|
||||
assert_eq!(r.conversation_names()[&10], "Buddy");
|
||||
assert!(!r.set_nickname(10, Some("Buddy")), "the same again is not");
|
||||
// Muting keeps the nickname, and the other way about.
|
||||
r.set_preferences(&PerFriendPreferences { accountid: Some(10), nickname: Some("Buddy".into()), notifications_showmessages: Some(NOTIFY_NEVER), ..Default::default() });
|
||||
assert_eq!(r.preferences.get(&10).and_then(|p| p.notifications_showmessages), Some(NOTIFY_NEVER));
|
||||
assert!(r.set_nickname(10, None));
|
||||
assert_eq!(r.conversation_names()[&10], "Gabe");
|
||||
assert_eq!(r.preferences.get(&10).and_then(|p| p.notifications_showmessages), Some(NOTIFY_NEVER), "taking the name back does not unmute");
|
||||
// Two friends given the same nickname are still told apart.
|
||||
r.set_nickname(10, Some("Pal"));
|
||||
r.set_nickname(20, Some("pal"));
|
||||
let names = r.conversation_names();
|
||||
assert_ne!(names[&10].to_lowercase(), names[&20].to_lowercase());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_kind_of_client_and_last_seen_are_kept_from_their_updates() {
|
||||
let mut r = Roster::default();
|
||||
r.apply_friends_list(&list(false, &[(10, REL_FRIEND)]));
|
||||
r.apply_persona(&ClientPersonaState {
|
||||
friends: vec![PersonaFriend { friendid: Some(steamid::steamid_of(10)), persona_state_flags: Some(512), last_logoff: Some(100), last_seen_online: Some(300), on_steam_deck: Some(false), rich_presence: vec![KeyValue { key: Some("status".into()), value: Some("In a match".into()) }], ..Default::default() }],
|
||||
..Default::default()
|
||||
});
|
||||
let p = r.persona(10).unwrap();
|
||||
assert_eq!((p.flags, p.last_seen, p.rich.as_deref()), (512, Some(300), Some("In a match")));
|
||||
// A later update only says what changed: the newest time wins, and the game ending ends its words.
|
||||
r.apply_persona(&ClientPersonaState { friends: vec![PersonaFriend { friendid: Some(steamid::steamid_of(10)), last_logoff: Some(200), gameid: Some(0), ..Default::default() }], ..Default::default() });
|
||||
let p = r.persona(10).unwrap();
|
||||
assert_eq!((p.last_seen, p.rich.as_deref(), p.flags), (Some(300), None, 512));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requests_are_told_apart_by_who_made_them() {
|
||||
let mut r = Roster::default();
|
||||
r.apply_friends_list(&list(false, &[(10, REL_FRIEND), (30, REL_REQUEST_RECIPIENT), (20, REL_REQUEST_RECIPIENT), (50, REL_REQUEST_INITIATOR), (60, REL_BLOCKED)]));
|
||||
assert_eq!(r.asked_us(), [20, 30]);
|
||||
assert_eq!(r.asked_by_us(), [50]);
|
||||
assert_eq!(r.friends(), [10]);
|
||||
// Saying yes turns a request into a friend.
|
||||
r.apply_friends_list(&list(true, &[(20, REL_FRIEND)]));
|
||||
assert_eq!(r.asked_us(), [30]);
|
||||
assert!(r.is_friend(20));
|
||||
}
|
||||
|
||||
fn entry(account: u32, relationship: u32) -> FriendsListEntry {
|
||||
FriendsListEntry { ulfriendid: Some(steamid::steamid_of(account)), efriendrelationship: Some(relationship) }
|
||||
}
|
||||
|
||||
fn list(incremental: bool, entries: &[(u32, u32)]) -> ClientFriendsList {
|
||||
ClientFriendsList { bincremental: Some(incremental), friends: entries.iter().map(|(a, r)| entry(*a, *r)).collect(), ..Default::default() }
|
||||
}
|
||||
|
||||
fn named(account: u32, name: &str, state: u32) -> PersonaFriend {
|
||||
PersonaFriend { friendid: Some(steamid::steamid_of(account)), player_name: Some(name.into()), persona_state: Some(state), ..Default::default() }
|
||||
}
|
||||
|
||||
fn persona(friends: Vec<PersonaFriend>) -> ClientPersonaState {
|
||||
ClientPersonaState { friends, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_friends_are_friends_not_requests_or_the_blocked() {
|
||||
let mut r = Roster::default();
|
||||
r.apply_friends_list(&list(false, &[(10, REL_FRIEND), (20, REL_REQUEST_RECIPIENT), (30, REL_BLOCKED), (40, REL_FRIEND), (50, REL_REQUEST_INITIATOR)]));
|
||||
assert_eq!(r.friends(), vec![10, 40]);
|
||||
assert!(r.is_friend(10) && !r.is_friend(20) && !r.is_friend(99));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_incremental_list_changes_the_people_it_names_and_leaves_the_rest() {
|
||||
let mut r = Roster::default();
|
||||
r.apply_friends_list(&list(false, &[(10, REL_FRIEND), (20, REL_FRIEND)]));
|
||||
let changed = r.apply_friends_list(&list(true, &[(20, REL_NONE), (30, REL_FRIEND), (10, REL_FRIEND)]));
|
||||
assert_eq!(r.friends(), vec![10, 30]);
|
||||
changed.iter().for_each(|a| assert!([20, 30].contains(a), "10 did not change, so it is not reported: {changed:?}"));
|
||||
assert_eq!(changed.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_list_forgets_whoever_it_no_longer_names() {
|
||||
let mut r = Roster::default();
|
||||
r.apply_friends_list(&list(false, &[(10, REL_FRIEND), (20, REL_FRIEND)]));
|
||||
let changed = r.apply_friends_list(&list(false, &[(20, REL_FRIEND)]));
|
||||
assert_eq!(r.friends(), vec![20]);
|
||||
assert_eq!(changed, vec![10]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_id_that_is_not_a_person_is_skipped_not_a_panic() {
|
||||
let mut r = Roster::default();
|
||||
let group = (1u64 << 56) | (7 << 52) | (1 << 32) | 5;
|
||||
let bad = ClientFriendsList { friends: vec![FriendsListEntry { ulfriendid: Some(group), efriendrelationship: Some(3) }, FriendsListEntry { ulfriendid: None, efriendrelationship: Some(3) }], ..Default::default() };
|
||||
assert!(r.apply_friends_list(&bad).is_empty());
|
||||
assert!(r.friends().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_persona_is_built_up_from_partial_updates() {
|
||||
let mut r = Roster::default();
|
||||
r.apply_persona(&persona(vec![named(10, "Alex", 1)]));
|
||||
// A later update with only a state, and one with only a game.
|
||||
r.apply_persona(&persona(vec![PersonaFriend { friendid: Some(steamid::steamid_of(10)), persona_state: Some(3), ..Default::default() }]));
|
||||
r.apply_persona(&persona(vec![PersonaFriend { friendid: Some(steamid::steamid_of(10)), game_name: Some("Portal 2".into()), gameid: Some(620), ..Default::default() }]));
|
||||
let p = r.persona(10).unwrap();
|
||||
assert_eq!(p.name.as_deref(), Some("Alex"), "a name left out is not a name taken away");
|
||||
assert_eq!(p.state, 3);
|
||||
assert_eq!(p.game.as_deref(), Some("Portal 2"));
|
||||
// Quitting the game is said by saying there is none.
|
||||
r.apply_persona(&persona(vec![PersonaFriend { friendid: Some(steamid::steamid_of(10)), gameid: Some(0), ..Default::default() }]));
|
||||
assert_eq!(r.persona(10).unwrap().game, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_real_change_is_reported_as_one() {
|
||||
let mut r = Roster::default();
|
||||
assert_eq!(r.apply_persona(&persona(vec![named(10, "Alex", 1)])), vec![10]);
|
||||
assert!(r.apply_persona(&persona(vec![named(10, "Alex", 1)])).is_empty(), "the same again is not news");
|
||||
assert_eq!(r.apply_persona(&persona(vec![named(10, "Alex", 3)])), vec![10]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_is_made_fit_to_be_a_conversation() {
|
||||
assert_eq!(clean_name(" Alex\n"), "Alex");
|
||||
assert_eq!(clean_name("A\u{0}l\u{7}ex"), "Alex");
|
||||
assert_eq!(clean_name("日本語 名前"), "日本語 名前");
|
||||
assert_eq!(clean_name("\n\t "), "");
|
||||
let mut r = Roster::default();
|
||||
r.apply_friends_list(&list(false, &[(10, REL_FRIEND)]));
|
||||
r.apply_persona(&persona(vec![named(10, " ", 1)]));
|
||||
assert_eq!(r.persona(10).unwrap().name, None, "a blank name is not a name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn friends_are_called_by_name_and_unnamed_ones_by_their_id() {
|
||||
let mut r = Roster::default();
|
||||
r.apply_friends_list(&list(false, &[(10, REL_FRIEND), (20, REL_FRIEND)]));
|
||||
r.apply_persona(&persona(vec![named(10, "Alex", 1)]));
|
||||
let names = r.conversation_names();
|
||||
assert_eq!(names[&10], "Alex");
|
||||
assert_eq!(names[&20], steamid::text_of(20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_friends_with_the_same_name_do_not_share_a_conversation() {
|
||||
let mut r = Roster::default();
|
||||
r.apply_friends_list(&list(false, &[(10, REL_FRIEND), (20, REL_FRIEND), (30, REL_FRIEND)]));
|
||||
r.apply_persona(&persona(vec![named(10, "Alex", 1), named(20, "alex", 1), named(30, "Sam", 1)]));
|
||||
let names = r.conversation_names();
|
||||
// Case is not what tells people apart, so these two collide...
|
||||
assert_eq!(names[&10], "Alex (10)");
|
||||
assert_eq!(names[&20], "alex (20)");
|
||||
assert_eq!(names[&30], "Sam");
|
||||
let distinct: std::collections::HashSet<_> = names.values().map(|n| n.to_lowercase()).collect();
|
||||
assert_eq!(distinct.len(), 3, "every conversation has a name of its own");
|
||||
// ...and when one of them is renamed the other goes back to plain.
|
||||
r.apply_persona(&persona(vec![named(20, "Robin", 1)]));
|
||||
let names = r.conversation_names();
|
||||
assert_eq!((names[&10].as_str(), names[&20].as_str()), ("Alex", "Robin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_numbers_cannot_be_fooled_by_a_name_that_looks_like_one() {
|
||||
// Somebody calling themselves "Alex (20)" must not collide with the
|
||||
// friend who is made "Alex (20)" by sharing a name.
|
||||
let mut r = Roster::default();
|
||||
r.apply_friends_list(&list(false, &[(10, REL_FRIEND), (20, REL_FRIEND), (30, REL_FRIEND)]));
|
||||
r.apply_persona(&persona(vec![named(10, "Alex", 1), named(20, "Alex", 1), named(30, "Alex (20)", 1)]));
|
||||
let names = r.conversation_names();
|
||||
let distinct: std::collections::HashSet<_> = names.values().map(|n| n.to_lowercase()).collect();
|
||||
assert_eq!(distinct.len(), 3, "{names:?}");
|
||||
}
|
||||
|
||||
/// Whatever people call themselves, no two conversations share a name.
|
||||
/// Every way four friends can be given names from a pool made of exactly
|
||||
/// the strings the decoration produces, which is the worst case there is.
|
||||
#[test]
|
||||
fn however_friends_are_named_no_two_conversations_are_called_the_same() {
|
||||
let pool = ["a", "A", "a (1)", "a (2)", "a [1]", "a [2]", "a (1) [1]"];
|
||||
let mut checked = 0;
|
||||
for combo in 0..pool.len().pow(4) {
|
||||
let mut r = Roster::default();
|
||||
r.apply_friends_list(&list(false, &[(1, REL_FRIEND), (2, REL_FRIEND), (3, REL_FRIEND), (4, REL_FRIEND)]));
|
||||
let mut at = combo;
|
||||
let updates: Vec<PersonaFriend> = (1..=4u32)
|
||||
.map(|account| {
|
||||
let name = pool[at % pool.len()];
|
||||
at /= pool.len();
|
||||
named(account, name, 1)
|
||||
})
|
||||
.collect();
|
||||
r.apply_persona(&persona(updates));
|
||||
let names = r.conversation_names();
|
||||
let distinct: std::collections::HashSet<String> = names.values().map(|n| n.to_lowercase()).collect();
|
||||
assert_eq!(distinct.len(), 4, "combination {combo} gave {names:?}");
|
||||
checked += 1;
|
||||
}
|
||||
assert_eq!(checked, 7 * 7 * 7 * 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn somebody_who_writes_first_gets_a_conversation_without_becoming_a_friend() {
|
||||
let mut r = Roster::default();
|
||||
r.apply_friends_list(&list(false, &[(10, REL_FRIEND)]));
|
||||
assert!(r.note_conversation(77), "new");
|
||||
assert!(!r.note_conversation(77), "not new twice");
|
||||
assert!(!r.note_conversation(10), "a friend already has one");
|
||||
assert_eq!(r.friends(), vec![10]);
|
||||
assert_eq!(r.participants(), vec![10, 77]);
|
||||
r.apply_persona(&persona(vec![named(10, "Alex", 1), named(77, "Alex", 1)]));
|
||||
let names = r.conversation_names();
|
||||
assert_ne!(names[&10].to_lowercase(), names[&77].to_lowercase(), "a stranger sharing a friend's name does not share their conversation");
|
||||
// Once they are a friend they are only counted once.
|
||||
r.apply_friends_list(&list(true, &[(77, REL_FRIEND)]));
|
||||
assert_eq!(r.participants(), vec![10, 77]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_steam_deck_is_known_by_the_kind_of_device_as_the_steam_client_knows_it() {
|
||||
let mut roster = Roster::default();
|
||||
let said = |kind: u32| ClientPersonaState { friends: vec![PersonaFriend { friendid: Some(steamid::steamid_of(10)), gaming_device_type: Some(kind), persona_state: Some(1), ..Default::default() }], ..Default::default() };
|
||||
roster.apply_persona(&said(544));
|
||||
assert!(roster.persona(10).unwrap().on_deck, "544 is a Steam Deck");
|
||||
roster.apply_persona(&said(545));
|
||||
assert_eq!(roster.persona(10).unwrap().device, DEVICE_STEAM_MACHINE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_avatar_is_where_steam_serves_it_and_none_set_is_the_default_picture() {
|
||||
let hash: Vec<u8> = (0..20).collect();
|
||||
assert_eq!(avatar_url(&hash), "https://avatars.steamstatic.com/000102030405060708090a0b0c0d0e0f10111213_full.jpg");
|
||||
assert_eq!(avatar_url(&[0; 20]), "https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg");
|
||||
assert_eq!(avatar_url(&[]), avatar_url(&[0; 20]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
//! Becoming friends and ceasing to be: asking, answering and ending.
|
||||
//!
|
||||
//! On Steam these are two messages. `ClientAddFriend` is both "I would like
|
||||
//! to be your friend" and "yes, I would like that too", depending on whether
|
||||
//! the other has already asked - so accepting a request is adding the person
|
||||
//! who made it. `ClientRemoveFriend` ends a friendship, takes back a request
|
||||
//! we made, and turns down one made to us.
|
||||
//!
|
||||
//! Who is waiting on whom is kept in the roster; what the window is shown of
|
||||
//! it is built here.
|
||||
|
||||
use super::friends::Roster;
|
||||
use super::link::Link;
|
||||
use super::proto::{ClientAddFriend, ClientAddFriendResponse, ClientRemoveFriend};
|
||||
use super::wire::emsg;
|
||||
use super::{eresult, steamid};
|
||||
use crate::state::AppState;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Who somebody means when they type a person into the add box.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Target {
|
||||
/// Somebody's number, however it was written.
|
||||
Id(u64),
|
||||
/// An account name, which Steam looks up itself.
|
||||
Name(String),
|
||||
}
|
||||
|
||||
/// Reads what a person typed: the 17-digit id, the number a friend's "friend
|
||||
/// code" is, the `[U:1:n]` form, a profile address that carries the id, or
|
||||
/// else an account name.
|
||||
pub fn parse_target(text: &str) -> Result<Target, String> {
|
||||
let text = text.trim().trim_end_matches('/');
|
||||
if text.is_empty() {
|
||||
return Err("type who to add: a Steam id, a friend code, or an account name".to_string());
|
||||
}
|
||||
if let Some(inner) = text.strip_prefix("[U:1:").and_then(|t| t.strip_suffix(']')) {
|
||||
return inner.parse::<u32>().map(|n| Target::Id(steamid::steamid_of(n))).map_err(|_| format!("{text} is not a Steam id"));
|
||||
}
|
||||
// An invite link, `s.team/p/<code>/<token>`, or a profile address in the
|
||||
// same code's form: it says which account, and is asked as a request.
|
||||
if text.contains("s.team/p/") || text.contains("/user/") {
|
||||
return super::invites::account_of_link(text).map(|n| Target::Id(steamid::steamid_of(n))).ok_or_else(|| "that is not a Steam invite link".to_string());
|
||||
}
|
||||
// A profile address. Only the numeric form says who it is; a custom name
|
||||
// in one is looked up on the website, which this does not talk to.
|
||||
if let Some((_, rest)) = text.split_once("steamcommunity.com/") {
|
||||
return match rest.split_once('/') {
|
||||
Some(("profiles", id)) => parse_target(id),
|
||||
Some(("id", _)) => Err("that profile address uses a custom name, which moho cannot look up; use the id from the number-form address, or the account name".to_string()),
|
||||
_ => Err("that is not a profile address".to_string()),
|
||||
};
|
||||
}
|
||||
if text.bytes().all(|b| b.is_ascii_digit()) {
|
||||
let n: u64 = text.parse().map_err(|_| format!("{text} is too long to be a Steam id"))?;
|
||||
// A friend code is the account number alone; a full id is that plus
|
||||
// the fixed part for a person.
|
||||
return match steamid::account_of(n) {
|
||||
Some(_) => Ok(Target::Id(n)),
|
||||
None if n <= u64::from(u32::MAX) => Ok(Target::Id(steamid::steamid_of(n as u32))),
|
||||
None => Err(format!("{text} is not a person's Steam id")),
|
||||
};
|
||||
}
|
||||
Ok(Target::Name(text.to_string()))
|
||||
}
|
||||
|
||||
/// A friendship that was begun, or a request that was answered.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Added {
|
||||
pub steamid: u64,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// Asks somebody to be our friend, or says yes to their request.
|
||||
pub async fn add(link: &Link, target: &Target) -> Result<Added, String> {
|
||||
let request = match target {
|
||||
Target::Id(id) => ClientAddFriend { steamid_to_add: Some(*id), accountname_or_email_to_add: None },
|
||||
Target::Name(name) => ClientAddFriend { steamid_to_add: None, accountname_or_email_to_add: Some(name.clone()) },
|
||||
};
|
||||
let answer: ClientAddFriendResponse = link.ask(emsg::CLIENT_ADD_FRIEND, &request).await.map_err(|e| e.to_string())?;
|
||||
let code = answer.eresult.unwrap_or(eresult::FAIL);
|
||||
tracing::info!("steam: asking to add {target:?}: result {code}, id {:?}", answer.steam_id_added);
|
||||
if code != eresult::OK {
|
||||
return Err(refusal_of(code, target));
|
||||
}
|
||||
Ok(Added { steamid: answer.steam_id_added.unwrap_or_default(), name: answer.persona_name_added.filter(|n| !n.is_empty()) })
|
||||
}
|
||||
|
||||
/// What a refusal of an add means to the person who asked.
|
||||
///
|
||||
/// Steam answers a plain failure to a good many things and says nothing more.
|
||||
/// What is known to cause it is said alongside, as what to try rather than as
|
||||
/// the cause, because Steam does not say which it was.
|
||||
fn refusal_of(code: i32, target: &Target) -> String {
|
||||
// What an add means by some answers is not what they mean elsewhere.
|
||||
let words = match code {
|
||||
eresult::NO_MATCH | eresult::ACCOUNT_NOT_FOUND => "Steam found nobody by that name or number".to_string(),
|
||||
eresult::LIMIT_EXCEEDED => "a friends list can only hold so many people, and one of the two is full".to_string(),
|
||||
eresult::ACCESS_DENIED => "this person's settings do not allow a friend request from you".to_string(),
|
||||
other => eresult::describe(other),
|
||||
};
|
||||
if code != eresult::FAIL {
|
||||
return words;
|
||||
}
|
||||
let mut out = format!("{words} (error {code}).");
|
||||
out.push_str(" A new account that has not spent anything on Steam is \"limited\", and Steam does not let a limited account send friend requests - though it can accept one sent to it.");
|
||||
if matches!(target, Target::Name(_)) {
|
||||
out.push_str(" An account name here is the sign-in name, not the name shown to friends; the Steam id or friend code is more reliable.");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Ends a friendship, takes back a request, or turns one down. Steam sends
|
||||
/// no answer to this; the friends list that follows is the answer.
|
||||
pub fn remove(link: &Link, steamid: u64) -> Result<(), String> {
|
||||
steamid::account_of(steamid).ok_or_else(|| "that is not a person's Steam id".to_string())?;
|
||||
link.send(emsg::CLIENT_REMOVE_FRIEND, &ClientRemoveFriend { friendid: Some(steamid) }).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn person(roster: &Roster, account: u32) -> Value {
|
||||
json!({
|
||||
"steamid": steamid::text_of(account),
|
||||
"name": roster.persona(account).and_then(|p| p.name.clone()).filter(|n| !n.is_empty()),
|
||||
"avatarUrl": roster.persona(account).and_then(|p| p.avatar.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
/// What the window is shown: who has asked, and who we have asked.
|
||||
pub fn snapshot(roster: &Roster) -> Value {
|
||||
json!({
|
||||
"incoming": roster.asked_us().into_iter().map(|a| person(roster, a)).collect::<Vec<_>>(),
|
||||
"outgoing": roster.asked_by_us().into_iter().map(|a| person(roster, a)).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Keeps the window's picture of the requests current, and says out loud
|
||||
/// when somebody new has asked. `told` holds who the window has already been
|
||||
/// told about, so a name arriving a moment after the request, or the list
|
||||
/// being sent again, does not announce the same person twice.
|
||||
pub fn publish(state: &AppState, account_id: &str, roster: &Roster, told: &mut HashSet<u32>) {
|
||||
let now = snapshot(roster);
|
||||
if state.runtime.steam_requests(account_id) == now {
|
||||
return;
|
||||
}
|
||||
state.runtime.set_steam_requests(account_id, now.clone());
|
||||
let asking = roster.asked_us();
|
||||
told.retain(|a| asking.contains(a));
|
||||
for account in asking {
|
||||
if told.insert(account) {
|
||||
state.events.emit("steamFriendRequest", json!({ "accountId": account_id, "request": person(roster, account) }));
|
||||
}
|
||||
}
|
||||
state.events.emit("steamFriendRequests", json!({ "accountId": account_id, "requests": now }));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::backend::steam::link::fake::serve;
|
||||
use crate::backend::steam::proto::{ClientFriendsList, FriendsListEntry};
|
||||
use prost::Message;
|
||||
|
||||
/// Steam's answer to an add: its own message, matched to the question by job.
|
||||
fn reply(request: &crate::backend::steam::wire::Packet, body: Vec<u8>) -> crate::backend::steam::wire::Packet {
|
||||
use crate::backend::steam::{proto::Header, wire::Packet};
|
||||
Packet::new(emsg::CLIENT_ADD_FRIEND_RESPONSE, Header { jobid_target: request.header.jobid_source, ..Header::default() }, body)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_person_can_be_named_every_way_steam_writes_them() {
|
||||
let id = steamid::steamid_of(22202);
|
||||
assert_eq!(parse_target("76561197960287930"), Ok(Target::Id(id)));
|
||||
assert_eq!(parse_target(" 76561197960287930\n"), Ok(Target::Id(id)));
|
||||
assert_eq!(parse_target("22202"), Ok(Target::Id(id)), "a friend code is the account number");
|
||||
assert_eq!(parse_target("https://s.team/p/dknq-cpfb/QWERTYUI"), Ok(Target::Id(steamid::steamid_of(664476208))), "an invite link says whose it is");
|
||||
assert!(parse_target("https://s.team/p/not-code/TOKEN").is_err());
|
||||
assert_eq!(parse_target("[U:1:22202]"), Ok(Target::Id(id)));
|
||||
assert_eq!(parse_target("https://steamcommunity.com/profiles/76561197960287930/"), Ok(Target::Id(id)));
|
||||
assert_eq!(parse_target("mohotester"), Ok(Target::Name("mohotester".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_cannot_be_read_is_said_rather_than_guessed_at() {
|
||||
assert!(parse_target("").is_err());
|
||||
assert!(parse_target(" ").is_err());
|
||||
assert!(parse_target("https://steamcommunity.com/id/somebody").unwrap_err().contains("custom name"));
|
||||
assert!(parse_target("https://steamcommunity.com/groups/x").is_err());
|
||||
assert!(parse_target("[U:1:abc]").is_err());
|
||||
// A group's id, which has the digits of an id and is not a person's.
|
||||
assert!(parse_target("103582791429521412").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn adding_by_id_sends_the_id_and_reports_who_was_added() {
|
||||
let seen = std::sync::Arc::new(std::sync::Mutex::new(None));
|
||||
let url = serve(Box::new({
|
||||
let seen = seen.clone();
|
||||
move |packet| {
|
||||
*seen.lock().unwrap() = Some((packet.emsg, ClientAddFriend::decode(packet.body.as_slice()).unwrap()));
|
||||
let body = ClientAddFriendResponse { eresult: Some(1), steam_id_added: Some(steamid::steamid_of(7)), persona_name_added: Some("Seven".into()) };
|
||||
Some(vec![reply(&packet, body.encode_to_vec())])
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
let (link, _told) = crate::backend::steam::link::open_with("test", &url, std::time::Duration::from_secs(5)).await.unwrap();
|
||||
let added = add(&link, &Target::Id(steamid::steamid_of(7))).await.unwrap();
|
||||
assert_eq!(added, Added { steamid: steamid::steamid_of(7), name: Some("Seven".into()) });
|
||||
let (kind, sent) = seen.lock().unwrap().take().unwrap();
|
||||
assert_eq!(kind, emsg::CLIENT_ADD_FRIEND);
|
||||
assert_eq!(sent.steamid_to_add, Some(steamid::steamid_of(7)));
|
||||
assert_eq!(sent.accountname_or_email_to_add, None, "one or the other, never both");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn adding_by_name_sends_the_name() {
|
||||
let seen = std::sync::Arc::new(std::sync::Mutex::new(None));
|
||||
let url = serve(Box::new({
|
||||
let seen = seen.clone();
|
||||
move |packet| {
|
||||
*seen.lock().unwrap() = Some(ClientAddFriend::decode(packet.body.as_slice()).unwrap());
|
||||
let body = ClientAddFriendResponse { eresult: Some(1), steam_id_added: Some(steamid::steamid_of(8)), persona_name_added: None };
|
||||
Some(vec![reply(&packet, body.encode_to_vec())])
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
let (link, _told) = crate::backend::steam::link::open_with("test", &url, std::time::Duration::from_secs(5)).await.unwrap();
|
||||
let added = add(&link, &Target::Name("mohotester".into())).await.unwrap();
|
||||
assert_eq!(added.name, None, "an empty or absent name is not a name");
|
||||
assert_eq!(seen.lock().unwrap().take().unwrap().accountname_or_email_to_add.as_deref(), Some("mohotester"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refusal_is_said_in_words_and_a_missing_result_is_a_failure() {
|
||||
for (code, words) in [(Some(42), "nobody"), (Some(29), "already"), (Some(40), "blocked"), (Some(24), "limited account"), (None, "limited")] {
|
||||
let url = serve(Box::new(move |packet| {
|
||||
let body = ClientAddFriendResponse { eresult: code, ..Default::default() };
|
||||
Some(vec![reply(&packet, body.encode_to_vec())])
|
||||
}))
|
||||
.await;
|
||||
let (link, _told) = crate::backend::steam::link::open_with("test", &url, std::time::Duration::from_secs(5)).await.unwrap();
|
||||
let err = add(&link, &Target::Name("x".into())).await.unwrap_err();
|
||||
assert!(err.contains(words), "{code:?} -> {err}");
|
||||
if code.is_none() {
|
||||
assert!(err.contains("sign-in name"), "a name is not the same as an id: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removing_sends_the_id_and_refuses_what_is_not_a_person() {
|
||||
let seen = std::sync::Arc::new(std::sync::Mutex::new(None));
|
||||
let url = serve(Box::new({
|
||||
let seen = seen.clone();
|
||||
move |packet| {
|
||||
*seen.lock().unwrap() = Some((packet.emsg, ClientRemoveFriend::decode(packet.body.as_slice()).unwrap()));
|
||||
Some(vec![])
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
let (link, _told) = crate::backend::steam::link::open_with("test", &url, std::time::Duration::from_secs(5)).await.unwrap();
|
||||
assert!(remove(&link, 103582791429521412).is_err());
|
||||
assert!(seen.lock().unwrap().is_none(), "nothing was sent for a bad id");
|
||||
remove(&link, steamid::steamid_of(9)).unwrap();
|
||||
for _ in 0..100 {
|
||||
if seen.lock().unwrap().is_some() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
}
|
||||
let (kind, sent) = seen.lock().unwrap().take().unwrap();
|
||||
assert_eq!((kind, sent.friendid), (emsg::CLIENT_REMOVE_FRIEND, Some(steamid::steamid_of(9))));
|
||||
}
|
||||
|
||||
fn roster_with_requests() -> Roster {
|
||||
let mut r = Roster::default();
|
||||
let entry = |a: u32, rel: u32| FriendsListEntry { ulfriendid: Some(steamid::steamid_of(a)), efriendrelationship: Some(rel) };
|
||||
r.apply_friends_list(&ClientFriendsList { bincremental: Some(false), friends: vec![entry(10, 3), entry(20, 2), entry(30, 4)], ..Default::default() });
|
||||
r
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_snapshot_lists_who_asked_and_who_was_asked() {
|
||||
let now = snapshot(&roster_with_requests());
|
||||
assert_eq!(now["incoming"][0]["steamid"], steamid::text_of(20));
|
||||
assert_eq!(now["outgoing"][0]["steamid"], steamid::text_of(30));
|
||||
assert_eq!(now["incoming"].as_array().unwrap().len(), 1, "a friend is not a request");
|
||||
assert!(now["incoming"][0]["name"].is_null(), "not named until Steam says");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn somebody_asking_is_announced_once() {
|
||||
let state = crate::backend::kick::testkit::simulated_daemon("steam-friendship");
|
||||
let mut events = state.events.subscribe();
|
||||
let roster = roster_with_requests();
|
||||
let mut told = HashSet::new();
|
||||
publish(&state, "steam:me", &roster, &mut told);
|
||||
publish(&state, "steam:me", &roster, &mut told);
|
||||
|
||||
let mut asked = 0;
|
||||
let mut lists = 0;
|
||||
while let Ok(event) = events.try_recv() {
|
||||
match event.name {
|
||||
"steamFriendRequest" => asked += 1,
|
||||
"steamFriendRequests" => lists += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!((asked, lists), (1, 1), "the same list again says nothing");
|
||||
assert_eq!(state.runtime.steam_requests("steam:me")["incoming"][0]["steamid"], steamid::text_of(20));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
Header 09ba5600000100100110aef6ffffffffffffff0118da055111000000000000005912000000000000006226467269656e644d65737361676573436c69656e742e496e636f6d696e674d6573736167652331680172026e6f800201
|
||||
Multi 088020120a1f8b08007a6970706564
|
||||
ClientHello 08ad8004
|
||||
ClientHeartBeat 0801
|
||||
ClientLogon 08ad8004180928eb0d3207656e676c69736838cb0140015a050defbeaddea80102f2010a0102036d616368696e65800204880202920307736f6d656f6e65f80501820604484f5354a006858080808020b00601e2061165794a2e726566726573682e746f6b656ef80601
|
||||
ClientLogonResponse 0801181b2d803bb16a3003380b50057207736f6d656f6e65a101ba56000001001001aa01024742d801878080808040f0018980808080808002f8018080808020
|
||||
ClientLogOff
|
||||
ClientLoggedOff 0806
|
||||
ClientAccountInfo 0a08536f6d65204f6e651202474228033808880101
|
||||
FriendsListEntry 09ba560000010010011003
|
||||
ClientFriendsList 0801120b09ba560000010010011003120b090100000001001001100218fa0120022801
|
||||
KeyValue 0a06737461747573120a496e2061206d61746368
|
||||
PersonaFriend 09ba56000001001001100318da0530800450017a08536f6d65204f6e65fa0114000102030405060708090a0b0c0d0e0f10111213e80280e2cfaa06f002e4e2cfaa06f802c8e3cfaa06ba0310436f756e7465722d537472696b652032c103da02000000000000ba04190a0d737465616d5f646973706c6179120823506c6179696e67ba04110a06737461747573120744757374204949880501980502
|
||||
ClientPersonaState 08d208120e09ba5600000100100110017a0141120e09010000000100100110077a0142
|
||||
ClientRequestFriendData 08d20811ba56000001001001110100000001001001
|
||||
ClientChangeStatus 08071208536f6d65204f6e65180120012801300138014001
|
||||
GetCommunityPreferencesRequest
|
||||
CommunityPreferences 1880f7c4d5062001280230003807
|
||||
GetCommunityPreferencesResponse 0a0428013001
|
||||
GetTextFilterWordsRequest
|
||||
TextFilterWords 0a01610a01621201631803
|
||||
GetTextFilterWordsResponse 0a030a0178
|
||||
GetPrivacySettingsRequest
|
||||
PrivacySettings 080310021801200328023001
|
||||
GetPrivacySettingsResponse 0a020802
|
||||
GetParentalSettingsRequest 51ba56000001001001
|
||||
ParentalSettings 09ba560000010010014801500f6803
|
||||
GetParentalSettingsResponse 0a0448015008
|
||||
EnumerateTokensRequest 0801
|
||||
TokenUsageEvent 0881f7c4d506220247422a03454e4732064c6f6e646f6e
|
||||
RefreshTokenDescription 090300000000010000120c6d6f686f206f6e20686f73741882f7c4d506200328014a06080122024742520a080232064c6f6e646f6e
|
||||
EnumerateTokensResponse 0a12090500000000000000120570686f6e652003110500000000000000
|
||||
RevokeRefreshTokenRequest 09030000000001000011ba56000001001001
|
||||
RevokeRefreshTokenResponse
|
||||
ChatRoomState 08818080802012027332180120072009280c300d3a027337400f
|
||||
ChatRoomMember 080818022014300d388780808020388880808020
|
||||
ChatRole 08818080802012027332180a
|
||||
ChatRoomGroupHeaderState 08818080802012027332681470157a037331358201030102038801918080802092010c08818080802012027332180a9a01080881808080203801ca0103733235d00101
|
||||
ChatRoomGroupState 0a4308818080802012027332681470157a037331358201030102038801918080802092010c08818080802012027332180a9a01080881808080203801ca0103733235d001011214080818022014300d3887808080203888808080202084808080202a16088180808020120273321801280c300d3a027337400f3a14080818022014300d388780808020388880808020
|
||||
UserChatRoomState 0881808080201009180a20032802300d3801400f
|
||||
UserChatRoomGroupState 08818080802010091a140881808080201009180a20032802300d3801400f20032802300d38014001
|
||||
ChatGroupSummary 08818080802012027332180a200b2885808080203216088180808020120273321801280c300d3a027337400f380e420273384810501150135a030102036014688d80808020708e80808020708f80808020880118aa0103733231b00101
|
||||
ChatRoomSummaryPair 0a2808818080802010091a140881808080201009180a20032802300d3801400f20032802300d38014001125d08818080802012027332180a200b2885808080203216088180808020120273321801280c300d3a027337400f380e420273384810501150135a030102036014688d80808020708e80808020708f80808020880118aa0103733231b00101
|
||||
GetMyChatRoomGroupsRequest
|
||||
GetMyChatRoomGroupsResponse 0a89010a2808818080802010091a140881808080201009180a20032802300d3801400f20032802300d38014001125d08818080802012027332180a200b2885808080203216088180808020120273321801280c300d3a027337400f380e420273384810501150135a030102036014688d80808020708e80808020708f80808020880118aa0103733231b00101
|
||||
GetChatRoomGroupStateRequest 088180808020
|
||||
GetChatRoomGroupStateResponse 0a8f010a4308818080802012027332681470157a037331358201030102038801918080802092010c08818080802012027332180a9a01080881808080203801ca0103733235d001011214080818022014300d3887808080203888808080202084808080202a16088180808020120273321801280c300d3a027337400f3a14080818022014300d388780808020388880808020
|
||||
SetSessionActiveGroupsRequest 0881808080200882808080201082808080201083808080201806
|
||||
SetSessionActiveGroupsResponse 0a8f010a4308818080802012027332681470157a037331358201030102038801918080802092010c08818080802012027332180a9a01080881808080203801ca0103733235d001011214080818022014300d3887808080203888808080202084808080202a16088180808020120273321801280c300d3a027337400f3a14080818022014300d3887808080203888808080201082808080201083808080201a0c088180808020120408071001
|
||||
ChatSendRequest 0881808080201082808080201a04746578742001
|
||||
ChatSendResponse 0a0273311009180a22027334
|
||||
ChatHistoryRequest 088180808020108280808020180a200b280c300d380e
|
||||
ServerMessage 080212027332180a
|
||||
ChatMessageReaction 080112027332180a2001
|
||||
ChatHistoryMessage 080810091a0474657874200b2a08080212027332180a30013a0a080112027332180a2001
|
||||
ChatHistoryResponse 0a24080810091a0474657874200b2a08080212027332180a30013a0a080112027332180a20012001
|
||||
ChatMentions 08011001180a180c
|
||||
ChatIncoming 08818080802010828080802019ba56000001001001220474657874280c320808011001180a180c380e4208080212027332180a4a02733952037331305a03733131
|
||||
ChatAck 088180808020108280808020180a
|
||||
ChatModifiedMessage 080810091801
|
||||
ChatModified 0881808080201082808080201a06080810091801
|
||||
ChatMemberChange 0881808080201214080818022014300d3887808080203888808080201801
|
||||
ChatHeaderChange 0a4308818080802012027332681470157a037331358201030102038801918080802092010c08818080802012027332180a9a01080881808080203801ca0103733235d00101
|
||||
ChatRoomsChange 0881808080201082808080201a16088180808020120273321801280c300d3a027337400f
|
||||
ChatUserStateChange 088180808020122808818080802010091a140881808080201009180a20032802300d3801400f20032802300d380140011a5d08818080802012027332180a200b2885808080203216088180808020120273321801280c300d3a027337400f380e420273384810501150135a030102036014688d80808020708e80808020708f80808020880118aa0103733231b001012002
|
||||
CreateChatGroupRequest 09ba5600000100100111ba560000010010011a02733321ba56000001001001210100000001001001
|
||||
CreateChatGroupResponse 088180808020128f010a4308818080802012027332681470157a037331358201030102038801918080802092010c08818080802012027332180a9a01080881808080203801ca0103733235d001011214080818022014300d3887808080203888808080202084808080202a16088180808020120273321801280c300d3a027337400f3a14080818022014300d3887808080203888808080201a2808818080802010091a140881808080201009180a20032802300d3801400f20032802300d38014001
|
||||
JoinChatGroupRequest 08818080802012027332188380808020
|
||||
JoinChatGroupResponse 0a8f010a4308818080802012027332681470157a037331358201030102038801918080802092010c08818080802012027332180a9a01080881808080203801ca0103733235d001011214080818022014300d3887808080203888808080202084808080202a16088180808020120273321801280c300d3a027337400f3a14080818022014300d3887808080203888808080201a2808818080802010091a140881808080201009180a20032802300d3801400f20032802300d38014001208480808020280c
|
||||
LeaveChatGroupRequest 088180808020
|
||||
LeaveChatGroupResponse
|
||||
CreateChatRoomRequest 088180808020120273321801
|
||||
CreateChatRoomResponse 0a16088180808020120273321801280c300d3a027337400f
|
||||
DeleteChatRoomRequest 088180808020108280808020
|
||||
DeleteChatRoomResponse
|
||||
RenameChatRoomRequest 0881808080201082808080201a027333
|
||||
RenameChatRoomResponse
|
||||
RenameChatGroupRequest 08818080802012027332
|
||||
RenameChatGroupResponse
|
||||
CreateInviteLinkRequest 0881808080201009188380808020
|
||||
CreateInviteLinkResponse 0a0273311009
|
||||
GetInviteLinksRequest 088180808020
|
||||
InviteLinkInfo 0a02733111ba56000001001001180a208480808020
|
||||
GetInviteLinksResponse 0a150a02733111ba56000001001001180a208480808020
|
||||
GetInviteLinkInfoRequest 0a027331
|
||||
GetInviteLinkInfoResponse 19ba56000001001001200b308680808020425d08818080802012027332180a200b2885808080203216088180808020120273321801280c300d3a027337400f380e420273384810501150135a030102036014688d80808020708e80808020708f80808020880118aa0103733231b001014a2808818080802010091a140881808080201009180a20032802300d3801400f20032802300d3801400150115801
|
||||
DeleteInviteLinkRequest 08818080802012027332
|
||||
DeleteInviteLinkResponse
|
||||
KickChatUserRequest 08818080802011ba560000010010011806
|
||||
KickChatUserResponse
|
||||
BanChatUserRequest 08818080802011ba560000010010011801
|
||||
BanChatUserResponse
|
||||
MuteChatUserRequest 08818080802011ba560000010010011806
|
||||
MuteChatUserResponse
|
||||
DeleteChatMessage 08081009
|
||||
DeleteChatMessagesRequest 0881808080201082808080201a0408081009
|
||||
DeleteChatMessagesResponse
|
||||
ChatReactionRequest 088180808020108280808020180a200b2801320273363801
|
||||
ChatReactionResponse 080a
|
||||
ChatReactionNotice 088180808020108280808020180a200b29ba5600000100100130013a0273374001
|
||||
InviteFriendToChatGroupRequest 08818080802011ba560000010010011883808080202001
|
||||
InviteFriendToChatGroupResponse
|
||||
ChatRoleActions 08818080802010011801200128013001380140014801500158016001
|
||||
SetTaglineRequest 08818080802012027332
|
||||
SetTaglineResponse
|
||||
UserChatGroupPreferences 0803100218012001
|
||||
SetUserPreferencesRequest 08818080802012080803100218012001
|
||||
SetUserPreferencesResponse
|
||||
CreateRoleRequest 08818080802012027332
|
||||
CreateRoleResponse 12080881808080201801
|
||||
RenameRoleRequest 0881808080201082808080201a027333
|
||||
RenameRoleResponse
|
||||
DeleteRoleRequest 088180808020108280808020
|
||||
DeleteRoleResponse
|
||||
GetRoleActionsRequest 088180808020108280808020
|
||||
GetRoleActionsResponse 0a080881808080203801
|
||||
ReplaceRoleActionsRequest 088180808020108280808020220a08828080802020013801
|
||||
ReplaceRoleActionsResponse
|
||||
AddRoleToUserRequest 08818080802018838080802021ba56000001001001
|
||||
AddRoleToUserResponse
|
||||
DeleteRoleFromUserRequest 08818080802018838080802021ba56000001001001
|
||||
DeleteRoleFromUserResponse
|
||||
GetBanListRequest 088180808020
|
||||
BanInfo 08071008180922027334
|
||||
GetBanListResponse 0a0a08071008180922027334
|
||||
SetAvatarRequest 0881808080201203010203
|
||||
SetAvatarResponse
|
||||
ChatRoomNoticeNotification 08818080802011ba5600000100100118012001
|
||||
DismissChatRoomNotice 08818080802011ba560000010010011801
|
||||
ChatRoomMemberNotice 08071001
|
||||
ChatRoomGroupMemberNotices 088180808020120408071001
|
||||
FriendGroup 0807120752616964657273
|
||||
FriendGroupMembership 09ba560000010010011007
|
||||
ClientFriendsGroupsList 080110011a0b08071207526169646572731a0808081204576f726b220b09ba560000010010011007220b0901000000010010011008
|
||||
ClientCreateFriendsGroup 09ba5600000100100112075261696465727319ba56000001001001190100000001001001
|
||||
ClientCreateFriendsGroupResponse 08011007
|
||||
ClientDeleteFriendsGroup 09ba560000010010011007
|
||||
ClientDeleteFriendsGroupResponse 0801
|
||||
ClientManageFriendsGroup 080712075261696465727319ba56000001001001210100000001001001
|
||||
ClientManageFriendsGroupResponse 0801
|
||||
ClientAddFriendToGroup 080711ba56000001001001
|
||||
ClientAddFriendToGroupResponse 0801
|
||||
ClientRemoveFriendFromGroup 080711ba56000001001001
|
||||
ClientRemoveFriendFromGroupResponse 0801
|
||||
ClientAddFriend 09ba560000010010011207736f6d656f6e65
|
||||
ClientAddFriendResponse 081d1101000000010010011a08536f6d65204f6e65
|
||||
ClientRemoveFriend 09ba56000001001001
|
||||
ClientFriendMsg 09ba5600000100100110021a06747970696e6725813bb16a2801
|
||||
ClientFriendMsgIncoming 09ba5600000100100110011801220568656c6c6f2d823bb16a
|
||||
FriendMessagesSendRequest 09ba5600000100100110011a0f6869205b625d74686572655b2f625d20012801300142076162632d313233
|
||||
FriendMessagesSendResponse 0a0f6869205b625d74686572655b2f625d1083f7c4d506180222086869207468657265
|
||||
FriendMessagesIncoming 09ba56000001001001100118012202796f2d843bb16a300338014202796f4801
|
||||
GetRecentMessagesRequest 09ba56000001001001110100000001001001183220012d00f15365300138034080f7c4d5064804
|
||||
PastReaction 0801120c3a737465616d68617070793a18baad011864
|
||||
PastMessage 08baad011085f7c4d5061a0d66726f6d20746865207061737420022a120801120c3a737465616d68617070793a1864
|
||||
GetRecentMessagesResponse 0a0f08baad011085f7c4d5061a016120010a0d08641086f7c4d5061a016220002001
|
||||
GetActiveSessionsRequest 0880f7c4d5061001
|
||||
MessageSession 08641087f7c4d5061883f7c4d50620042801
|
||||
GetActiveSessionsResponse 0a1008641087f7c4d5061883f7c4d50620041088f7c4d506
|
||||
AckMessage 09ba560000010010011089f7c4d506
|
||||
GetEmoticonListRequest
|
||||
EmoticonInfo 0a0a737465616d68617070791003188af7c4d50620092880e2cfaa0630da05
|
||||
GetEmoticonListResponse 0a0e0a0a737465616d686170707910010a0e0a08737465616d73616410022004
|
||||
UpdateMessageReactionRequest 09ba56000001001001108bf7c4d506180220012a0a737465616d68617070793001
|
||||
UpdateMessageReactionResponse 08baad010864
|
||||
MessageReactionNotice 09ba56000001001001108cf7c4d50618012101000000010010012802320b43617453757270726973653801
|
||||
SessionNoticeNotification 09ba5600000100100110011801
|
||||
DismissSessionNotice 0901000000010010011001
|
||||
FriendProfileInfoRequest 09ba56000001001001
|
||||
FriendProfileInfoResponse 08011101000000010010011880daf1eb042208536f6d65204f6e652a0842656c6c65767565320257413a0d556e6974656420537461746573420548656c6c6f4a0841626f7574206d65
|
||||
FriendsSteamLevelsRequest 08baad010864
|
||||
FriendLevel 08baad01102a
|
||||
FriendsSteamLevelsResponse 0a0608baad01102a0a0408641007
|
||||
GetPerFriendPreferencesRequest
|
||||
PerFriendPreferences 0dba560000120542756464791801200228023001380240014802
|
||||
GetPerFriendPreferencesResponse 0a0e0dba560000120542756464792802
|
||||
SetPerFriendPreferencesRequest 0a0a0dba560000120350616c
|
||||
SetPerFriendPreferencesResponse
|
||||
PerFriendPreferencesChanged 0dba560000120c0dba560000120350616c2801
|
||||
FriendNicknameChanged 0dba560000120350616c1801
|
||||
ClientGetEmoticonList
|
||||
EmoticonOwned 0a0a737465616d68617070791003188af7c4d50620092880e2cfaa0630da05
|
||||
StickerOwned 0a0b436174537572707269736510021881e2cfaa0620b803288bf7c4d5063005
|
||||
EffectOwned 0a0946697265776f726b7310011882e2cfaa06200128ba04
|
||||
ClientEmoticonList 0a0e0a0a737465616d68617070791001120f0a0b436174537572707269736510021a0d0a0946697265776f726b732001
|
||||
IgnoreFriendRequest 09ba560000010010011001
|
||||
IgnoreFriendResponse 0805
|
||||
GetPasswordRsaPublicKeyRequest 0a07736f6d656f6e65
|
||||
GetPasswordRsaPublicKeyResponse 0a3043304646454543304646454543304646454543304646454543304646454543304646454543304646454543304646454512063031303030311888ffc1a28c34
|
||||
DeviceDetails 0a046d6f686f100318cb012001280232030908073802
|
||||
AllowedConfirmation 08031210612a2a2a406578616d706c652e636f6d
|
||||
BeginAuthViaCredentialsRequest 0a046d6f686f1207736f6d656f6e651a045a6d39762088ffc1a28c342801300338004206436c69656e744a080a046d6f686f10035205677561726458016003
|
||||
BeginAuthViaCredentialsResponse 08838080808080800212030a0b0c1d0000a040220208032205080412017828baad8180908080880132047765616b3a0968747470733a2f2f7842046d6f7265
|
||||
BeginAuthViaQrRequest 0a046d6f686f10031a060a046d6f686f2206436c69656e74
|
||||
BeginAuthViaQrResponse 088480808080808002121468747470733a2f2f732e7465616d2f712f312f321a02010225000020402a0208023001
|
||||
PollAuthSessionRequest 08838080808080800212030a0b0c190000000000010000
|
||||
PollAuthSessionResponse 088580808080808002121468747470733a2f2f732e7465616d2f712f6e65771a0772656672657368220661636365737328013207736f6d656f6e653a086e65776775617264420968747470733a2f2f61
|
||||
UpdateAuthWithGuardCodeRequest 08838080808080800211ba560000010010011a0541423132432003
|
||||
UpdateAuthWithGuardCodeResponse 3a0968747470733a2f2f61
|
||||
GenerateAccessTokenRequest 0a077265667265736811ba560000010010011801
|
||||
GenerateAccessTokenResponse 0a0661636365737312087265667265736832
|
||||
IpAddress 1210000102030405060708090a0b0c0d0e0f
|
||||
@@ -0,0 +1,388 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Makes golden.txt: every message in proto.rs, as Valve's own definitions
|
||||
encode it.
|
||||
|
||||
proto.rs is written by hand, and a wrong field number or type in a hand-written
|
||||
message is silent: the field just never arrives. This asks the real
|
||||
definitions (the .proto files extracted from the official client) to encode a
|
||||
fully populated message of each kind, and the tests in proto.rs decode those
|
||||
bytes with the hand-written structs and require the very same bytes back. A
|
||||
field the struct has wrong is dropped or fails to decode, and the bytes differ.
|
||||
|
||||
Run it when proto.rs gains a message or Valve changes one:
|
||||
|
||||
python3 make_golden.py /path/to/steam/decomp/protos > golden.txt
|
||||
|
||||
It needs `protoc` and the Python `protobuf` package. It also checks that every
|
||||
field of each struct in proto.rs is filled in here, so adding a field to the
|
||||
Rust and forgetting it here fails loudly instead of leaving it untested.
|
||||
"""
|
||||
import importlib
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
PROTO_DIR = sys.argv[1] if len(sys.argv) > 1 else "/1tb/git/steam/decomp/protos"
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
FILES = [
|
||||
"steammessages_base.proto",
|
||||
"steammessages_clientserver_login.proto",
|
||||
"steammessages_clientserver_friends.proto",
|
||||
"steammessages_friendmessages.steamclient.proto",
|
||||
"steammessages_auth.steamclient.proto",
|
||||
"steammessages_player.steamclient.proto",
|
||||
"steammessages_clientserver_2.proto",
|
||||
"steammessages_parental.steamclient.proto",
|
||||
"steammessages_chat.steamclient.proto",
|
||||
"steammessages_parental_objects.proto",
|
||||
# What those import.
|
||||
"steammessages_unified_base.steamclient.proto",
|
||||
"enums.proto",
|
||||
]
|
||||
|
||||
out = tempfile.mkdtemp()
|
||||
subprocess.run(["protoc", "-I", PROTO_DIR, f"--python_out={out}", *FILES], check=True, capture_output=True)
|
||||
sys.path.insert(0, out)
|
||||
|
||||
base = importlib.import_module("steammessages_base_pb2")
|
||||
login = importlib.import_module("steammessages_clientserver_login_pb2")
|
||||
friends = importlib.import_module("steammessages_clientserver_friends_pb2")
|
||||
fmsg = importlib.import_module("steammessages_friendmessages.steamclient_pb2")
|
||||
auth = importlib.import_module("steammessages_auth.steamclient_pb2")
|
||||
player = importlib.import_module("steammessages_player.steamclient_pb2")
|
||||
cs2 = importlib.import_module("steammessages_clientserver_2_pb2")
|
||||
parental = importlib.import_module("steammessages_parental.steamclient_pb2")
|
||||
parental_objects = importlib.import_module("steammessages_parental_objects_pb2")
|
||||
chat = importlib.import_module("steammessages_chat.steamclient_pb2")
|
||||
|
||||
ID = 76561197960287930 # an account's 64-bit id
|
||||
ID2 = 76561197960265729
|
||||
|
||||
# Rust struct name -> (the real message class, the values to give it).
|
||||
# Every value is something other than the field's default, or it would not be
|
||||
# written to the wire and would prove nothing.
|
||||
CASES = {
|
||||
"Header": (base.CMsgProtoBufHeader, dict(
|
||||
steamid=ID, client_sessionid=-1234, routing_appid=730, jobid_source=17, jobid_target=18,
|
||||
target_job_name="FriendMessagesClient.IncomingMessage#1", eresult=1, error_message="no", realm=1)),
|
||||
"Multi": (base.CMsgMulti, dict(size_unzipped=4096, message_body=b"\x1f\x8b\x08\x00zipped")),
|
||||
"ClientHello": (login.CMsgClientHello, dict(protocol_version=65581)),
|
||||
"ClientHeartBeat": (login.CMsgClientHeartBeat, dict(send_reply=True)),
|
||||
"ClientLogon": (login.CMsgClientLogon, dict(
|
||||
protocol_version=65581, cell_id=9, client_package_version=1771, client_language="english",
|
||||
client_os_type=203, should_remember_password=True, obfuscated_private_ip=dict(v4=0xdeadbeef),
|
||||
qos_level=2, machine_id=b"\x01\x02\x03machine", ui_mode=4, chat_mode=2, account_name="someone",
|
||||
steamguard_dont_remember_computer=True, machine_name="HOST", client_instance_id=2**40 + 5,
|
||||
supports_rate_limit_response=True, access_token="eyJ.refresh.token", gaming_device_type=1)),
|
||||
"ClientLogonResponse": (login.CMsgClientLogonResponse, dict(
|
||||
eresult=1, heartbeat_seconds=27, rtime32_server_time=1790000000, account_flags=3, cell_id=11,
|
||||
eresult_extended=5, vanity_url="someone", client_supplied_steamid=ID, ip_country_code="GB",
|
||||
client_instance_id=2**41 + 7, token_id=2**50 + 9, family_group_id=2**33)),
|
||||
"ClientLogOff": (login.CMsgClientLogOff, dict()),
|
||||
"ClientLoggedOff": (login.CMsgClientLoggedOff, dict(eresult=6)),
|
||||
"ClientAccountInfo": (login.CMsgClientAccountInfo, dict(
|
||||
persona_name="Some One", ip_country="GB", count_authed_computers=3, account_flags=8, two_factor_state=1)),
|
||||
"FriendsListEntry": (friends.CMsgClientFriendsList.Friend, dict(ulfriendid=ID, efriendrelationship=3)),
|
||||
"ClientFriendsList": (friends.CMsgClientFriendsList, dict(
|
||||
bincremental=True, friends=[dict(ulfriendid=ID, efriendrelationship=3), dict(ulfriendid=ID2, efriendrelationship=2)],
|
||||
max_friend_count=250, active_friend_count=2, friends_limit_hit=True)),
|
||||
"KeyValue": (friends.CMsgClientPersonaState.Friend.KV, dict(key="status", value="In a match")),
|
||||
"PersonaFriend": (friends.CMsgClientPersonaState.Friend, dict(
|
||||
friendid=ID, persona_state=3, game_played_app_id=730, persona_state_flags=512, persona_set_by_user=True,
|
||||
player_name="Some One", avatar_hash=bytes(range(20)), last_logoff=1700000000, last_logon=1700000100,
|
||||
last_seen_online=1700000200, game_name="Counter-Strike 2", gameid=730,
|
||||
rich_presence=[dict(key="steam_display", value="#Playing"), dict(key="status", value="Dust II")],
|
||||
on_steam_deck=True, gaming_device_type=2)),
|
||||
"ClientPersonaState": (friends.CMsgClientPersonaState, dict(
|
||||
status_flags=1106, friends=[dict(friendid=ID, persona_state=1, player_name="A"), dict(friendid=ID2, persona_state=7, player_name="B")])),
|
||||
"ClientRequestFriendData": (friends.CMsgClientRequestFriendData, dict(persona_state_requested=1106, friends=[ID, ID2])),
|
||||
"ClientChangeStatus": (friends.CMsgClientChangeStatus, dict(
|
||||
persona_state=7, player_name="Some One", is_auto_generated_name=True, high_priority=True,
|
||||
persona_set_by_user=True, persona_state_flags=1, need_persona_response=True, is_client_idle=True)),
|
||||
"GetCommunityPreferencesRequest": (player.CPlayer_GetCommunityPreferences_Request, dict()),
|
||||
"CommunityPreferences": (player.CPlayer_CommunityPreferences, dict(
|
||||
timestamp_updated=1790000000, parenthesize_nicknames=True, text_filter_setting=2, text_filter_ignore_friends=False, text_filter_words_revision=7)),
|
||||
"GetCommunityPreferencesResponse": (player.CPlayer_GetCommunityPreferences_Response, dict(preferences=dict(text_filter_setting=1, text_filter_ignore_friends=True))),
|
||||
"GetTextFilterWordsRequest": (player.CPlayer_GetTextFilterWords_Request, dict()),
|
||||
"TextFilterWords": (player.CPlayer_TextFilterWords, dict(text_filter_custom_banned_words=["a", "b"], text_filter_custom_clean_words=["c"], text_filter_words_revision=3)),
|
||||
"GetTextFilterWordsResponse": (player.CPlayer_GetTextFilterWords_Response, dict(words=dict(text_filter_custom_banned_words=["x"]))),
|
||||
"GetPrivacySettingsRequest": (player.CPlayer_GetPrivacySettings_Request, dict()),
|
||||
"PrivacySettings": (player.CPrivacySettings, dict(
|
||||
privacy_state=3, privacy_state_inventory=2, privacy_state_gifts=1, privacy_state_ownedgames=3, privacy_state_playtime=2, privacy_state_friendslist=1)),
|
||||
"GetPrivacySettingsResponse": (player.CPlayer_GetPrivacySettings_Response, dict(privacy_settings=dict(privacy_state=2))),
|
||||
"GetParentalSettingsRequest": (parental.CParental_GetParentalSettings_Request, dict(steamid=ID)),
|
||||
"ParentalSettings": (parental_objects.ParentalSettings, dict(steamid=ID, is_enabled=True, enabled_features=15, temporary_enabled_features=3)),
|
||||
"GetParentalSettingsResponse": (parental.CParental_GetParentalSettings_Response, dict(settings=dict(is_enabled=True, enabled_features=8))),
|
||||
"EnumerateTokensRequest": (auth.CAuthentication_RefreshToken_Enumerate_Request, dict(include_revoked=True)),
|
||||
"TokenUsageEvent": (auth.CAuthentication_RefreshToken_Enumerate_Response.TokenUsageEvent, dict(time=1790000001, country="GB", state="ENG", city="London")),
|
||||
"RefreshTokenDescription": (auth.CAuthentication_RefreshToken_Enumerate_Response.RefreshTokenDescription, dict(
|
||||
token_id=2**40 + 3, token_description="moho on host", time_updated=1790000002, platform_type=3, logged_in=True,
|
||||
first_seen=dict(time=1, country="GB"), last_seen=dict(time=2, city="London"))),
|
||||
"EnumerateTokensResponse": (auth.CAuthentication_RefreshToken_Enumerate_Response, dict(
|
||||
refresh_tokens=[dict(token_id=5, token_description="phone", platform_type=3)], requesting_token=5)),
|
||||
"RevokeRefreshTokenRequest": (auth.CAuthentication_RefreshToken_Revoke_Request, dict(token_id=2**40 + 3, steamid=ID)),
|
||||
"RevokeRefreshTokenResponse": (auth.CAuthentication_RefreshToken_Revoke_Response, dict()),
|
||||
"ChatRoomState": (chat.CChatRoomState, dict(chat_id=8589934593, chat_name='s2', voice_allowed=True, members_in_voice=[7, 9], time_last_message=12, sort_order=13, last_message='s7', accountid_last_message=15)),
|
||||
"ChatRoomMember": (chat.CChatRoomMember, dict(accountid=8, state=2, rank=20, time_kick_expire=13, role_ids=[8589934599, 8589934600])),
|
||||
"ChatRole": (chat.CChatRole, dict(role_id=8589934593, name='s2', ordinal=10)),
|
||||
"ChatRoomGroupHeaderState": (chat.CChatRoomGroupHeaderState, dict(chat_group_id=8589934593, chat_name='s2', clanid=20, accountid_owner=21, tagline='s15', avatar_sha=b'\x01\x02\x03', default_role_id=8589934609, roles=[dict(role_id=8589934593, name='s2', ordinal=10)], role_actions=[dict(role_id=8589934593, can_chat=True)], avatar_ugc_url='s25', disabled=True)),
|
||||
"ChatRoomGroupState": (chat.CChatRoomGroupState, dict(header_state=dict(chat_group_id=8589934593, chat_name='s2', clanid=20, accountid_owner=21, tagline='s15', avatar_sha=b'\x01\x02\x03', default_role_id=8589934609, roles=[dict(role_id=8589934593, name='s2', ordinal=10)], role_actions=[dict(role_id=8589934593, can_chat=True)], avatar_ugc_url='s25', disabled=True), members=[dict(accountid=8, state=2, rank=20, time_kick_expire=13, role_ids=[8589934599, 8589934600])], default_chat_id=8589934596, chat_rooms=[dict(chat_id=8589934593, chat_name='s2', voice_allowed=True, time_last_message=12, sort_order=13, last_message='s7', accountid_last_message=15)], kicked=[dict(accountid=8, state=2, rank=20, time_kick_expire=13, role_ids=[8589934599, 8589934600])])),
|
||||
"UserChatRoomState": (chat.CUserChatRoomState, dict(chat_id=8589934593, time_joined=9, time_last_ack=10, desktop_notification_level=3, mobile_notification_level=2, time_last_mention=13, unread_indicator_muted=True, time_first_unread=15)),
|
||||
"UserChatRoomGroupState": (chat.CUserChatRoomGroupState, dict(chat_group_id=8589934593, time_joined=9, user_chat_room_state=[dict(chat_id=8589934593, time_joined=9, time_last_ack=10, desktop_notification_level=3, mobile_notification_level=2, time_last_mention=13, unread_indicator_muted=True, time_first_unread=15)], desktop_notification_level=3, mobile_notification_level=2, time_last_group_ack=13, unread_indicator_muted=True, direct_messages_allowed=True)),
|
||||
"ChatGroupSummary": (chat.CChatRoom_GetChatRoomGroupSummary_Response, dict(chat_group_id=8589934593, chat_group_name='s2', active_member_count=10, active_voice_member_count=11, default_chat_id=8589934597, chat_rooms=[dict(chat_id=8589934593, chat_name='s2', voice_allowed=True, time_last_message=12, sort_order=13, last_message='s7', accountid_last_message=15)], clanid=14, chat_group_tagline='s8', accountid_owner=16, top_members=[17, 19], chat_group_avatar_sha=b'\x01\x02\x03', rank=20, default_role_id=8589934605, role_ids=[8589934606, 8589934607], appid=24, avatar_ugc_url='s21', disabled=True)),
|
||||
"ChatRoomSummaryPair": (chat.CChatRoomSummaryPair, dict(user_chat_group_state=dict(chat_group_id=8589934593, time_joined=9, user_chat_room_state=[dict(chat_id=8589934593, time_joined=9, time_last_ack=10, desktop_notification_level=3, mobile_notification_level=2, time_last_mention=13, unread_indicator_muted=True, time_first_unread=15)], desktop_notification_level=3, mobile_notification_level=2, time_last_group_ack=13, unread_indicator_muted=True, direct_messages_allowed=True), group_summary=dict(chat_group_id=8589934593, chat_group_name='s2', active_member_count=10, active_voice_member_count=11, default_chat_id=8589934597, chat_rooms=[dict(chat_id=8589934593, chat_name='s2', voice_allowed=True, time_last_message=12, sort_order=13, last_message='s7', accountid_last_message=15)], clanid=14, chat_group_tagline='s8', accountid_owner=16, top_members=[17, 19], chat_group_avatar_sha=b'\x01\x02\x03', rank=20, default_role_id=8589934605, role_ids=[8589934606, 8589934607], appid=24, avatar_ugc_url='s21', disabled=True))),
|
||||
"GetMyChatRoomGroupsRequest": (chat.CChatRoom_GetMyChatRoomGroups_Request, dict()),
|
||||
"GetMyChatRoomGroupsResponse": (chat.CChatRoom_GetMyChatRoomGroups_Response, dict(chat_room_groups=[dict(user_chat_group_state=dict(chat_group_id=8589934593, time_joined=9, user_chat_room_state=[dict(chat_id=8589934593, time_joined=9, time_last_ack=10, desktop_notification_level=3, mobile_notification_level=2, time_last_mention=13, unread_indicator_muted=True, time_first_unread=15)], desktop_notification_level=3, mobile_notification_level=2, time_last_group_ack=13, unread_indicator_muted=True, direct_messages_allowed=True), group_summary=dict(chat_group_id=8589934593, chat_group_name='s2', active_member_count=10, active_voice_member_count=11, default_chat_id=8589934597, chat_rooms=[dict(chat_id=8589934593, chat_name='s2', voice_allowed=True, time_last_message=12, sort_order=13, last_message='s7', accountid_last_message=15)], clanid=14, chat_group_tagline='s8', accountid_owner=16, top_members=[17, 19], chat_group_avatar_sha=b'\x01\x02\x03', rank=20, default_role_id=8589934605, role_ids=[8589934606, 8589934607], appid=24, avatar_ugc_url='s21', disabled=True))])),
|
||||
"GetChatRoomGroupStateRequest": (chat.CChatRoom_GetChatRoomGroupState_Request, dict(chat_group_id=8589934593)),
|
||||
"GetChatRoomGroupStateResponse": (chat.CChatRoom_GetChatRoomGroupState_Response, dict(state=dict(header_state=dict(chat_group_id=8589934593, chat_name='s2', clanid=20, accountid_owner=21, tagline='s15', avatar_sha=b'\x01\x02\x03', default_role_id=8589934609, roles=[dict(role_id=8589934593, name='s2', ordinal=10)], role_actions=[dict(role_id=8589934593, can_chat=True)], avatar_ugc_url='s25', disabled=True), members=[dict(accountid=8, state=2, rank=20, time_kick_expire=13, role_ids=[8589934599, 8589934600])], default_chat_id=8589934596, chat_rooms=[dict(chat_id=8589934593, chat_name='s2', voice_allowed=True, time_last_message=12, sort_order=13, last_message='s7', accountid_last_message=15)], kicked=[dict(accountid=8, state=2, rank=20, time_kick_expire=13, role_ids=[8589934599, 8589934600])]))),
|
||||
"SetSessionActiveGroupsRequest": (chat.CChatRoom_SetSessionActiveChatRoomGroups_Request, dict(chat_group_ids=[8589934593, 8589934594], chat_groups_data_requested=[8589934594, 8589934595], virtualize_members_threshold=6)),
|
||||
"SetSessionActiveGroupsResponse": (chat.CChatRoom_SetSessionActiveChatRoomGroups_Response, dict(chat_group_notices=[dict(chat_group_id=8589934593, member_notices=[dict(accountid=7, notice_type=1)])], chat_states=[dict(header_state=dict(chat_group_id=8589934593, chat_name='s2', clanid=20, accountid_owner=21, tagline='s15', avatar_sha=b'\x01\x02\x03', default_role_id=8589934609, roles=[dict(role_id=8589934593, name='s2', ordinal=10)], role_actions=[dict(role_id=8589934593, can_chat=True)], avatar_ugc_url='s25', disabled=True), members=[dict(accountid=8, state=2, rank=20, time_kick_expire=13, role_ids=[8589934599, 8589934600])], default_chat_id=8589934596, chat_rooms=[dict(chat_id=8589934593, chat_name='s2', voice_allowed=True, time_last_message=12, sort_order=13, last_message='s7', accountid_last_message=15)], kicked=[dict(accountid=8, state=2, rank=20, time_kick_expire=13, role_ids=[8589934599, 8589934600])])], virtualize_members_chat_group_ids=[8589934594, 8589934595])),
|
||||
"ChatSendRequest": (chat.CChatRoom_SendChatMessage_Request, dict(chat_group_id=8589934593, chat_id=8589934594, message='text', echo_to_sender=True)),
|
||||
"ChatSendResponse": (chat.CChatRoom_SendChatMessage_Response, dict(modified_message='s1', server_timestamp=9, ordinal=10, message_without_bb_code='s4')),
|
||||
"ChatHistoryRequest": (chat.CChatRoom_GetMessageHistory_Request, dict(chat_group_id=8589934593, chat_id=8589934594, last_time=10, last_ordinal=11, start_time=12, start_ordinal=13, max_count=14)),
|
||||
"ServerMessage": (chat.ServerMessage, dict(message=2, string_param='s2', accountid_param=10)),
|
||||
"ChatMessageReaction": (chat.CChatRoom_GetMessageHistory_Response.ChatMessage.MessageReaction, dict(reaction_type=1, reaction='s2', num_reactors=10, has_user_reacted=True)),
|
||||
"ChatHistoryMessage": (chat.CChatRoom_GetMessageHistory_Response.ChatMessage, dict(sender=8, server_timestamp=9, message='text', ordinal=11, server_message=dict(message=2, string_param='s2', accountid_param=10), deleted=True, reactions=[dict(reaction_type=1, reaction='s2', num_reactors=10, has_user_reacted=True)])),
|
||||
"ChatHistoryResponse": (chat.CChatRoom_GetMessageHistory_Response, dict(messages=[dict(sender=8, server_timestamp=9, message='text', ordinal=11, server_message=dict(message=2, string_param='s2', accountid_param=10), deleted=True, reactions=[dict(reaction_type=1, reaction='s2', num_reactors=10, has_user_reacted=True)])], more_available=True)),
|
||||
"ChatMentions": (chat.CChatMentions, dict(mention_all=True, mention_here=True, mention_accountids=[10, 12])),
|
||||
"ChatIncoming": (chat.CChatRoom_IncomingChatMessage_Notification, dict(chat_group_id=8589934593, chat_id=8589934594, steamid_sender=ID, message='text', timestamp=12, mentions=dict(mention_all=True, mention_here=True, mention_accountids=[10, 12]), ordinal=14, server_message=dict(message=2, string_param='s2', accountid_param=10), message_no_bbcode='s9', chat_name='s10', notification_key='s11')),
|
||||
"ChatAck": (chat.CChatRoom_AckChatMessage_Notification, dict(chat_group_id=8589934593, chat_id=8589934594, timestamp=10)),
|
||||
"ChatModifiedMessage": (chat.CChatRoom_ChatMessageModified_Notification.ChatMessage, dict(server_timestamp=8, ordinal=9, deleted=True)),
|
||||
"ChatModified": (chat.CChatRoom_ChatMessageModified_Notification, dict(chat_group_id=8589934593, chat_id=8589934594, messages=[dict(server_timestamp=8, ordinal=9, deleted=True)])),
|
||||
"ChatMemberChange": (chat.CChatRoom_MemberStateChange_Notification, dict(chat_group_id=8589934593, member=dict(accountid=8, state=2, rank=20, time_kick_expire=13, role_ids=[8589934599, 8589934600]), change=1)),
|
||||
"ChatHeaderChange": (chat.CChatRoom_ChatRoomHeaderState_Notification, dict(header_state=dict(chat_group_id=8589934593, chat_name='s2', clanid=20, accountid_owner=21, tagline='s15', avatar_sha=b'\x01\x02\x03', default_role_id=8589934609, roles=[dict(role_id=8589934593, name='s2', ordinal=10)], role_actions=[dict(role_id=8589934593, can_chat=True)], avatar_ugc_url='s25', disabled=True))),
|
||||
"ChatRoomsChange": (chat.CChatRoom_ChatRoomGroupRoomsChange_Notification, dict(chat_group_id=8589934593, default_chat_id=8589934594, chat_rooms=[dict(chat_id=8589934593, chat_name='s2', voice_allowed=True, time_last_message=12, sort_order=13, last_message='s7', accountid_last_message=15)])),
|
||||
"ChatUserStateChange": (chat.ChatRoomClient_NotifyChatGroupUserStateChanged_Notification, dict(chat_group_id=8589934593, user_chat_group_state=dict(chat_group_id=8589934593, time_joined=9, user_chat_room_state=[dict(chat_id=8589934593, time_joined=9, time_last_ack=10, desktop_notification_level=3, mobile_notification_level=2, time_last_mention=13, unread_indicator_muted=True, time_first_unread=15)], desktop_notification_level=3, mobile_notification_level=2, time_last_group_ack=13, unread_indicator_muted=True, direct_messages_allowed=True), group_summary=dict(chat_group_id=8589934593, chat_group_name='s2', active_member_count=10, active_voice_member_count=11, default_chat_id=8589934597, chat_rooms=[dict(chat_id=8589934593, chat_name='s2', voice_allowed=True, time_last_message=12, sort_order=13, last_message='s7', accountid_last_message=15)], clanid=14, chat_group_tagline='s8', accountid_owner=16, top_members=[17, 19], chat_group_avatar_sha=b'\x01\x02\x03', rank=20, default_role_id=8589934605, role_ids=[8589934606, 8589934607], appid=24, avatar_ugc_url='s21', disabled=True), user_action=2)),
|
||||
"CreateChatGroupRequest": (chat.CChatRoom_CreateChatRoomGroup_Request, dict(steamid_partner=ID, steamid_invited=ID, name='s3', steamid_invitees=[ID, ID2])),
|
||||
"CreateChatGroupResponse": (chat.CChatRoom_CreateChatRoomGroup_Response, dict(chat_group_id=8589934593, state=dict(header_state=dict(chat_group_id=8589934593, chat_name='s2', clanid=20, accountid_owner=21, tagline='s15', avatar_sha=b'\x01\x02\x03', default_role_id=8589934609, roles=[dict(role_id=8589934593, name='s2', ordinal=10)], role_actions=[dict(role_id=8589934593, can_chat=True)], avatar_ugc_url='s25', disabled=True), members=[dict(accountid=8, state=2, rank=20, time_kick_expire=13, role_ids=[8589934599, 8589934600])], default_chat_id=8589934596, chat_rooms=[dict(chat_id=8589934593, chat_name='s2', voice_allowed=True, time_last_message=12, sort_order=13, last_message='s7', accountid_last_message=15)], kicked=[dict(accountid=8, state=2, rank=20, time_kick_expire=13, role_ids=[8589934599, 8589934600])]), user_chat_state=dict(chat_group_id=8589934593, time_joined=9, user_chat_room_state=[dict(chat_id=8589934593, time_joined=9, time_last_ack=10, desktop_notification_level=3, mobile_notification_level=2, time_last_mention=13, unread_indicator_muted=True, time_first_unread=15)], desktop_notification_level=3, mobile_notification_level=2, time_last_group_ack=13, unread_indicator_muted=True, direct_messages_allowed=True))),
|
||||
"JoinChatGroupRequest": (chat.CChatRoom_JoinChatRoomGroup_Request, dict(chat_group_id=8589934593, invite_code='s2', chat_id=8589934595)),
|
||||
"JoinChatGroupResponse": (chat.CChatRoom_JoinChatRoomGroup_Response, dict(state=dict(header_state=dict(chat_group_id=8589934593, chat_name='s2', clanid=20, accountid_owner=21, tagline='s15', avatar_sha=b'\x01\x02\x03', default_role_id=8589934609, roles=[dict(role_id=8589934593, name='s2', ordinal=10)], role_actions=[dict(role_id=8589934593, can_chat=True)], avatar_ugc_url='s25', disabled=True), members=[dict(accountid=8, state=2, rank=20, time_kick_expire=13, role_ids=[8589934599, 8589934600])], default_chat_id=8589934596, chat_rooms=[dict(chat_id=8589934593, chat_name='s2', voice_allowed=True, time_last_message=12, sort_order=13, last_message='s7', accountid_last_message=15)], kicked=[dict(accountid=8, state=2, rank=20, time_kick_expire=13, role_ids=[8589934599, 8589934600])]), user_chat_state=dict(chat_group_id=8589934593, time_joined=9, user_chat_room_state=[dict(chat_id=8589934593, time_joined=9, time_last_ack=10, desktop_notification_level=3, mobile_notification_level=2, time_last_mention=13, unread_indicator_muted=True, time_first_unread=15)], desktop_notification_level=3, mobile_notification_level=2, time_last_group_ack=13, unread_indicator_muted=True, direct_messages_allowed=True), join_chat_id=8589934596, time_expire=12)),
|
||||
"LeaveChatGroupRequest": (chat.CChatRoom_LeaveChatRoomGroup_Request, dict(chat_group_id=8589934593)),
|
||||
"LeaveChatGroupResponse": (chat.CChatRoom_LeaveChatRoomGroup_Response, dict()),
|
||||
"CreateChatRoomRequest": (chat.CChatRoom_CreateChatRoom_Request, dict(chat_group_id=8589934593, name='s2', allow_voice=True)),
|
||||
"CreateChatRoomResponse": (chat.CChatRoom_CreateChatRoom_Response, dict(chat_room=dict(chat_id=8589934593, chat_name='s2', voice_allowed=True, time_last_message=12, sort_order=13, last_message='s7', accountid_last_message=15))),
|
||||
"DeleteChatRoomRequest": (chat.CChatRoom_DeleteChatRoom_Request, dict(chat_group_id=8589934593, chat_id=8589934594)),
|
||||
"DeleteChatRoomResponse": (chat.CChatRoom_DeleteChatRoom_Response, dict()),
|
||||
"RenameChatRoomRequest": (chat.CChatRoom_RenameChatRoom_Request, dict(chat_group_id=8589934593, chat_id=8589934594, name='s3')),
|
||||
"RenameChatRoomResponse": (chat.CChatRoom_RenameChatRoom_Response, dict()),
|
||||
"RenameChatGroupRequest": (chat.CChatRoom_RenameChatRoomGroup_Request, dict(chat_group_id=8589934593, name='s2')),
|
||||
"RenameChatGroupResponse": (chat.CChatRoom_RenameChatRoomGroup_Response, dict()),
|
||||
"CreateInviteLinkRequest": (chat.CChatRoom_CreateInviteLink_Request, dict(chat_group_id=8589934593, seconds_valid=9, chat_id=8589934595)),
|
||||
"CreateInviteLinkResponse": (chat.CChatRoom_CreateInviteLink_Response, dict(invite_code='s1', seconds_valid=9)),
|
||||
"GetInviteLinksRequest": (chat.CChatRoom_GetInviteLinksForGroup_Request, dict(chat_group_id=8589934593)),
|
||||
"InviteLinkInfo": (chat.CChatRoom_GetInviteLinksForGroup_Response.LinkInfo, dict(invite_code='s1', steamid_creator=ID, time_expires=10, chat_id=8589934596)),
|
||||
"GetInviteLinksResponse": (chat.CChatRoom_GetInviteLinksForGroup_Response, dict(invite_links=[dict(invite_code='s1', steamid_creator=ID, time_expires=10, chat_id=8589934596)])),
|
||||
"GetInviteLinkInfoRequest": (chat.CChatRoom_GetInviteLinkInfo_Request, dict(invite_code='s1')),
|
||||
"GetInviteLinkInfoResponse": (chat.CChatRoom_GetInviteLinkInfo_Response, dict(steamid_sender=ID, time_expires=11, chat_id=8589934598, group_summary=dict(chat_group_id=8589934593, chat_group_name='s2', active_member_count=10, active_voice_member_count=11, default_chat_id=8589934597, chat_rooms=[dict(chat_id=8589934593, chat_name='s2', voice_allowed=True, time_last_message=12, sort_order=13, last_message='s7', accountid_last_message=15)], clanid=14, chat_group_tagline='s8', accountid_owner=16, top_members=[17, 19], chat_group_avatar_sha=b'\x01\x02\x03', rank=20, default_role_id=8589934605, role_ids=[8589934606, 8589934607], appid=24, avatar_ugc_url='s21', disabled=True), user_chat_group_state=dict(chat_group_id=8589934593, time_joined=9, user_chat_room_state=[dict(chat_id=8589934593, time_joined=9, time_last_ack=10, desktop_notification_level=3, mobile_notification_level=2, time_last_mention=13, unread_indicator_muted=True, time_first_unread=15)], desktop_notification_level=3, mobile_notification_level=2, time_last_group_ack=13, unread_indicator_muted=True, direct_messages_allowed=True), time_kick_expire=17, banned=True)),
|
||||
"DeleteInviteLinkRequest": (chat.CChatRoom_DeleteInviteLink_Request, dict(chat_group_id=8589934593, invite_code='s2')),
|
||||
"DeleteInviteLinkResponse": (chat.CChatRoom_DeleteInviteLink_Response, dict()),
|
||||
"KickChatUserRequest": (chat.CChatRoom_KickUser_Request, dict(chat_group_id=8589934593, steamid=ID, expiration=6)),
|
||||
"KickChatUserResponse": (chat.CChatRoom_KickUser_Response, dict()),
|
||||
"BanChatUserRequest": (chat.CChatRoom_SetUserBanState_Request, dict(chat_group_id=8589934593, steamid=ID, ban_state=True)),
|
||||
"BanChatUserResponse": (chat.CChatRoom_SetUserBanState_Response, dict()),
|
||||
"MuteChatUserRequest": (chat.CChatRoom_MuteUser_Request, dict(chat_group_id=8589934593, steamid=ID, expiration=6)),
|
||||
"MuteChatUserResponse": (chat.CChatRoom_MuteUser_Response, dict()),
|
||||
"DeleteChatMessage": (chat.CChatRoom_DeleteChatMessages_Request.Message, dict(server_timestamp=8, ordinal=9)),
|
||||
"DeleteChatMessagesRequest": (chat.CChatRoom_DeleteChatMessages_Request, dict(chat_group_id=8589934593, chat_id=8589934594, messages=[dict(server_timestamp=8, ordinal=9)])),
|
||||
"DeleteChatMessagesResponse": (chat.CChatRoom_DeleteChatMessages_Response, dict()),
|
||||
"ChatReactionRequest": (chat.CChatRoom_UpdateMessageReaction_Request, dict(chat_group_id=8589934593, chat_id=8589934594, server_timestamp=10, ordinal=11, reaction_type=1, reaction='s6', is_add=True)),
|
||||
"ChatReactionResponse": (chat.CChatRoom_UpdateMessageReaction_Response, dict(num_reactors=10)),
|
||||
"ChatReactionNotice": (chat.CChatRoom_MessageReaction_Notification, dict(chat_group_id=8589934593, chat_id=8589934594, server_timestamp=10, ordinal=11, reactor=ID, reaction_type=1, reaction='s7', is_add=True)),
|
||||
"InviteFriendToChatGroupRequest": (chat.CChatRoom_InviteFriendToChatRoomGroup_Request, dict(chat_group_id=8589934593, steamid=ID, chat_id=8589934595, skip_friendsui_check=True)),
|
||||
"InviteFriendToChatGroupResponse": (chat.CChatRoom_InviteFriendToChatRoomGroup_Response, dict()),
|
||||
"ChatRoleActions": (chat.CChatRoleActions, dict(role_id=8589934593, can_create_rename_delete_channel=True, can_kick=True, can_ban=True, can_invite=True, can_change_tagline_avatar_name=True, can_chat=True, can_view_history=True, can_change_group_roles=True, can_change_user_roles=True, can_mention_all=True, can_set_watching_broadcast=True)),
|
||||
"SetTaglineRequest": (chat.CChatRoom_SetChatRoomGroupTagline_Request, dict(chat_group_id=8589934593, tagline='s2')),
|
||||
"SetTaglineResponse": (chat.CChatRoom_SetChatRoomGroupTagline_Response, dict()),
|
||||
"UserChatGroupPreferences": (chat.CChatRoom_SetUserChatGroupPreferences_Request.ChatGroupPreferences, dict(desktop_notification_level=3, mobile_notification_level=2, unread_indicator_muted=True, direct_messages_allowed=True)),
|
||||
"SetUserPreferencesRequest": (chat.CChatRoom_SetUserChatGroupPreferences_Request, dict(chat_group_id=8589934593, chat_group_preferences=dict(desktop_notification_level=3, mobile_notification_level=2, unread_indicator_muted=True, direct_messages_allowed=True))),
|
||||
"SetUserPreferencesResponse": (chat.CChatRoom_SetUserChatGroupPreferences_Response, dict()),
|
||||
"CreateRoleRequest": (chat.CChatRoom_CreateRole_Request, dict(chat_group_id=8589934593, name='s2')),
|
||||
"CreateRoleResponse": (chat.CChatRoom_CreateRole_Response, dict(actions=dict(role_id=8589934593, can_kick=True))),
|
||||
"RenameRoleRequest": (chat.CChatRoom_RenameRole_Request, dict(chat_group_id=8589934593, role_id=8589934594, name='s3')),
|
||||
"RenameRoleResponse": (chat.CChatRoom_RenameRole_Response, dict()),
|
||||
"DeleteRoleRequest": (chat.CChatRoom_DeleteRole_Request, dict(chat_group_id=8589934593, role_id=8589934594)),
|
||||
"DeleteRoleResponse": (chat.CChatRoom_DeleteRole_Response, dict()),
|
||||
"GetRoleActionsRequest": (chat.CChatRoom_GetRoleActions_Request, dict(chat_group_id=8589934593, role_id=8589934594)),
|
||||
"GetRoleActionsResponse": (chat.CChatRoom_GetRoleActions_Response, dict(actions=[dict(role_id=8589934593, can_chat=True)])),
|
||||
"ReplaceRoleActionsRequest": (chat.CChatRoom_ReplaceRoleActions_Request, dict(chat_group_id=8589934593, role_id=8589934594, actions=dict(role_id=8589934594, can_chat=True, can_ban=True))),
|
||||
"ReplaceRoleActionsResponse": (chat.CChatRoom_ReplaceRoleActions_Response, dict()),
|
||||
"AddRoleToUserRequest": (chat.CChatRoom_AddRoleToUser_Request, dict(chat_group_id=8589934593, role_id=8589934595, steamid=ID)),
|
||||
"AddRoleToUserResponse": (chat.CChatRoom_AddRoleToUser_Response, dict()),
|
||||
"DeleteRoleFromUserRequest": (chat.CChatRoom_DeleteRoleFromUser_Request, dict(chat_group_id=8589934593, role_id=8589934595, steamid=ID)),
|
||||
"DeleteRoleFromUserResponse": (chat.CChatRoom_DeleteRoleFromUser_Response, dict()),
|
||||
"GetBanListRequest": (chat.CChatRoom_GetBanList_Request, dict(chat_group_id=8589934593)),
|
||||
"BanInfo": (chat.CChatRoom_GetBanList_Response.BanInfo, dict(accountid=7, accountid_actor=8, time_banned=9, ban_reason='s4')),
|
||||
"GetBanListResponse": (chat.CChatRoom_GetBanList_Response, dict(bans=[dict(accountid=7, accountid_actor=8, time_banned=9, ban_reason='s4')])),
|
||||
"SetAvatarRequest": (chat.CChatRoom_SetChatRoomGroupAvatar_Request, dict(chat_group_id=8589934593, avatar_sha=b'\x01\x02\x03')),
|
||||
"SetAvatarResponse": (chat.CChatRoom_SetChatRoomGroupAvatar_Response, dict()),
|
||||
"ChatRoomNoticeNotification": (chat.CChatRoom_ChatRoomNotice_Notification, dict(chat_group_id=8589934593, steamid_sender=ID, notice_type=1, active=True)),
|
||||
"DismissChatRoomNotice": (chat.CChatRoom_DismissChatRoomNotice_Notification, dict(chat_group_id=8589934593, steamid_sender=ID, notice_type=1)),
|
||||
"ChatRoomMemberNotice": (chat.CChatRoomMemberNotice, dict(accountid=7, notice_type=1)),
|
||||
"ChatRoomGroupMemberNotices": (chat.CChatRoomGroupMemberNotices, dict(chat_group_id=8589934593, member_notices=[dict(accountid=7, notice_type=1)])),
|
||||
"FriendGroup": (friends.CMsgClientFriendsGroupsList.FriendGroup, dict(nGroupID=7, strGroupName="Raiders")),
|
||||
"FriendGroupMembership": (friends.CMsgClientFriendsGroupsList.FriendGroupsMembership, dict(ulSteamID=ID, nGroupID=7)),
|
||||
"ClientFriendsGroupsList": (friends.CMsgClientFriendsGroupsList, dict(
|
||||
bremoval=True, bincremental=True, friendGroups=[dict(nGroupID=7, strGroupName="Raiders"), dict(nGroupID=8, strGroupName="Work")],
|
||||
memberships=[dict(ulSteamID=ID, nGroupID=7), dict(ulSteamID=ID2, nGroupID=8)])),
|
||||
"ClientCreateFriendsGroup": (friends.CMsgClientCreateFriendsGroup, dict(steamid=ID, groupname="Raiders", steamid_friends=[ID, ID2])),
|
||||
"ClientCreateFriendsGroupResponse": (friends.CMsgClientCreateFriendsGroupResponse, dict(eresult=1, groupid=7)),
|
||||
"ClientDeleteFriendsGroup": (friends.CMsgClientDeleteFriendsGroup, dict(steamid=ID, groupid=7)),
|
||||
"ClientDeleteFriendsGroupResponse": (friends.CMsgClientDeleteFriendsGroupResponse, dict(eresult=1)),
|
||||
"ClientManageFriendsGroup": (friends.CMsgClientManageFriendsGroup, dict(groupid=7, groupname="Raiders", steamid_friends_added=[ID], steamid_friends_removed=[ID2])),
|
||||
"ClientManageFriendsGroupResponse": (friends.CMsgClientManageFriendsGroupResponse, dict(eresult=1)),
|
||||
"ClientAddFriendToGroup": (friends.CMsgClientAddFriendToGroup, dict(groupid=7, steamiduser=ID)),
|
||||
"ClientAddFriendToGroupResponse": (friends.CMsgClientAddFriendToGroupResponse, dict(eresult=1)),
|
||||
"ClientRemoveFriendFromGroup": (friends.CMsgClientRemoveFriendFromGroup, dict(groupid=7, steamiduser=ID)),
|
||||
"ClientRemoveFriendFromGroupResponse": (friends.CMsgClientRemoveFriendFromGroupResponse, dict(eresult=1)),
|
||||
"ClientAddFriend": (friends.CMsgClientAddFriend, dict(steamid_to_add=ID, accountname_or_email_to_add="someone")),
|
||||
"ClientAddFriendResponse": (friends.CMsgClientAddFriendResponse, dict(eresult=29, steam_id_added=ID2, persona_name_added="Some One")),
|
||||
"ClientRemoveFriend": (friends.CMsgClientRemoveFriend, dict(friendid=ID)),
|
||||
"ClientFriendMsg": (friends.CMsgClientFriendMsg, dict(
|
||||
steamid=ID, chat_entry_type=2, message=b"typing", rtime32_server_timestamp=1790000001, echo_to_sender=True)),
|
||||
"ClientFriendMsgIncoming": (friends.CMsgClientFriendMsgIncoming, dict(
|
||||
steamid_from=ID, chat_entry_type=1, from_limited_account=True, message=b"hello", rtime32_server_timestamp=1790000002)),
|
||||
"FriendMessagesSendRequest": (fmsg.CFriendMessages_SendMessage_Request, dict(
|
||||
steamid=ID, chat_entry_type=1, message="hi [b]there[/b]", contains_bbcode=True, echo_to_sender=True,
|
||||
low_priority=True, client_message_id="abc-123")),
|
||||
"FriendMessagesSendResponse": (fmsg.CFriendMessages_SendMessage_Response, dict(
|
||||
modified_message="hi [b]there[/b]", server_timestamp=1790000003, ordinal=2, message_without_bb_code="hi there")),
|
||||
"FriendMessagesIncoming": (fmsg.CFriendMessages_IncomingMessage_Notification, dict(
|
||||
steamid_friend=ID, chat_entry_type=1, from_limited_account=True, message="yo", rtime32_server_timestamp=1790000004,
|
||||
ordinal=3, local_echo=True, message_no_bbcode="yo", low_priority=True)),
|
||||
"GetRecentMessagesRequest": (fmsg.CFriendMessages_GetRecentMessages_Request, dict(
|
||||
steamid1=ID, steamid2=ID2, count=50, most_recent_conversation=True, rtime32_start_time=1700000000,
|
||||
bbcode_format=True, start_ordinal=3, time_last=1790000000, ordinal_last=4)),
|
||||
"PastReaction": (fmsg.CFriendMessages_GetRecentMessages_Response.FriendMessage.MessageReaction, dict(reaction_type=1, reaction=":steamhappy:", reactors=[22202, 100])),
|
||||
"PastMessage": (fmsg.CFriendMessages_GetRecentMessages_Response.FriendMessage, dict(
|
||||
accountid=22202, timestamp=1790000005, message="from the past", ordinal=2,
|
||||
reactions=[dict(reaction_type=1, reaction=":steamhappy:", reactors=[100])])),
|
||||
"GetRecentMessagesResponse": (fmsg.CFriendMessages_GetRecentMessages_Response, dict(
|
||||
messages=[dict(accountid=22202, timestamp=1790000005, message="a", ordinal=1), dict(accountid=100, timestamp=1790000006, message="b", ordinal=0)],
|
||||
more_available=True)),
|
||||
"GetActiveSessionsRequest": (fmsg.CFriendsMessages_GetActiveMessageSessions_Request, dict(lastmessage_since=1790000000, only_sessions_with_messages=True)),
|
||||
"MessageSession": (fmsg.CFriendsMessages_GetActiveMessageSessions_Response.FriendMessageSession, dict(
|
||||
accountid_friend=100, last_message=1790000007, last_view=1790000003, unread_message_count=4, notices=[1])),
|
||||
"GetActiveSessionsResponse": (fmsg.CFriendsMessages_GetActiveMessageSessions_Response, dict(
|
||||
message_sessions=[dict(accountid_friend=100, last_message=1790000007, last_view=1790000003, unread_message_count=4)],
|
||||
timestamp=1790000008)),
|
||||
"AckMessage": (fmsg.CFriendMessages_AckMessage_Notification, dict(steamid_partner=ID, timestamp=1790000009)),
|
||||
"GetEmoticonListRequest": (player.CPlayer_GetEmoticonList_Request, dict()),
|
||||
"EmoticonInfo": (player.CPlayer_GetEmoticonList_Response.Emoticon, dict(
|
||||
name="steamhappy", count=3, time_last_used=1790000010, use_count=9, time_received=1700000000, appid=730)),
|
||||
"GetEmoticonListResponse": (player.CPlayer_GetEmoticonList_Response, dict(
|
||||
emoticons=[dict(name="steamhappy", count=1), dict(name="steamsad", count=2, use_count=4)])),
|
||||
"UpdateMessageReactionRequest": (fmsg.CFriendMessages_UpdateMessageReaction_Request, dict(
|
||||
steamid=ID, server_timestamp=1790000011, ordinal=2, reaction_type=1, reaction="steamhappy", is_add=True)),
|
||||
"UpdateMessageReactionResponse": (fmsg.CFriendMessages_UpdateMessageReaction_Response, dict(reactors=[22202, 100])),
|
||||
"MessageReactionNotice": (fmsg.CFriendMessages_MessageReaction_Notification, dict(
|
||||
steamid_friend=ID, server_timestamp=1790000012, ordinal=1, reactor=ID2, reaction_type=2, reaction="CatSurprise", is_add=True)),
|
||||
"SessionNoticeNotification": (fmsg.CFriendMessages_SessionNotice_Notification, dict(steamid_friend=ID, notice_type=1, active=True)),
|
||||
"DismissSessionNotice": (fmsg.CFriendMessages_DismissSessionNotice_Notification, dict(steamid_friend=ID2, notice_type=1)),
|
||||
"FriendProfileInfoRequest": (friends.CMsgClientFriendProfileInfo, dict(steamid_friend=ID)),
|
||||
"FriendProfileInfoResponse": (friends.CMsgClientFriendProfileInfoResponse, dict(
|
||||
eresult=1, steamid_friend=ID2, time_created=1300000000, real_name="Some One", city_name="Bellevue",
|
||||
state_name="WA", country_name="United States", headline="Hello", summary="About me")),
|
||||
"FriendsSteamLevelsRequest": (cs2.CMsgClientFSGetFriendsSteamLevels, dict(accountids=[22202, 100])),
|
||||
"FriendLevel": (cs2.CMsgClientFSGetFriendsSteamLevelsResponse.Friend, dict(accountid=22202, level=42)),
|
||||
"FriendsSteamLevelsResponse": (cs2.CMsgClientFSGetFriendsSteamLevelsResponse, dict(friends=[dict(accountid=22202, level=42), dict(accountid=100, level=7)])),
|
||||
"GetPerFriendPreferencesRequest": (player.CPlayer_GetPerFriendPreferences_Request, dict()),
|
||||
"PerFriendPreferences": (player.PerFriendPreferences, dict(
|
||||
accountid=22202, nickname="Buddy", notifications_showingame=1, notifications_showonline=2, notifications_showmessages=2,
|
||||
sounds_showingame=1, sounds_showonline=2, sounds_showmessages=1, notifications_sendmobile=2)),
|
||||
"GetPerFriendPreferencesResponse": (player.CPlayer_GetPerFriendPreferences_Response, dict(preferences=[dict(accountid=22202, nickname="Buddy", notifications_showmessages=2)])),
|
||||
"SetPerFriendPreferencesRequest": (player.CPlayer_SetPerFriendPreferences_Request, dict(preferences=dict(accountid=22202, nickname="Pal"))),
|
||||
"SetPerFriendPreferencesResponse": (player.CPlayer_SetPerFriendPreferences_Response, dict()),
|
||||
"PerFriendPreferencesChanged": (player.CPlayer_PerFriendPreferencesChanged_Notification, dict(accountid=22202, preferences=dict(accountid=22202, nickname="Pal", notifications_showmessages=1))),
|
||||
"FriendNicknameChanged": (player.CPlayer_FriendNicknameChanged_Notification, dict(accountid=22202, nickname="Pal", is_echo_to_self=True)),
|
||||
"ClientGetEmoticonList": (friends.CMsgClientGetEmoticonList, dict()),
|
||||
"EmoticonOwned": (friends.CMsgClientEmoticonList.Emoticon, dict(name="steamhappy", count=3, time_last_used=1790000010, use_count=9, time_received=1700000000, appid=730)),
|
||||
"StickerOwned": (friends.CMsgClientEmoticonList.Sticker, dict(name="CatSurprise", count=2, time_received=1700000001, appid=440, time_last_used=1790000011, use_count=5)),
|
||||
"EffectOwned": (friends.CMsgClientEmoticonList.Effect, dict(name="Fireworks", count=1, time_received=1700000002, infinite_use=True, appid=570)),
|
||||
"ClientEmoticonList": (friends.CMsgClientEmoticonList, dict(
|
||||
emoticons=[dict(name="steamhappy", count=1)], stickers=[dict(name="CatSurprise", count=2)], effects=[dict(name="Fireworks", infinite_use=True)])),
|
||||
"IgnoreFriendRequest": (player.CPlayer_IgnoreFriend_Request, dict(steamid=ID, unignore=True)),
|
||||
"IgnoreFriendResponse": (player.CPlayer_IgnoreFriend_Response, dict(friend_relationship=5)),
|
||||
"GetPasswordRsaPublicKeyRequest": (auth.CAuthentication_GetPasswordRSAPublicKey_Request, dict(account_name="someone")),
|
||||
"GetPasswordRsaPublicKeyResponse": (auth.CAuthentication_GetPasswordRSAPublicKey_Response, dict(
|
||||
publickey_mod="C0FFEE" * 8, publickey_exp="010001", timestamp=1790000005000)),
|
||||
"DeviceDetails": (auth.CAuthentication_DeviceDetails, dict(
|
||||
device_friendly_name="moho", platform_type=3, os_type=203, gaming_device_type=1, client_count=2,
|
||||
machine_id=b"\x09\x08\x07", app_type=2)),
|
||||
"AllowedConfirmation": (auth.CAuthentication_AllowedConfirmation, dict(confirmation_type=3, associated_message="a***@example.com")),
|
||||
"BeginAuthViaCredentialsRequest": (auth.CAuthentication_BeginAuthSessionViaCredentials_Request, dict(
|
||||
device_friendly_name="moho", account_name="someone", encrypted_password="Zm9v", encryption_timestamp=1790000005000,
|
||||
remember_login=True, platform_type=3, persistence=0, website_id="Client",
|
||||
device_details=dict(device_friendly_name="moho", platform_type=3), guard_data="guard", language=1, qos_level=3)),
|
||||
"BeginAuthViaCredentialsResponse": (auth.CAuthentication_BeginAuthSessionViaCredentials_Response, dict(
|
||||
client_id=2**50 + 3, request_id=b"\x0a\x0b\x0c", interval=5.0,
|
||||
allowed_confirmations=[dict(confirmation_type=3), dict(confirmation_type=4, associated_message="x")],
|
||||
steamid=ID, weak_token="weak", agreement_session_url="https://x", extended_error_message="more")),
|
||||
"BeginAuthViaQrRequest": (auth.CAuthentication_BeginAuthSessionViaQR_Request, dict(
|
||||
device_friendly_name="moho", platform_type=3, device_details=dict(device_friendly_name="moho"), website_id="Client")),
|
||||
"BeginAuthViaQrResponse": (auth.CAuthentication_BeginAuthSessionViaQR_Response, dict(
|
||||
client_id=2**50 + 4, challenge_url="https://s.team/q/1/2", request_id=b"\x01\x02", interval=2.5,
|
||||
allowed_confirmations=[dict(confirmation_type=2)], version=1)),
|
||||
"PollAuthSessionRequest": (auth.CAuthentication_PollAuthSessionStatus_Request, dict(
|
||||
client_id=2**50 + 3, request_id=b"\x0a\x0b\x0c", token_to_revoke=2**40)),
|
||||
"PollAuthSessionResponse": (auth.CAuthentication_PollAuthSessionStatus_Response, dict(
|
||||
new_client_id=2**50 + 5, new_challenge_url="https://s.team/q/new", refresh_token="refresh", access_token="access",
|
||||
had_remote_interaction=True, account_name="someone", new_guard_data="newguard", agreement_session_url="https://a")),
|
||||
"UpdateAuthWithGuardCodeRequest": (auth.CAuthentication_UpdateAuthSessionWithSteamGuardCode_Request, dict(
|
||||
client_id=2**50 + 3, steamid=ID, code="AB12C", code_type=3)),
|
||||
"UpdateAuthWithGuardCodeResponse": (auth.CAuthentication_UpdateAuthSessionWithSteamGuardCode_Response, dict(agreement_session_url="https://a")),
|
||||
"GenerateAccessTokenRequest": (auth.CAuthentication_AccessToken_GenerateForApp_Request, dict(
|
||||
refresh_token="refresh", steamid=ID, renewal_type=1)),
|
||||
"GenerateAccessTokenResponse": (auth.CAuthentication_AccessToken_GenerateForApp_Response, dict(access_token="access", refresh_token="refresh2")),
|
||||
"IpAddress": (base.CMsgIPAddress, dict(v6=bytes(range(16)))),
|
||||
}
|
||||
|
||||
|
||||
def fill(msg, values):
|
||||
for name, value in values.items():
|
||||
field = msg.DESCRIPTOR.fields_by_name[name]
|
||||
if field.message_type is not None and field.is_repeated:
|
||||
for item in value:
|
||||
fill(getattr(msg, name).add(), item)
|
||||
elif field.message_type is not None:
|
||||
fill(getattr(msg, name), value)
|
||||
elif field.is_repeated:
|
||||
getattr(msg, name).extend(value)
|
||||
else:
|
||||
setattr(msg, name, value)
|
||||
return msg
|
||||
|
||||
|
||||
def rust_fields():
|
||||
"""struct name -> its field names, read out of proto.rs."""
|
||||
text = open(os.path.join(HERE, "..", "proto.rs")).read()
|
||||
structs = {}
|
||||
for chunk in text.split("pub struct ")[1:]:
|
||||
name = chunk.split(" ", 1)[0]
|
||||
# An empty message is written `{}` on one line; the rest end at a
|
||||
# closing brace at the start of a line.
|
||||
body = "" if chunk.startswith(f"{name} {{}}") else chunk.split("\n}", 1)[0]
|
||||
structs[name] = set(re.findall(r"pub (\w+):", body))
|
||||
return structs
|
||||
|
||||
|
||||
rust = rust_fields()
|
||||
problems = []
|
||||
for name, (cls, values) in CASES.items():
|
||||
# The oneof in IpAddress is one Rust field standing for two real ones.
|
||||
if name == "IpAddress":
|
||||
continue
|
||||
if name not in rust:
|
||||
problems.append(f"{name}: no such struct in proto.rs")
|
||||
continue
|
||||
if set(values) != rust[name]:
|
||||
problems.append(f"{name}: filled {sorted(set(values) ^ rust[name])} differently from the struct")
|
||||
missing = sorted(set(rust) - set(CASES))
|
||||
if missing:
|
||||
problems.append(f"structs with no case here: {missing}")
|
||||
if problems:
|
||||
sys.exit("\n".join(problems))
|
||||
|
||||
for name, (cls, values) in CASES.items():
|
||||
print(name, fill(cls(), values).SerializeToString().hex())
|
||||
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Writes voicewire.rs and voice_golden.txt from the Steam client's own JavaScript.
|
||||
|
||||
Steam's voice and WebRTC calls are not in the .proto files extracted from
|
||||
steamclient.so: they are only in the web client, as generated message classes
|
||||
with their field numbers and types. This reads those tables out of the
|
||||
prettified UI bundle and writes, from the one table:
|
||||
|
||||
- ../voicewire.rs: a prost struct for each message
|
||||
- voice_golden.txt: each message encoded by the reference protobuf library from
|
||||
a descriptor built out of the same table, which the tests in voicewire.rs
|
||||
require the structs to reproduce byte for byte
|
||||
|
||||
python3 make_voice.py /path/to/steam/decomp/steamui/chunk~2dcc5aaf7.js
|
||||
|
||||
The bundle is the source of truth; nothing here is typed in by hand.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from google.protobuf import descriptor_pb2, descriptor_pool, message_factory
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
SRC = open(sys.argv[1]).read()
|
||||
|
||||
WANT = re.compile(r"^C(VoiceChat|WebRTCClient|WebRTC)_|^CChatRoom_(JoinVoiceChat|LeaveVoiceChat|NotifyShouldRejoinChatRoomVoiceChat)")
|
||||
KINDS = {
|
||||
"readFixed64String": ("fixed64", "u64", descriptor_pb2.FieldDescriptorProto.TYPE_FIXED64),
|
||||
"readUint64String": ("uint64", "u64", descriptor_pb2.FieldDescriptorProto.TYPE_UINT64),
|
||||
"readUint32": ("uint32", "u32", descriptor_pb2.FieldDescriptorProto.TYPE_UINT32),
|
||||
"readInt32": ("int32", "i32", descriptor_pb2.FieldDescriptorProto.TYPE_INT32),
|
||||
"readBool": ("bool", "bool", descriptor_pb2.FieldDescriptorProto.TYPE_BOOL),
|
||||
"readString": ("string", "String", descriptor_pb2.FieldDescriptorProto.TYPE_STRING),
|
||||
"readBytes": ('bytes = "vec"', "Vec<u8>", descriptor_pb2.FieldDescriptorProto.TYPE_BYTES),
|
||||
"readEnum": ("int32", "i32", descriptor_pb2.FieldDescriptorProto.TYPE_INT32),
|
||||
"readFloat": ("float", "f32", descriptor_pb2.FieldDescriptorProto.TYPE_FLOAT),
|
||||
}
|
||||
|
||||
# JS class variable -> wire name, for nested message fields.
|
||||
# Class variables are reused from one module of the bundle to the next, so a name
|
||||
# is resolved to the nearest definition before the place it is used.
|
||||
defs = [] # (position, var, wire name, body)
|
||||
for m in re.finditer(r"class (\w+) extends d\.Message \{(.*?)getClassName\(\) \{\s*return \"(\w+)\";", SRC, re.S):
|
||||
var, body, name = m.groups()
|
||||
defs.append((m.start(), var, name, body))
|
||||
|
||||
def rust_name(wire):
|
||||
n = wire[1:] if wire.startswith("C") else wire
|
||||
n = n.replace("CSSRCToAccountIDMapping", "SsrcAccount")
|
||||
parts = n.split("_")
|
||||
if parts[0] in ("VoiceChat", "WebRTCClient", "WebRTC"):
|
||||
parts = parts[1:]
|
||||
elif parts[0] == "ChatRoom":
|
||||
# Both services have a LeaveVoiceChat; the room's is told apart.
|
||||
parts = ["Room"] + parts[1:]
|
||||
return "".join(p[0].upper() + p[1:] for p in parts)
|
||||
|
||||
def resolve(var, before):
|
||||
same = [d for d in defs if d[1] == var]
|
||||
earlier = [d for d in same if d[0] < before]
|
||||
# A message can refer to one defined just after it in the same module.
|
||||
return (earlier[-1] if earlier else [d for d in same if d[0] > before][0])[2]
|
||||
|
||||
messages = {} # wire name -> (rust name, [(field, number, kind, rust type, repeated, submsg)])
|
||||
order = []
|
||||
for pos, var, name, body in defs:
|
||||
if not WANT.search(name):
|
||||
continue
|
||||
fields = []
|
||||
for fm in re.finditer(r"(\w+): \{ n: (\d+), (?:br: m\.qM\.(\w+)|c: (\w+), r: !0)", body):
|
||||
fname, num, read, sub = fm.groups()
|
||||
if read:
|
||||
fields.append((fname, int(num), read, None))
|
||||
else:
|
||||
fields.append((fname, int(num), None, resolve(sub, pos)))
|
||||
messages[name] = (rust_name(name), fields)
|
||||
order.append(name)
|
||||
|
||||
def sub_wire(wire):
|
||||
return wire
|
||||
|
||||
# --- Rust ---
|
||||
rs = ['//! Steam voice: the messages of the VoiceChat and WebRTCClient services and the voice\n',
|
||||
'//! calls of ChatRoom, written out by `golden/make_voice.py` from the Steam client\'s own\n',
|
||||
'//! JavaScript. Do not edit by hand: change the generator and run it again.\n\n',
|
||||
'#![allow(dead_code)]\n\nuse prost::Message;\n\n']
|
||||
for name in order:
|
||||
rname, fields = messages[name]
|
||||
rs.append("#[derive(Clone, PartialEq, Message)]\n")
|
||||
rs.append(f"pub struct {rname} {{\n")
|
||||
for fname, num, read, sub in fields:
|
||||
if read:
|
||||
attr, ty, _ = KINDS[read]
|
||||
rs.append(f' #[prost({attr}, optional, tag = "{num}")]\n pub {fname}: Option<{ty}>,\n')
|
||||
else:
|
||||
inner = messages[sub_wire(sub)][0]
|
||||
rs.append(f' #[prost(message, repeated, tag = "{num}")]\n pub {fname}: Vec<{inner}>,\n')
|
||||
rs.append("}\n\n")
|
||||
names = ", ".join(messages[n][0] for n in order)
|
||||
rs.append("#[cfg(test)]\nmod tests {\n use super::*;\n\n const GOLDEN: &str = include_str!(\"golden/voice_golden.txt\");\n\n")
|
||||
rs.append(" fn unhex(s: &str) -> Vec<u8> {\n (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()\n }\n\n")
|
||||
rs.append(" fn round_trips<M: Message + Default>(name: &str, bytes: &[u8]) {\n let decoded = M::decode(bytes).unwrap_or_else(|e| panic!(\"{name}: {e}\"));\n assert_eq!(decoded.encode_to_vec(), bytes, \"{name} does not encode to what the reference library wrote\");\n }\n\n")
|
||||
rs.append(" #[test]\n fn every_voice_message_matches_the_reference_encoding() {\n let mut seen = 0;\n for line in GOLDEN.lines().filter(|l| !l.trim().is_empty()) {\n let (name, hex) = line.split_once(' ').expect(\"name and bytes\");\n let bytes = unhex(hex);\n macro_rules! cases {\n ($($t:ident),* $(,)?) => {\n match name {\n $( stringify!($t) => round_trips::<$t>(name, &bytes), )*\n other => panic!(\"voice_golden.txt has {other}, which voicewire.rs does not\"),\n }\n };\n }\n")
|
||||
rs.append(f" cases!({names});\n seen += 1;\n }}\n assert_eq!(seen, {len(order)});\n }}\n}}\n")
|
||||
open(os.path.join(HERE, "..", "voicewire.rs"), "w").write("".join(rs))
|
||||
|
||||
# --- reference encoding ---
|
||||
fdp = descriptor_pb2.FileDescriptorProto(name="voice.proto", package="v", syntax="proto2")
|
||||
for name in order:
|
||||
rname, fields = messages[name]
|
||||
msg = fdp.message_type.add(name=rname)
|
||||
for fname, num, read, sub in fields:
|
||||
f = msg.field.add(name=fname, number=num, label=descriptor_pb2.FieldDescriptorProto.LABEL_OPTIONAL)
|
||||
if read:
|
||||
f.type = KINDS[read][2]
|
||||
else:
|
||||
f.label = descriptor_pb2.FieldDescriptorProto.LABEL_REPEATED
|
||||
f.type = descriptor_pb2.FieldDescriptorProto.TYPE_MESSAGE
|
||||
f.type_name = ".v." + messages[sub_wire(sub)][0]
|
||||
pool = descriptor_pool.DescriptorPool()
|
||||
pool.Add(fdp)
|
||||
|
||||
def sample(desc, depth=0):
|
||||
values = {}
|
||||
for f in desc.fields:
|
||||
T = f.type
|
||||
if T == f.TYPE_MESSAGE:
|
||||
if depth < 2:
|
||||
values[f.name] = [sample(f.message_type, depth + 1)]
|
||||
elif T == f.TYPE_BOOL:
|
||||
values[f.name] = True
|
||||
elif T == f.TYPE_STRING:
|
||||
values[f.name] = f"s{f.number}"
|
||||
elif T == f.TYPE_BYTES:
|
||||
values[f.name] = b"\x01\x02\x03"
|
||||
elif T == f.TYPE_FLOAT:
|
||||
values[f.name] = 1.5
|
||||
elif T in (f.TYPE_FIXED64, f.TYPE_UINT64):
|
||||
values[f.name] = 2**33 + f.number
|
||||
else:
|
||||
values[f.name] = 7 + f.number
|
||||
return values
|
||||
|
||||
def fill(msg, values):
|
||||
for k, v in values.items():
|
||||
f = msg.DESCRIPTOR.fields_by_name[k]
|
||||
if f.type == f.TYPE_MESSAGE:
|
||||
for item in v:
|
||||
fill(getattr(msg, k).add(), item)
|
||||
else:
|
||||
setattr(msg, k, v)
|
||||
return msg
|
||||
|
||||
lines = []
|
||||
for name in order:
|
||||
rname = messages[name][0]
|
||||
cls = message_factory.GetMessageClass(pool.FindMessageTypeByName("v." + rname))
|
||||
lines.append(f"{rname} {fill(cls(), sample(cls.DESCRIPTOR)).SerializeToString().hex()}")
|
||||
open(os.path.join(HERE, "voice_golden.txt"), "w").write("\n".join(lines) + "\n")
|
||||
print(len(order), "messages")
|
||||
@@ -0,0 +1,37 @@
|
||||
RequestOneOnOneChatRequest 090100000002000000
|
||||
RequestOneOnOneChatResponse 090100000002000000
|
||||
OneOnOneChatRequestedNotification 090100000002000000110200000002000000
|
||||
AnswerOneOnOneChatRequest 0901000000020000001102000000020000001801
|
||||
AnswerOneOnOneChatResponse
|
||||
OneOnOneChatRequestResponseNotification 0901000000020000001102000000020000001801
|
||||
EndOneOnOneChatRequest 090100000002000000
|
||||
EndOneOnOneChatResponse
|
||||
LeaveOneOnOneChatRequest 090100000002000000110200000002000000
|
||||
LeaveOneOnOneChatResponse
|
||||
UserJoinedVoiceChatNotification 090100000002000000110200000002000000188380808020210400000002000000290500000002000000308680808020380e
|
||||
UserVoiceStatusNotification 090100000002000000110200000002000000180120012801300d3801
|
||||
AllMembersStatusNotification 090100000002000000121c090100000002000000110200000002000000180120012801300d3801
|
||||
UpdateVoiceChatWebRTCDataRequest 0901000000020000001009180a200b280c300d3a0273374001480150015801
|
||||
UpdateVoiceChatWebRTCDataResponse 0801
|
||||
UpdateUserVideoStatusRequest 0901000000020000001102000000020000001801
|
||||
UpdateUserVideoStatusResponse
|
||||
UploadClientVoiceChatLogsRequest 09010000000200000012027332
|
||||
UploadClientVoiceChatLogsResponse
|
||||
LeaveVoiceChatRequest 090100000002000000
|
||||
LeaveVoiceChatResponse
|
||||
UserLeftVoiceChatNotification 090100000002000000110200000002000000188380808020210400000002000000290500000002000000308680808020380e
|
||||
VoiceChatEndedNotification 090100000002000000110200000002000000190300000002000000208480808020288580808020
|
||||
InitiateWebRTCConnectionRequest 0a027331120273321a027333
|
||||
InitiateWebRTCConnectionResponse 0a027331
|
||||
UpdateWebRTCConnectionRequest 08081009180a200b2a027335
|
||||
UpdateWebRTCConnectionResponse 0a027331
|
||||
WebRTCSessionConnectedNotification 08081009180a200b280c
|
||||
WebRTCUpdateRemoteDescriptionNotification 0a0273311082808080201a0408081009
|
||||
WebRTCUpdateRemoteDescriptionNotificationSsrcAccount 08081009
|
||||
AcknowledgeUpdatedRemoteDescriptionRequest 08081009180a200b288580808020
|
||||
AcknowledgeUpdatedRemoteDescriptionResponse
|
||||
RoomJoinVoiceChatRequest 088180808020108280808020
|
||||
RoomJoinVoiceChatResponse 088180808020
|
||||
RoomLeaveVoiceChatRequest 088180808020108280808020
|
||||
RoomLeaveVoiceChatResponse
|
||||
RoomNotifyShouldRejoinChatRoomVoiceChatNotification 088180808020108280808020
|
||||
@@ -0,0 +1,320 @@
|
||||
//! The groups a Steam account has sorted its friends into.
|
||||
//!
|
||||
//! Steam keeps them for the account - "Raiders", "Work" - and tells every
|
||||
//! client of it, so a friend filed in one place is filed in all. A friend can be
|
||||
//! in several. The window files each conversation under the one heading its own
|
||||
//! category system has for it and mirrors changes both ways; this is the
|
||||
//! daemon's half: what Steam says, and the calls that change it.
|
||||
|
||||
use super::link::Link;
|
||||
use super::proto::*;
|
||||
use super::steamid;
|
||||
use super::wire::emsg;
|
||||
use crate::state::AppState;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
/// The groups and who is in each.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Groups {
|
||||
names: BTreeMap<i32, String>,
|
||||
members: BTreeMap<i32, BTreeSet<u64>>,
|
||||
}
|
||||
|
||||
impl Groups {
|
||||
/// Takes in what Steam said: all of it, or what changed. A list marked as a
|
||||
/// removal names things taken away. Returns whether anything is different.
|
||||
pub fn apply(&mut self, list: &ClientFriendsGroupsList) -> bool {
|
||||
let before = self.clone();
|
||||
let incremental = list.bincremental.unwrap_or(false);
|
||||
let removal = list.bremoval.unwrap_or(false);
|
||||
if !incremental {
|
||||
self.names.clear();
|
||||
self.members.clear();
|
||||
}
|
||||
for group in &list.friendGroups {
|
||||
let Some(id) = group.nGroupID else { continue };
|
||||
if removal {
|
||||
self.names.remove(&id);
|
||||
self.members.remove(&id);
|
||||
} else {
|
||||
self.names.insert(id, group.strGroupName.clone().unwrap_or_default());
|
||||
self.members.entry(id).or_default();
|
||||
}
|
||||
}
|
||||
for membership in &list.memberships {
|
||||
let (Some(friend), Some(id)) = (membership.ulSteamID, membership.nGroupID) else { continue };
|
||||
if removal {
|
||||
if let Some(members) = self.members.get_mut(&id) {
|
||||
members.remove(&friend);
|
||||
}
|
||||
} else if self.names.contains_key(&id) {
|
||||
self.members.entry(id).or_default().insert(friend);
|
||||
}
|
||||
}
|
||||
*self != before
|
||||
}
|
||||
|
||||
/// The groups by number and name: for the live check, which has to find its own.
|
||||
#[cfg(test)]
|
||||
pub fn names_for_probe(&self) -> Vec<(i32, String)> {
|
||||
self.names.iter().map(|(i, n)| (*i, n.clone())).collect()
|
||||
}
|
||||
|
||||
pub fn put_member(&mut self, group: i32, friend: u64, present: bool) {
|
||||
if !self.names.contains_key(&group) {
|
||||
return;
|
||||
}
|
||||
let members = self.members.entry(group).or_default();
|
||||
if present {
|
||||
members.insert(friend);
|
||||
} else {
|
||||
members.remove(&friend);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn put_group(&mut self, group: i32, name: &str, friends: &[u64]) {
|
||||
self.names.insert(group, name.to_string());
|
||||
self.members.insert(group, friends.iter().copied().collect());
|
||||
}
|
||||
|
||||
pub fn rename(&mut self, group: i32, name: &str) {
|
||||
if let Some(held) = self.names.get_mut(&group) {
|
||||
*held = name.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, group: i32) {
|
||||
self.names.remove(&group);
|
||||
self.members.remove(&group);
|
||||
}
|
||||
|
||||
/// As the window reads it: each group with its name and the friends in it
|
||||
/// by their 64-bit ids, which is how a conversation names who it is with.
|
||||
pub fn to_json(&self) -> Value {
|
||||
Value::Array(
|
||||
self.names
|
||||
.iter()
|
||||
.map(|(id, name)| json!({ "id": id, "name": name, "members": self.members.get(id).map(|m| m.iter().map(|f| f.to_string()).collect::<Vec<_>>()).unwrap_or_default() }))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// What each signed-in account's groups are now, for the window to ask for.
|
||||
static HELD: LazyLock<Mutex<HashMap<String, Groups>>> = LazyLock::new(Default::default);
|
||||
|
||||
pub fn held(account_id: &str) -> Groups {
|
||||
HELD.lock().unwrap().get(account_id).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn clear(account_id: &str) {
|
||||
HELD.lock().unwrap().remove(account_id);
|
||||
}
|
||||
|
||||
/// Keeps what Steam says and tells the window, if it is different.
|
||||
pub fn heard(state: &AppState, account_id: &str, list: &ClientFriendsGroupsList) {
|
||||
let groups = {
|
||||
let mut all = HELD.lock().unwrap();
|
||||
let groups = all.entry(account_id.to_string()).or_default();
|
||||
if !groups.apply(list) {
|
||||
return;
|
||||
}
|
||||
groups.clone()
|
||||
};
|
||||
tell(state, account_id, &groups);
|
||||
unfile_conversations(state, account_id);
|
||||
}
|
||||
|
||||
/// Takes every friend's conversation out of any heading it was given, and keeps it out. A
|
||||
/// heading on a conversation is a service's own grouping and cannot be moved, renamed or removed
|
||||
/// in the window; a Steam folder is not that - the Steam account's own page draws the folders
|
||||
/// itself and the direct-messages page's headings are the person's - and one filed here once
|
||||
/// (a heading restored from an earlier run among them) was a heading nobody could do anything with.
|
||||
pub fn unfile_conversations(state: &AppState, account_id: &str) {
|
||||
for buffer in state.runtime.list_buffers().into_iter().filter(|b| b.account_id == account_id && b.kind == "dm" && b.category.is_some()) {
|
||||
state.runtime.set_buffer_category(state, &buffer.id, None, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The same for one conversation, for when it has only just been made or restored.
|
||||
pub fn unfile_one(state: &AppState, buffer_id: &str) {
|
||||
state.runtime.set_buffer_category(state, buffer_id, None, 0);
|
||||
}
|
||||
|
||||
fn tell(state: &AppState, account_id: &str, groups: &Groups) {
|
||||
state.events.emit("steamFriendGroups", json!({ "accountId": account_id, "groups": groups.to_json() }));
|
||||
}
|
||||
|
||||
/// What a change is, for the one call that makes it.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Change {
|
||||
Add { group: i32, friend: u64 },
|
||||
Remove { group: i32, friend: u64 },
|
||||
Create { name: String, friends: Vec<u64> },
|
||||
Rename { group: i32, name: String },
|
||||
Delete { group: i32 },
|
||||
}
|
||||
|
||||
/// Asks Steam to make a change, and keeps what it answers. Returns the number
|
||||
/// of the group made, for a creation.
|
||||
pub async fn change(state: &AppState, link: &Link, account_id: &str, own: u64, what: Change) -> Result<Option<i32>, String> {
|
||||
let refused = |code: Option<u32>| match code.unwrap_or(2) {
|
||||
1 => None,
|
||||
other => Some(super::eresult::describe(other as i32)),
|
||||
};
|
||||
let asked = |e: super::link::CallError| super::chat::refusal(&e);
|
||||
let mut made = None;
|
||||
match &what {
|
||||
Change::Add { group, friend } => {
|
||||
let answer: ClientAddFriendToGroupResponse = link.ask(emsg::CLIENT_ADD_FRIEND_TO_GROUP, &ClientAddFriendToGroup { groupid: Some(*group), steamiduser: Some(*friend) }).await.map_err(asked)?;
|
||||
if let Some(why) = refused(answer.eresult) {
|
||||
return Err(why);
|
||||
}
|
||||
}
|
||||
Change::Remove { group, friend } => {
|
||||
let answer: ClientRemoveFriendFromGroupResponse = link.ask(emsg::CLIENT_REMOVE_FRIEND_FROM_GROUP, &ClientRemoveFriendFromGroup { groupid: Some(*group), steamiduser: Some(*friend) }).await.map_err(asked)?;
|
||||
if let Some(why) = refused(answer.eresult) {
|
||||
return Err(why);
|
||||
}
|
||||
}
|
||||
Change::Create { name, friends } => {
|
||||
let answer: ClientCreateFriendsGroupResponse = link
|
||||
.ask(emsg::CLIENT_CREATE_FRIENDS_GROUP, &ClientCreateFriendsGroup { steamid: Some(own), groupname: Some(name.clone()), steamid_friends: friends.clone() })
|
||||
.await
|
||||
.map_err(asked)?;
|
||||
if let Some(why) = refused(answer.eresult) {
|
||||
return Err(why);
|
||||
}
|
||||
made = answer.groupid;
|
||||
}
|
||||
Change::Rename { group, name } => {
|
||||
let answer: ClientManageFriendsGroupResponse =
|
||||
link.ask(emsg::CLIENT_MANAGE_FRIENDS_GROUP, &ClientManageFriendsGroup { groupid: Some(*group), groupname: Some(name.clone()), ..Default::default() }).await.map_err(asked)?;
|
||||
if let Some(why) = refused(answer.eresult) {
|
||||
return Err(why);
|
||||
}
|
||||
}
|
||||
Change::Delete { group } => {
|
||||
let answer: ClientDeleteFriendsGroupResponse = link.ask(emsg::CLIENT_DELETE_FRIENDS_GROUP, &ClientDeleteFriendsGroup { steamid: Some(own), groupid: Some(*group) }).await.map_err(asked)?;
|
||||
if let Some(why) = refused(answer.eresult) {
|
||||
return Err(why);
|
||||
}
|
||||
}
|
||||
}
|
||||
// What Steam has just agreed to, held now; if it also says so, it changes nothing.
|
||||
let groups = {
|
||||
let mut all = HELD.lock().unwrap();
|
||||
let groups = all.entry(account_id.to_string()).or_default();
|
||||
match what {
|
||||
Change::Add { group, friend } => groups.put_member(group, friend, true),
|
||||
Change::Remove { group, friend } => groups.put_member(group, friend, false),
|
||||
Change::Create { name, friends } => {
|
||||
if let Some(id) = made {
|
||||
groups.put_group(id, &name, &friends);
|
||||
}
|
||||
}
|
||||
Change::Rename { group, name } => groups.rename(group, &name),
|
||||
Change::Delete { group } => groups.remove(group),
|
||||
}
|
||||
groups.clone()
|
||||
};
|
||||
tell(state, account_id, &groups);
|
||||
unfile_conversations(state, account_id);
|
||||
Ok(made)
|
||||
}
|
||||
|
||||
/// The friend a window names by their id as text.
|
||||
pub fn friend_of(text: &str) -> Option<u64> {
|
||||
text.parse::<u64>().ok().filter(|id| steamid::account_of(*id).is_some())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const A: u64 = 76561197960287930;
|
||||
const B: u64 = 76561197960265729;
|
||||
|
||||
fn list(groups: &[(i32, &str)], members: &[(u64, i32)], incremental: bool, removal: bool) -> ClientFriendsGroupsList {
|
||||
ClientFriendsGroupsList {
|
||||
bremoval: Some(removal),
|
||||
bincremental: Some(incremental),
|
||||
friendGroups: groups.iter().map(|(id, name)| FriendGroup { nGroupID: Some(*id), strGroupName: Some(name.to_string()) }).collect(),
|
||||
memberships: members.iter().map(|(f, g)| FriendGroupMembership { ulSteamID: Some(*f), nGroupID: Some(*g) }).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_conversation_filed_under_a_heading_is_taken_out_of_it() {
|
||||
let state = crate::backend::kick::testkit::simulated_daemon("steam-groups-unfile");
|
||||
let account = "steam:unfile";
|
||||
let buffer = state.runtime.ensure_buffer(&state, account, "Gabe", "dm");
|
||||
state.runtime.set_buffer_category(&state, &buffer.id, Some("QC"), 0);
|
||||
assert_eq!(state.runtime.get_buffer(&buffer.id).unwrap().category.as_deref(), Some("QC"));
|
||||
unfile_conversations(&state, account);
|
||||
assert_eq!(state.runtime.get_buffer(&buffer.id).unwrap().category, None, "a heading nobody can move, rename or remove is not left on it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_steam_says_is_the_groups_and_who_is_in_them() {
|
||||
let mut groups = Groups::default();
|
||||
assert!(groups.apply(&list(&[(7, "Raiders"), (8, "Work")], &[(A, 7), (B, 7), (B, 8)], false, false)));
|
||||
assert_eq!(
|
||||
groups.to_json(),
|
||||
json!([{ "id": 7, "name": "Raiders", "members": [B.to_string(), A.to_string()] }, { "id": 8, "name": "Work", "members": [B.to_string()] }])
|
||||
);
|
||||
assert!(!groups.apply(&list(&[(7, "Raiders")], &[(A, 7)], true, false)), "the same said again changes nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_change_is_one_thing_and_a_full_list_is_everything() {
|
||||
let mut groups = Groups::default();
|
||||
groups.apply(&list(&[(7, "Raiders"), (8, "Work")], &[(A, 7), (B, 8)], false, false));
|
||||
// Incremental: a rename, somebody moved.
|
||||
assert!(groups.apply(&list(&[(7, "Raid Team")], &[(B, 7)], true, false)));
|
||||
assert_eq!(groups.names[&7], "Raid Team");
|
||||
assert!(groups.members[&7].contains(&B) && groups.members[&7].contains(&A));
|
||||
// A removal of a person from a group, and of a group.
|
||||
assert!(groups.apply(&list(&[], &[(A, 7)], true, true)));
|
||||
assert!(!groups.members[&7].contains(&A));
|
||||
assert!(groups.apply(&list(&[(8, "")], &[], true, true)));
|
||||
assert!(!groups.names.contains_key(&8) && !groups.members.contains_key(&8));
|
||||
// A full list replaces what was held.
|
||||
assert!(groups.apply(&list(&[(9, "New")], &[], false, false)));
|
||||
assert_eq!(groups.names.keys().copied().collect::<Vec<_>>(), vec![9]);
|
||||
// Somebody in a group nobody has named is in no group.
|
||||
assert!(!groups.apply(&list(&[], &[(A, 55)], true, false)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn changes_are_asked_of_steam_and_kept_when_it_agrees() {
|
||||
use crate::backend::steam::link::{self, fake::*};
|
||||
use prost::Message;
|
||||
let state = crate::backend::kick::testkit::simulated_daemon("steam-groups-change");
|
||||
let url = serve(Box::new(|p| match p.emsg {
|
||||
emsg::CLIENT_CREATE_FRIENDS_GROUP => {
|
||||
let ask = ClientCreateFriendsGroup::decode(p.body.as_slice()).unwrap();
|
||||
assert_eq!((ask.steamid, ask.groupname.as_deref(), ask.steamid_friends.clone()), (Some(A), Some("Raiders"), vec![B]));
|
||||
Some(vec![answer(&p, 1, ClientCreateFriendsGroupResponse { eresult: Some(1), groupid: Some(7) }.encode_to_vec())])
|
||||
}
|
||||
emsg::CLIENT_ADD_FRIEND_TO_GROUP => Some(vec![answer(&p, 1, ClientAddFriendToGroupResponse { eresult: Some(1) }.encode_to_vec())]),
|
||||
emsg::CLIENT_REMOVE_FRIEND_FROM_GROUP => Some(vec![answer(&p, 1, ClientRemoveFriendFromGroupResponse { eresult: Some(15) }.encode_to_vec())]),
|
||||
_ => Some(vec![]),
|
||||
}))
|
||||
.await;
|
||||
let (link, _told) = link::open_with("test", &url, std::time::Duration::from_secs(5)).await.unwrap();
|
||||
let account = "steam:groups-test";
|
||||
let made = change(&state, &link, account, A, Change::Create { name: "Raiders".into(), friends: vec![B] }).await.unwrap();
|
||||
assert_eq!(made, Some(7));
|
||||
assert_eq!(held(account).to_json(), json!([{ "id": 7, "name": "Raiders", "members": [B.to_string()] }]));
|
||||
change(&state, &link, account, A, Change::Add { group: 7, friend: A }).await.unwrap();
|
||||
assert!(held(account).members[&7].contains(&A));
|
||||
// Steam refusing leaves what was held as it was, and says why.
|
||||
assert!(change(&state, &link, account, A, Change::Remove { group: 7, friend: A }).await.is_err());
|
||||
assert!(held(account).members[&7].contains(&A));
|
||||
clear(account);
|
||||
assert_eq!(held(account), Groups::default());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
//! What was said before, and what has been read.
|
||||
//!
|
||||
//! Steam keeps every conversation, so nothing here is moho's to lose: opening
|
||||
//! a conversation asks for its recent end, scrolling up asks for the part
|
||||
//! before, and signing in asks what arrived while nobody was listening. What
|
||||
//! comes back is stored under the same id a live message gets - the second
|
||||
//! Steam stamped it and where it fell within that second - so hearing a
|
||||
//! message twice, once live and once in history, is hearing it once.
|
||||
//!
|
||||
//! Reading is shared the other way too: looking at a conversation here tells
|
||||
//! Steam it is read, so a phone stops buzzing about it, and Steam tells us
|
||||
//! when it was read there.
|
||||
|
||||
use super::chat::message_id;
|
||||
use super::friends::Roster;
|
||||
use super::publish::Conversations;
|
||||
use super::link::{CallError, Link};
|
||||
use super::proto::{AckMessage, GetActiveSessionsRequest, GetActiveSessionsResponse, GetRecentMessagesRequest, GetRecentMessagesResponse, PastMessage};
|
||||
use super::steamid;
|
||||
use crate::model::Buffer;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// How many conversations a catch-up reads, most recent first.
|
||||
const CATCH_UP_CONVERSATIONS: usize = 30;
|
||||
|
||||
/// How many messages one request asks for. Steam's own client loads fifty at
|
||||
/// a time as you scroll up.
|
||||
pub const PAGE: u32 = 50;
|
||||
|
||||
/// One message out of the past, oldest-first once it leaves `fetch`.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Past {
|
||||
pub account: u32,
|
||||
pub timestamp: u32,
|
||||
pub ordinal: u32,
|
||||
pub text: String,
|
||||
/// Who has reacted with what, as held here; empty in a test that has none.
|
||||
pub reactions: Vec<crate::model::Reaction>,
|
||||
/// The web pages Steam's client described in it, which are cards.
|
||||
pub embeds: Vec<crate::model::Embed>,
|
||||
/// An invitation to a group chat, if that is what it is.
|
||||
pub invite: Option<(u64, Option<u64>, String)>,
|
||||
}
|
||||
|
||||
impl Past {
|
||||
pub fn id(&self) -> String {
|
||||
message_id(self.timestamp, self.ordinal)
|
||||
}
|
||||
}
|
||||
|
||||
fn read(message: &PastMessage, own: u32) -> Option<Past> {
|
||||
let account = message.accountid.filter(|a| *a != 0)?;
|
||||
// Asked for with its tags, and read into what moho draws.
|
||||
let rendered = super::bbcode::render_message(message.message.as_deref().unwrap_or_default());
|
||||
let (text, embeds) = (rendered.text, rendered.cards);
|
||||
if text.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let invite = super::chatgroups::invite_in(message.message.as_deref().unwrap_or_default());
|
||||
Some(Past { account, timestamp: message.timestamp.unwrap_or_default(), ordinal: message.ordinal.unwrap_or_default(), text, reactions: super::reactions::from_history(own, &message.reactions), embeds, invite })
|
||||
}
|
||||
|
||||
/// The conversations Steam has said have nothing before what is held: asking again for the part
|
||||
/// before would get the same answer, and it is asked every time the top is scrolled to.
|
||||
static AT_START: std::sync::LazyLock<std::sync::Mutex<std::collections::HashSet<String>>> = std::sync::LazyLock::new(Default::default);
|
||||
|
||||
/// Whether this conversation is known to be held back to its first message.
|
||||
pub fn at_start(buffer_id: &str) -> bool {
|
||||
AT_START.lock().unwrap().contains(buffer_id)
|
||||
}
|
||||
|
||||
/// Remembers that there is nothing before what is held, or that there is after all.
|
||||
pub fn note_start(buffer_id: &str, reached: bool) {
|
||||
let mut held = AT_START.lock().unwrap();
|
||||
if reached {
|
||||
held.insert(buffer_id.to_string());
|
||||
} else {
|
||||
held.remove(buffer_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks for up to `count` messages of a conversation that are older than
|
||||
/// `older_than` - a second and an ordinal within it - or the newest when that
|
||||
/// is absent. Oldest first, and whether there are more before them.
|
||||
pub async fn fetch(link: &Link, own: u64, friend: u64, older_than: Option<(u32, u32)>, count: u32) -> Result<(Vec<Past>, bool), String> {
|
||||
let (time_last, ordinal_last) = older_than.unwrap_or((u32::MAX, 0));
|
||||
let request = GetRecentMessagesRequest {
|
||||
steamid1: Some(own),
|
||||
steamid2: Some(friend),
|
||||
count: Some(count),
|
||||
rtime32_start_time: Some(0),
|
||||
start_ordinal: Some(0),
|
||||
time_last: Some(time_last),
|
||||
ordinal_last: Some(ordinal_last),
|
||||
bbcode_format: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
let answer: GetRecentMessagesResponse = link.call("FriendMessages.GetRecentMessages#1", &request, true).await.map_err(|e| super::chat::refusal(&e))?;
|
||||
let own_number = steamid::account_of(own).unwrap_or_default();
|
||||
let mut messages: Vec<Past> = answer.messages.iter().filter_map(|m| read(m, own_number)).collect();
|
||||
// Steam answers newest first.
|
||||
messages.reverse();
|
||||
Ok((messages, answer.more_available.unwrap_or(false)))
|
||||
}
|
||||
|
||||
/// Everything Steam holds a session for: who it has had messages with, when it
|
||||
/// was last looked at, and how many are unread.
|
||||
pub async fn sessions(link: &Link, since: u32) -> Result<GetActiveSessionsResponse, CallError> {
|
||||
link.call("FriendMessages.GetActiveMessageSessions#1", &GetActiveSessionsRequest { lastmessage_since: Some(since), only_sessions_with_messages: Some(true) }, true).await
|
||||
}
|
||||
|
||||
/// Says a conversation has been read up to a moment.
|
||||
pub fn ack(link: &Link, friend: u64, timestamp: u32) -> Result<(), CallError> {
|
||||
link.notify("FriendMessages.AckMessage#1", &AckMessage { steamid_partner: Some(friend), timestamp: Some(timestamp) })
|
||||
}
|
||||
|
||||
/// The friend's 64-bit id from the conversation, and our own number.
|
||||
fn people(state: &AppState, buffer: &Buffer) -> Result<(u64, u64), String> {
|
||||
let friend: u64 = buffer.remote_id.as_deref().and_then(|id| id.parse().ok()).filter(|id| steamid::account_of(*id).is_some()).ok_or("this conversation is not with a Steam account")?;
|
||||
let own = state.accounts.get_steam(&buffer.account_id).map(|c| c.steamid).ok_or("this Steam account is not set up")?;
|
||||
Ok((own, friend))
|
||||
}
|
||||
|
||||
/// Puts messages in a conversation's log without announcing them - they are
|
||||
/// old, and a window told about each as news would treat them as unread.
|
||||
/// Returns how many were new to the log.
|
||||
pub fn store_quietly(state: &AppState, buffer: &Buffer, own: u32, past: &[Past]) -> usize {
|
||||
let own_name = state.runtime.own_identity(&buffer.account_id).unwrap_or_default();
|
||||
let mut added = 0;
|
||||
for p in past {
|
||||
let is_own = p.account == own;
|
||||
let from = if is_own { own_name.as_str() } else { buffer.name.as_str() };
|
||||
let sender = (!is_own).then(|| steamid::text_of(p.account));
|
||||
let text = if is_own { p.text.clone() } else { super::account::filtered(&buffer.account_id, true, &p.text) };
|
||||
let stored = state.store.append_message(&buffer.id, &p.id(), from, &text, i64::from(p.timestamp), false, false, "chat", None, &p.reactions, is_own, None, &p.embeds, &[], sender.as_deref(), None, "dm", None, &[]);
|
||||
if matches!(stored, Ok(true)) {
|
||||
added += 1;
|
||||
} else if !p.reactions.is_empty() {
|
||||
// Already held, but reacted to since: Steam's list is the truth.
|
||||
let _ = state.store.set_reactions(&buffer.id, &p.id(), &p.reactions);
|
||||
}
|
||||
}
|
||||
added
|
||||
}
|
||||
|
||||
/// The recent end of a conversation, stored: what opening it asks for.
|
||||
pub async fn open(state: &AppState, buffer: &Buffer) -> Result<usize, String> {
|
||||
let link = state.runtime.steam_link(&buffer.account_id).ok_or("this Steam account is not connected")?;
|
||||
let (own, friend) = people(state, buffer)?;
|
||||
let (past, _) = fetch(&link, own, friend, None, PAGE).await?;
|
||||
Ok(store_quietly(state, buffer, steamid::account_of(own).unwrap_or_default(), &past))
|
||||
}
|
||||
|
||||
/// The part before what is shown, stored: what scrolling to the top asks for.
|
||||
/// `before` is the second of the oldest message on screen.
|
||||
pub async fn older(state: &AppState, buffer: &Buffer, before: i64) -> Result<usize, String> {
|
||||
// Steam has already said there is nothing before this: no question to ask again.
|
||||
if at_start(&buffer.id) {
|
||||
return Ok(0);
|
||||
}
|
||||
let link = state.runtime.steam_link(&buffer.account_id).ok_or("this Steam account is not connected")?;
|
||||
let (own, friend) = people(state, buffer)?;
|
||||
let before = u32::try_from(before).map_err(|_| "that is not a time Steam knows".to_string())?;
|
||||
// Up to and including the second asked about, whichever ordinal: what is
|
||||
// already stored is recognised and not stored again.
|
||||
let (past, more) = fetch(&link, own, friend, Some((before, u32::MAX)), PAGE).await?;
|
||||
note_start(&buffer.id, !more);
|
||||
Ok(store_quietly(state, buffer, steamid::account_of(own).unwrap_or_default(), &past))
|
||||
}
|
||||
|
||||
/// Tells Steam a conversation has been read, up to its newest message.
|
||||
pub fn mark_read(state: &AppState, buffer: &Buffer) -> Result<(), String> {
|
||||
let link = state.runtime.steam_link(&buffer.account_id).ok_or("this Steam account is not connected")?;
|
||||
let (_, friend) = people(state, buffer)?;
|
||||
let newest = state.store.get_backlog(&buffer.id, 0, 1).ok().and_then(|m| m.last().map(|m| m.ts)).filter(|t| *t > 0);
|
||||
let at = newest.and_then(|t| u32::try_from(t).ok()).unwrap_or_else(|| std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs() as u32);
|
||||
ack(&link, friend, at).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Catches up on what arrived while nobody was listening: for each
|
||||
/// conversation Steam says has something this log does not, the newest page of
|
||||
/// it. What was sent to us and not yet looked at anywhere is announced as news,
|
||||
/// so it counts as unread and is notified; everything else - what we sent from
|
||||
/// another client, what was read there - is only put in the log.
|
||||
///
|
||||
/// Returns how many unread conversations had no friend named yet to be read
|
||||
/// into, so the caller can try again as the names arrive.
|
||||
///
|
||||
/// Run after a connection settles, and so on every reconnect as well as every
|
||||
/// start: whatever the gap was, this is what fills it. A conversation whose log
|
||||
/// is already up to date is left alone, and one with no log at all is left for
|
||||
/// opening it, which asks for the same.
|
||||
pub async fn catch_up(state: &AppState, link: &Link, account_id: &str, own: u32, roster: &Roster, shown: &Conversations) -> usize {
|
||||
let answer = match sessions(link, 0).await {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
tracing::info!("steam[{account_id}]: could not ask what was missed: {e}");
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
let mut unnamed = 0;
|
||||
let own_id = steamid::steamid_of(own);
|
||||
// The recently active first, and only so many: a start must not spend its
|
||||
// first minute reading what was said to somebody years ago. What is left
|
||||
// is read when the conversation is opened, which asks for the same.
|
||||
let mut sessions = answer.message_sessions;
|
||||
sessions.sort_by_key(|s| std::cmp::Reverse(s.last_message.unwrap_or(0)));
|
||||
tracing::debug!("steam[{account_id}]: {} conversation(s) with messages, {} unread", sessions.len(), sessions.iter().filter(|s| s.unread_message_count.unwrap_or(0) > 0).count());
|
||||
sessions.truncate(CATCH_UP_CONVERSATIONS);
|
||||
for session in sessions {
|
||||
let Some(friend) = session.accountid_friend else { continue };
|
||||
let Some(name) = shown.name_of(friend) else {
|
||||
if session.unread_message_count.unwrap_or(0) > 0 {
|
||||
unnamed += 1;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let buffer_id = crate::model::buffer_id(account_id, name);
|
||||
let Some(buffer) = state.runtime.get_buffer(&buffer_id) else {
|
||||
tracing::debug!("steam[{account_id}]: no conversation {buffer_id} to read what was missed into");
|
||||
continue;
|
||||
};
|
||||
let newest_held = state.store.get_backlog(&buffer_id, 0, 1).ok().and_then(|m| m.last().map(|m| m.ts)).unwrap_or(0);
|
||||
let unread = session.unread_message_count.unwrap_or(0);
|
||||
let behind = newest_held > 0 && i64::from(session.last_message.unwrap_or(0)) > newest_held;
|
||||
tracing::debug!("steam[{account_id}]: {name}: {unread} unread, last message {}, held up to {newest_held}, behind: {behind}", session.last_message.unwrap_or(0));
|
||||
if unread == 0 && !behind {
|
||||
continue;
|
||||
}
|
||||
let (past, _) = match fetch(link, own_id, steamid::steamid_of(friend), None, PAGE).await {
|
||||
Ok(page) => page,
|
||||
Err(e) => {
|
||||
tracing::info!("steam[{account_id}]: could not catch up on {name}: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let last_view = i64::from(session.last_view.unwrap_or(0));
|
||||
let friend_name = roster.persona(friend).and_then(|p| p.name.clone()).unwrap_or_else(|| name.to_string());
|
||||
let (news, old): (Vec<Past>, Vec<Past>) = past
|
||||
.into_iter()
|
||||
.filter(|p| i64::from(p.timestamp) > newest_held)
|
||||
.partition(|p| p.account != own && i64::from(p.timestamp) > last_view);
|
||||
store_quietly(state, &buffer, own, &old);
|
||||
for p in &news {
|
||||
state.runtime.record_message_at(
|
||||
state,
|
||||
account_id,
|
||||
name,
|
||||
"dm",
|
||||
&friend_name,
|
||||
&super::account::filtered(account_id, true, &p.text),
|
||||
false,
|
||||
"chat",
|
||||
None,
|
||||
Some(p.id()),
|
||||
false,
|
||||
roster.persona(friend).and_then(|x| x.avatar.clone()),
|
||||
p.embeds.clone(),
|
||||
Vec::new(),
|
||||
Some(steamid::text_of(p.account)),
|
||||
Some(i64::from(p.timestamp)),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
if let Some(found) = p.invite.clone().filter(|_| p.account != own) {
|
||||
super::chatgroups::note_invite(state, account_id, &friend_name, found);
|
||||
}
|
||||
}
|
||||
// Read elsewhere, so whatever this window counted is not waiting.
|
||||
if unread == 0 {
|
||||
state.events.emit("bufferRead", serde_json::json!({ "accountId": account_id, "bufferId": buffer_id }));
|
||||
}
|
||||
}
|
||||
unnamed
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::backend::kick::testkit::simulated_daemon;
|
||||
use crate::backend::steam::friends::{Roster, REL_FRIEND};
|
||||
use crate::backend::steam::link::{self, fake::*};
|
||||
use crate::backend::steam::proto::*;
|
||||
use crate::backend::steam::wire::Packet;
|
||||
use prost::Message;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
const ACCOUNT: &str = "steam:tester";
|
||||
const ME: u32 = 22202;
|
||||
const FRIEND: u32 = 100;
|
||||
|
||||
fn past(account: u32, ts: u32, ord: u32, text: &str) -> PastMessage {
|
||||
PastMessage { accountid: Some(account), timestamp: Some(ts), message: Some(text.into()), ordinal: Some(ord), reactions: vec![] }
|
||||
}
|
||||
|
||||
/// A Steam holding a conversation, newest first as it answers, which also
|
||||
/// remembers what it was asked and what it was told.
|
||||
fn steam(history: Vec<PastMessage>, sessions: Vec<MessageSession>, seen: Arc<Mutex<Vec<Packet>>>) -> Script {
|
||||
Box::new(move |p| {
|
||||
seen.lock().unwrap().push(p.clone());
|
||||
let method = p.header.target_job_name.clone().unwrap_or_default();
|
||||
if method.contains("GetRecentMessages") {
|
||||
let ask = GetRecentMessagesRequest::decode(p.body.as_slice()).unwrap();
|
||||
// Older than the boundary, newest first, as many as asked for.
|
||||
let (t, o) = (ask.time_last.unwrap_or(u32::MAX), ask.ordinal_last.unwrap_or(0));
|
||||
let mut older: Vec<PastMessage> = history.iter().filter(|m| (m.timestamp.unwrap_or(0), m.ordinal.unwrap_or(0)) < (t, o)).cloned().collect();
|
||||
older.sort_by_key(|m| std::cmp::Reverse((m.timestamp.unwrap_or(0), m.ordinal.unwrap_or(0))));
|
||||
let count = ask.count.unwrap_or(50) as usize;
|
||||
let more = older.len() > count;
|
||||
older.truncate(count);
|
||||
Some(vec![answer(&p, 1, GetRecentMessagesResponse { messages: older, more_available: Some(more) }.encode_to_vec())])
|
||||
} else if method.contains("GetActiveMessageSessions") {
|
||||
Some(vec![answer(&p, 1, GetActiveSessionsResponse { message_sessions: sessions.clone(), timestamp: Some(1) }.encode_to_vec())])
|
||||
} else {
|
||||
Some(vec![])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn link_to(script: Script) -> Link {
|
||||
let url = serve(script).await;
|
||||
link::open_with("test", &url, Duration::from_secs(5)).await.unwrap().0
|
||||
}
|
||||
|
||||
fn friends_list() -> (Roster, Conversations) {
|
||||
let mut roster = Roster::default();
|
||||
roster.apply_friends_list(&ClientFriendsList {
|
||||
bincremental: Some(false),
|
||||
friends: vec![FriendsListEntry { ulfriendid: Some(steamid::steamid_of(FRIEND)), efriendrelationship: Some(REL_FRIEND) }],
|
||||
..Default::default()
|
||||
});
|
||||
roster.apply_persona(&ClientPersonaState { friends: vec![PersonaFriend { friendid: Some(steamid::steamid_of(FRIEND)), player_name: Some("Gabe".into()), persona_state: Some(1), ..Default::default() }], ..Default::default() });
|
||||
(roster, Conversations::new(ACCOUNT))
|
||||
}
|
||||
|
||||
fn held(state: &AppState) -> Vec<(String, String, bool)> {
|
||||
state.store.get_backlog(&crate::model::buffer_id(ACCOUNT, "Gabe"), 0, 200).unwrap().into_iter().map(|m| (m.id, m.body, m.is_own)).collect()
|
||||
}
|
||||
|
||||
fn five() -> Vec<PastMessage> {
|
||||
vec![past(FRIEND, 100, 0, "one"), past(ME, 110, 0, "two"), past(FRIEND, 120, 0, "three"), past(FRIEND, 120, 1, "same second"), past(ME, 130, 0, "five")]
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn history_comes_back_oldest_first_and_asks_the_way_steams_own_client_does() {
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let link = link_to(steam(five(), vec![], seen.clone())).await;
|
||||
let (newest, more) = fetch(&link, steamid::steamid_of(ME), steamid::steamid_of(FRIEND), None, 3).await.unwrap();
|
||||
assert_eq!(newest.iter().map(|p| p.text.as_str()).collect::<Vec<_>>(), ["three", "same second", "five"], "the newest three, oldest first");
|
||||
assert!(more);
|
||||
let ask = GetRecentMessagesRequest::decode(seen.lock().unwrap()[0].body.as_slice()).unwrap();
|
||||
assert_eq!((ask.steamid1, ask.steamid2, ask.count), (Some(steamid::steamid_of(ME)), Some(steamid::steamid_of(FRIEND)), Some(3)));
|
||||
assert_eq!((ask.rtime32_start_time, ask.time_last, ask.ordinal_last), (Some(0), Some(u32::MAX), Some(0)), "no lower bound, no upper one");
|
||||
|
||||
// Paging: strictly older than a second and an ordinal within it.
|
||||
let (before, more) = fetch(&link, steamid::steamid_of(ME), steamid::steamid_of(FRIEND), Some((120, 0)), 3).await.unwrap();
|
||||
assert_eq!(before.iter().map(|p| p.text.as_str()).collect::<Vec<_>>(), ["one", "two"]);
|
||||
assert!(!more, "that was the start of it");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_message_with_nothing_in_it_or_nobody_behind_it_is_not_history() {
|
||||
let blank = vec![past(FRIEND, 1, 0, " "), past(0, 2, 0, "nobody"), PastMessage { accountid: Some(FRIEND), timestamp: Some(3), message: None, ordinal: None, reactions: vec![] }, past(FRIEND, 4, 0, "kept ")];
|
||||
let link = link_to(steam(blank, vec![], Arc::default())).await;
|
||||
let (got, _) = fetch(&link, steamid::steamid_of(ME), steamid::steamid_of(FRIEND), None, 50).await.unwrap();
|
||||
assert_eq!(got, vec![Past { account: FRIEND, timestamp: 4, ordinal: 0, text: "kept".into(), reactions: vec![], embeds: vec![], invite: None }], "and trailing space is dropped");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stored_history_is_the_same_message_as_one_heard_live() {
|
||||
let state = simulated_daemon("steam-history-dedupe");
|
||||
state.accounts.add_steam(crate::accounts::SteamAccountConfig { username: "tester".into(), steamid: steamid::steamid_of(ME), ..Default::default() }).unwrap();
|
||||
state.runtime.set_own_identity(ACCOUNT, "Me");
|
||||
let (roster, mut shown) = friends_list();
|
||||
shown.publish(&state, &roster);
|
||||
let buffer = state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, "Gabe")).unwrap();
|
||||
let link = link_to(steam(five(), vec![], Arc::default())).await;
|
||||
state.runtime.set_steam_link(ACCOUNT, link);
|
||||
|
||||
// One of them was heard live first, under the id a live message gets.
|
||||
crate::backend::steam::chat::record_incoming(
|
||||
&state, ACCOUNT, &mut friends_list().0, &mut shown, "Me",
|
||||
&crate::backend::steam::chat::Incoming { account: FRIEND, text: "three".into(), timestamp: 120, id: message_id(120, 0), from_us: false, embeds: Vec::new(), invite: None },
|
||||
);
|
||||
assert_eq!(held(&state).len(), 1);
|
||||
|
||||
let added = open(&state, &buffer).await.unwrap();
|
||||
assert_eq!(added, 4, "the four it did not have, not the one it did");
|
||||
let all = held(&state);
|
||||
assert_eq!(all.len(), 5);
|
||||
assert_eq!(all.iter().map(|m| m.1.as_str()).collect::<Vec<_>>().len(), 5);
|
||||
let mine: Vec<&str> = all.iter().filter(|m| m.2).map(|m| m.1.as_str()).collect();
|
||||
assert_eq!(mine.len(), 2, "what we sent is ours: {mine:?}");
|
||||
assert_eq!(open(&state, &buffer).await.unwrap(), 0, "and asking again adds nothing");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scrolling_up_asks_for_what_is_older_and_not_for_what_is_there() {
|
||||
let state = simulated_daemon("steam-history-older");
|
||||
state.accounts.add_steam(crate::accounts::SteamAccountConfig { username: "tester".into(), steamid: steamid::steamid_of(ME), ..Default::default() }).unwrap();
|
||||
let (roster, mut shown) = friends_list();
|
||||
shown.publish(&state, &roster);
|
||||
let buffer = state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, "Gabe")).unwrap();
|
||||
note_start(&buffer.id, false);
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
state.runtime.set_steam_link(ACCOUNT, link_to(steam(five(), vec![], seen.clone())).await);
|
||||
assert!(older(&state, &buffer, -5).await.is_err());
|
||||
// Everything from the second of 120 on is on screen.
|
||||
assert_eq!(older(&state, &buffer, 120).await.unwrap(), 4, "everything up to and including that second is stored, whichever of it was shown");
|
||||
let texts: Vec<String> = held(&state).into_iter().map(|m| m.1).collect();
|
||||
assert_eq!(texts, ["one", "two", "three", "same second"]);
|
||||
// Steam said there was nothing before that, so scrolling to the top again asks nothing.
|
||||
let asked = seen.lock().unwrap().len();
|
||||
assert!(at_start(&buffer.id));
|
||||
assert_eq!(older(&state, &buffer, 20).await.unwrap(), 0);
|
||||
assert_eq!(older(&state, &buffer, 20).await.unwrap(), 0);
|
||||
assert_eq!(seen.lock().unwrap().len(), asked, "no question was sent");
|
||||
note_start(&buffer.id, false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reading_is_told_to_steam_up_to_the_newest_message_held() {
|
||||
let state = simulated_daemon("steam-history-ack");
|
||||
state.accounts.add_steam(crate::accounts::SteamAccountConfig { username: "tester".into(), steamid: steamid::steamid_of(ME), ..Default::default() }).unwrap();
|
||||
let (roster, mut shown) = friends_list();
|
||||
shown.publish(&state, &roster);
|
||||
let buffer = state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, "Gabe")).unwrap();
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
state.runtime.set_steam_link(ACCOUNT, link_to(steam(five(), vec![], seen.clone())).await);
|
||||
open(&state, &buffer).await.unwrap();
|
||||
mark_read(&state, &buffer).unwrap();
|
||||
for _ in 0..100 {
|
||||
if seen.lock().unwrap().iter().any(|p| p.header.target_job_name.as_deref() == Some("FriendMessages.AckMessage#1")) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
let seen = seen.lock().unwrap();
|
||||
let ack = seen.iter().find(|p| p.header.target_job_name.as_deref() == Some("FriendMessages.AckMessage#1")).expect("an ack was sent");
|
||||
let body = AckMessage::decode(ack.body.as_slice()).unwrap();
|
||||
assert_eq!((body.steamid_partner, body.timestamp), (Some(steamid::steamid_of(FRIEND)), Some(130)));
|
||||
assert_eq!(ack.header.jobid_source, None, "a notification, which nothing waits on");
|
||||
}
|
||||
|
||||
fn session(unread: u32, last_message: u32, last_view: u32) -> MessageSession {
|
||||
MessageSession { accountid_friend: Some(FRIEND), last_message: Some(last_message), last_view: Some(last_view), unread_message_count: Some(unread), notices: vec![] }
|
||||
}
|
||||
|
||||
async fn caught_up(name: &str, held_before: Vec<PastMessage>, session: MessageSession) -> (AppState, Vec<serde_json::Value>, Vec<serde_json::Value>) {
|
||||
let state = simulated_daemon(name);
|
||||
state.accounts.add_steam(crate::accounts::SteamAccountConfig { username: "tester".into(), steamid: steamid::steamid_of(ME), ..Default::default() }).unwrap();
|
||||
state.runtime.set_own_identity(ACCOUNT, "Me");
|
||||
let (roster, mut shown) = friends_list();
|
||||
shown.publish(&state, &roster);
|
||||
let buffer = state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, "Gabe")).unwrap();
|
||||
let link = link_to(steam(five(), vec![session], Arc::default())).await;
|
||||
store_quietly(&state, &buffer, ME, &held_before.iter().filter_map(|m| read(m, ME)).collect::<Vec<_>>());
|
||||
let mut rx = state.events.subscribe();
|
||||
let _ = catch_up(&state, &link, ACCOUNT, ME, &roster, &shown).await;
|
||||
let (mut said, mut read_events) = (vec![], vec![]);
|
||||
while let Ok(e) = rx.try_recv() {
|
||||
match e.name {
|
||||
"message" => said.push(e.data),
|
||||
"bufferRead" => read_events.push(e.data),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(state, said, read_events)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn what_arrived_while_away_and_was_not_read_anywhere_is_news() {
|
||||
// Held up to 110; Steam says three since, unread, nothing viewed since 110.
|
||||
let (state, said, read) = caught_up("steam-catchup-news", five()[..2].to_vec(), session(3, 130, 110)).await;
|
||||
let heard: Vec<&str> = said.iter().map(|m| m["body"].as_str().unwrap()).collect();
|
||||
assert_eq!(heard, ["three", "same second"], "what the friend sent after 110 is news; what we sent ('five') is not");
|
||||
assert!(read.is_empty(), "something is still unread");
|
||||
let all: Vec<String> = held(&state).into_iter().map(|m| m.1).collect();
|
||||
assert_eq!(all, ["one", "two", "three", "same second", "five"], "and all of it is in the log, in order");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn what_was_read_on_another_client_is_logged_and_clears_the_unread_count() {
|
||||
let (state, said, read) = caught_up("steam-catchup-read", five()[..2].to_vec(), session(0, 130, 130)).await;
|
||||
assert!(said.is_empty(), "read on the phone, so nothing to announce: {said:?}");
|
||||
assert_eq!(held(&state).len(), 5, "but in the log");
|
||||
assert_eq!(read.len(), 1, "and what this window counted is cleared");
|
||||
assert_eq!(read[0]["bufferId"], crate::model::buffer_id(ACCOUNT, "Gabe"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_conversation_that_is_up_to_date_is_left_alone() {
|
||||
let (state, said, read) = caught_up("steam-catchup-current", five(), session(0, 130, 130)).await;
|
||||
assert!(said.is_empty() && read.is_empty());
|
||||
assert_eq!(held(&state).len(), 5);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_conversation_with_no_log_and_nothing_unread_waits_to_be_opened() {
|
||||
let (state, said, _) = caught_up("steam-catchup-unopened", vec![], session(0, 130, 130)).await;
|
||||
assert!(said.is_empty());
|
||||
assert!(held(&state).is_empty(), "years of history are not fetched for every friend at every start");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Friend invite links: the one an account gives out, and the ones it is given.
|
||||
//!
|
||||
//! Steam's `s.team/p/<code>/<token>`. The code is the account's number written
|
||||
//! in a way that is hard to misread - its hexadecimal digits mapped onto sixteen
|
||||
//! letters that cannot be mistaken for each other or for a number - with a
|
||||
//! hyphen in the middle. The token is a link of the account's own making: it
|
||||
//! lets whoever has it add the account even if it takes no requests from
|
||||
//! strangers, lasts a month, and can be revoked.
|
||||
//!
|
||||
//! The links are made, listed and revoked through Steam's website, as the
|
||||
//! official client does; a link received is read for the account it is from and
|
||||
//! turned into the ordinary request to be friends.
|
||||
|
||||
use super::link::Link;
|
||||
use super::web;
|
||||
use serde_json::Value;
|
||||
|
||||
/// The sixteen letters, in order of the hexadecimal digit each stands for.
|
||||
const LETTERS: &[u8; 16] = b"bcdfghjkmnpqrtvw";
|
||||
|
||||
/// The code an account is known by in a link.
|
||||
pub fn code_of(account: u32) -> String {
|
||||
let code: String = format!("{account:x}").bytes().map(|d| LETTERS[(d as char).to_digit(16).unwrap_or(0) as usize] as char).collect();
|
||||
if code.len() > 3 {
|
||||
let (a, b) = code.split_at(code.len() / 2);
|
||||
format!("{a}-{b}")
|
||||
} else {
|
||||
code
|
||||
}
|
||||
}
|
||||
|
||||
/// The account a code is for, or nothing for text that is not one.
|
||||
pub fn account_in(code: &str) -> Option<u32> {
|
||||
let letters: String = code.chars().filter(|c| *c != '-').collect::<String>().to_ascii_lowercase();
|
||||
if letters.is_empty() || letters.len() > 8 {
|
||||
return None;
|
||||
}
|
||||
let mut value: u64 = 0;
|
||||
for c in letters.bytes() {
|
||||
let digit = LETTERS.iter().position(|l| *l == c)? as u64;
|
||||
value = value * 16 + digit;
|
||||
}
|
||||
u32::try_from(value).ok().filter(|v| *v > 0)
|
||||
}
|
||||
|
||||
/// The account a link or a bare code is from: `s.team/p/<code>[/<token>]`, a
|
||||
/// profile address in the code's form, or the code on its own.
|
||||
pub fn account_of_link(text: &str) -> Option<u32> {
|
||||
let text = text.trim().trim_end_matches('/');
|
||||
let rest = text.split_once("/p/").map(|(_, r)| r).or_else(|| text.split_once("/user/").map(|(_, r)| r)).unwrap_or(text);
|
||||
let code = rest.split(['/', '?', '#']).next()?;
|
||||
account_in(code)
|
||||
}
|
||||
|
||||
/// A link an account has given out.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct InviteLink {
|
||||
pub token: String,
|
||||
pub link: String,
|
||||
pub valid: bool,
|
||||
pub created: u64,
|
||||
pub lasts_seconds: u64,
|
||||
}
|
||||
|
||||
fn read_link(own_code: &str, entry: &Value) -> Option<InviteLink> {
|
||||
let token = entry["invite_token"].as_str().filter(|t| !t.is_empty())?.to_string();
|
||||
Some(InviteLink {
|
||||
link: format!("https://s.team/p/{own_code}/{token}"),
|
||||
valid: entry["valid"].as_i64().unwrap_or(0) != 0,
|
||||
created: entry["time_created"].as_u64().unwrap_or(0),
|
||||
lasts_seconds: entry["invite_duration"].as_str().and_then(|d| d.parse().ok()).or_else(|| entry["invite_duration"].as_u64()).unwrap_or(0),
|
||||
token,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads the list the website gives: the ones still good, newest first.
|
||||
pub fn read_links(own_account: u32, answer: &Value) -> Vec<InviteLink> {
|
||||
let code = code_of(own_account);
|
||||
let mut links: Vec<InviteLink> = answer["tokens"].as_array().into_iter().flatten().filter_map(|t| read_link(&code, t)).filter(|l| l.valid).collect();
|
||||
links.sort_by_key(|l| std::cmp::Reverse(l.created));
|
||||
links
|
||||
}
|
||||
|
||||
async fn website(link: &Link, key: &str, steamid: u64, refresh_token: &str) -> Result<(reqwest::Client, String, String), String> {
|
||||
web::login(link, key, steamid, refresh_token).await
|
||||
}
|
||||
|
||||
fn plain_json(raw: &[u8]) -> Value {
|
||||
serde_json::from_str(&web::plain(raw)).unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
/// The account's own links that are still good.
|
||||
pub async fn list(link: &Link, key: &str, steamid: u64, refresh_token: &str) -> Result<Vec<InviteLink>, String> {
|
||||
let (http, cookie, session) = website(link, key, steamid, refresh_token).await?;
|
||||
let response = http
|
||||
.get("https://steamcommunity.com/invites/ajaxgetall")
|
||||
.query(&[("sessionid", session.as_str())])
|
||||
.header("Cookie", cookie)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("could not reach Steam's website: {e}"))?;
|
||||
let status = response.status();
|
||||
let answer = plain_json(&response.bytes().await.map_err(|e| e.to_string())?);
|
||||
if !status.is_success() {
|
||||
return Err(web::explain(status, &answer));
|
||||
}
|
||||
Ok(read_links(super::steamid::account_of(steamid).unwrap_or_default(), &answer))
|
||||
}
|
||||
|
||||
/// Makes a new link, good for a month.
|
||||
pub async fn create(link: &Link, key: &str, steamid: u64, refresh_token: &str) -> Result<InviteLink, String> {
|
||||
let (http, cookie, session) = website(link, key, steamid, refresh_token).await?;
|
||||
let form = reqwest::multipart::Form::new().text("sessionid", session).text("steamid_user", steamid.to_string()).text("duration", "2592000");
|
||||
let response = http.post("https://steamcommunity.com/invites/ajaxcreate").header("Cookie", cookie).multipart(form).send().await.map_err(|e| format!("could not reach Steam's website: {e}"))?;
|
||||
let status = response.status();
|
||||
let answer = plain_json(&response.bytes().await.map_err(|e| e.to_string())?);
|
||||
if !status.is_success() {
|
||||
return Err(web::explain(status, &answer));
|
||||
}
|
||||
read_link(&code_of(super::steamid::account_of(steamid).unwrap_or_default()), &answer["invite"]).ok_or_else(|| "Steam did not make a link".to_string())
|
||||
}
|
||||
|
||||
/// Takes a link back, so that it is no use to whoever has it.
|
||||
pub async fn revoke(link: &Link, key: &str, steamid: u64, refresh_token: &str, token: &str) -> Result<(), String> {
|
||||
if token.is_empty() || !token.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||
return Err("that is not an invite link".to_string());
|
||||
}
|
||||
let (http, cookie, session) = website(link, key, steamid, refresh_token).await?;
|
||||
let form = reqwest::multipart::Form::new().text("sessionid", session).text("invite_token", token.to_string()).text("steamid_user", steamid.to_string());
|
||||
let response = http.post("https://steamcommunity.com/invites/ajaxrevoke").header("Cookie", cookie).multipart(form).send().await.map_err(|e| format!("could not reach Steam's website: {e}"))?;
|
||||
let status = response.status();
|
||||
let answer = plain_json(&response.bytes().await.map_err(|e| e.to_string())?);
|
||||
if status.is_success() && answer["success"].as_i64() == Some(1) {
|
||||
Ok(())
|
||||
} else if answer["success"].as_i64().is_some_and(|c| c > 1) || !status.is_success() {
|
||||
Err(web::explain(status, &answer))
|
||||
} else {
|
||||
Err("Steam would not revoke that link".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn a_code_is_the_account_number_in_letters() {
|
||||
// Checked against Steam itself: these two are the codes of two real accounts.
|
||||
assert_eq!(code_of(664476208), "dknq-cpfb");
|
||||
assert_eq!(code_of(17167656), "cbh-whdm");
|
||||
assert_eq!(account_in("dknq-cpfb"), Some(664476208));
|
||||
assert_eq!(account_in("CBH-WHDM"), Some(17167656));
|
||||
assert_eq!(account_in("dknqcpfb"), Some(664476208), "the hyphen is for reading");
|
||||
for account in [1u32, 255, 4095, 22202, 664476208, u32::MAX] {
|
||||
assert_eq!(account_in(&code_of(account)), Some(account), "{account}");
|
||||
}
|
||||
assert_eq!(account_in(""), None);
|
||||
assert_eq!(account_in("abcdefgh"), None, "a, e and i are not among the letters");
|
||||
assert_eq!(account_in("bcdfghjkmnpq-rtvw1"), None, "too long to be a number of this kind");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_is_read_for_the_account_it_is_from() {
|
||||
assert_eq!(account_of_link("https://s.team/p/dknq-cpfb/QWERTYUI"), Some(664476208));
|
||||
assert_eq!(account_of_link("https://s.team/p/dknq-cpfb"), Some(664476208));
|
||||
assert_eq!(account_of_link("http://s.team/p/dknq-cpfb/QWERTYUI/"), Some(664476208));
|
||||
assert_eq!(account_of_link("https://steamcommunity.com/user/cbh-whdm/"), Some(17167656));
|
||||
assert_eq!(account_of_link("dknq-cpfb"), Some(664476208));
|
||||
assert_eq!(account_of_link("https://example.com/not/this"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_links_an_account_has_are_read_newest_first_and_only_the_good_ones() {
|
||||
let answer = json!({ "success": 1, "tokens": [
|
||||
{ "invite_token": "AAAAAAAA", "valid": 1, "time_created": 100, "invite_duration": "2592000" },
|
||||
{ "invite_token": "BBBBBBBB", "valid": 0, "time_created": 300, "invite_duration": "2592000" },
|
||||
{ "invite_token": "CCCCCCCC", "valid": 1, "time_created": 200, "invite_duration": "2592000" },
|
||||
{ "valid": 1 }
|
||||
] });
|
||||
let links = read_links(664476208, &answer);
|
||||
assert_eq!(links.iter().map(|l| l.token.as_str()).collect::<Vec<_>>(), ["CCCCCCCC", "AAAAAAAA"]);
|
||||
assert_eq!(links[0].link, "https://s.team/p/dknq-cpfb/CCCCCCCC");
|
||||
assert_eq!(links[0].lasts_seconds, 2592000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
//! One WebSocket to a connection manager, and the two ways of using it.
|
||||
//!
|
||||
//! **Asking.** Most of what a client does is a question: who is this, may I
|
||||
//! sign in, send this. Each is a packet that names a job, and the answer is a
|
||||
//! packet that names the same job; [`Link::call`] sends one and waits for its
|
||||
//! answer, however many others arrive in between.
|
||||
//!
|
||||
//! **Being told.** The rest is things the server says unprompted - a friend
|
||||
//! came online, a message arrived, you were signed out. Those come out of the
|
||||
//! channel [`open`] returns, in the order they arrived, with the batches
|
||||
//! already taken apart.
|
||||
//!
|
||||
//! A `Link` is a cheap handle: cloning it gives another way to ask, and the
|
||||
//! connection closes when the last one is dropped.
|
||||
|
||||
use super::eresult;
|
||||
use super::proto::Header;
|
||||
use super::wire::{self, emsg, Packet, NO_JOB};
|
||||
use anyhow::{Context, Result};
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use prost::Message;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tokio_tungstenite::tungstenite::Message as Ws;
|
||||
|
||||
/// How long to wait for an answer before saying there is none.
|
||||
pub const CALL_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Who this connection is signed in as: stamped on everything sent once it is.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Identity {
|
||||
pub steamid: u64,
|
||||
pub session: i32,
|
||||
}
|
||||
|
||||
/// Why a question got no answer, or a bad one.
|
||||
#[derive(Debug)]
|
||||
pub enum CallError {
|
||||
/// Steam answered, and the answer was no. The code and, where Steam said,
|
||||
/// its own words.
|
||||
Refused(i32, Option<String>),
|
||||
/// The connection ended before the answer came.
|
||||
Closed,
|
||||
/// No answer arrived in time.
|
||||
TimedOut,
|
||||
/// An answer arrived that could not be read.
|
||||
Unreadable(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CallError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CallError::Refused(code, said) => {
|
||||
let words = eresult::describe(*code);
|
||||
// Steam's own words go after ours when they add anything.
|
||||
match said.as_deref().filter(|m| !m.eq_ignore_ascii_case(&words)) {
|
||||
Some(said) => write!(f, "{words} ({said})"),
|
||||
None => write!(f, "{words}"),
|
||||
}
|
||||
}
|
||||
CallError::Closed => write!(f, "the connection to Steam ended"),
|
||||
CallError::TimedOut => write!(f, "Steam did not answer in time"),
|
||||
CallError::Unreadable(why) => write!(f, "an answer from Steam could not be read: {why}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CallError {}
|
||||
|
||||
/// The questions waiting for an answer, and whether there is still a
|
||||
/// connection to answer them.
|
||||
///
|
||||
/// The two are one thing behind one lock so that a question cannot be added
|
||||
/// after the connection ended and before anybody noticed: that question would
|
||||
/// wait out its whole timeout for an answer that cannot come.
|
||||
#[derive(Default)]
|
||||
struct Waiting {
|
||||
ended: bool,
|
||||
by_job: HashMap<u64, oneshot::Sender<Packet>>,
|
||||
}
|
||||
|
||||
type Jobs = Arc<Mutex<Waiting>>;
|
||||
|
||||
struct Inner {
|
||||
out: mpsc::UnboundedSender<Vec<u8>>,
|
||||
jobs: Jobs,
|
||||
next_job: AtomicU64,
|
||||
identity: Mutex<Identity>,
|
||||
timeout: Duration,
|
||||
tasks: [tokio::task::AbortHandle; 2],
|
||||
}
|
||||
|
||||
impl Drop for Inner {
|
||||
fn drop(&mut self) {
|
||||
for task in &self.tasks {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Link(Arc<Inner>);
|
||||
|
||||
/// Opens the connection, routed as `key` is, and starts reading it.
|
||||
pub async fn open(key: &str, url: &str) -> Result<(Link, mpsc::UnboundedReceiver<Packet>)> {
|
||||
open_with(key, url, CALL_TIMEOUT).await
|
||||
}
|
||||
|
||||
pub async fn open_with(key: &str, url: &str, timeout: Duration) -> Result<(Link, mpsc::UnboundedReceiver<Packet>)> {
|
||||
let (socket, _) = crate::net::route::websocket(key, url).await.with_context(|| format!("connecting to {url}"))?;
|
||||
let (mut sink, mut stream) = socket.split();
|
||||
let (out, mut out_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
||||
let (told, told_rx) = mpsc::unbounded_channel::<Packet>();
|
||||
let jobs: Jobs = Arc::new(Mutex::new(Waiting::default()));
|
||||
|
||||
let writer = tokio::spawn(async move {
|
||||
while let Some(bytes) = out_rx.recv().await {
|
||||
if sink.send(Ws::Binary(bytes)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = sink.close().await;
|
||||
});
|
||||
|
||||
let reader = tokio::spawn({
|
||||
let jobs = jobs.clone();
|
||||
let key = key.to_string();
|
||||
async move {
|
||||
let mut why = "the stream ended".to_string();
|
||||
loop {
|
||||
let frame = match stream.next().await {
|
||||
None => break,
|
||||
Some(Err(e)) => {
|
||||
why = format!("a read failed: {e}");
|
||||
break;
|
||||
}
|
||||
Some(Ok(frame)) => frame,
|
||||
};
|
||||
let bytes = match frame {
|
||||
Ws::Binary(bytes) => bytes,
|
||||
Ws::Close(close) => {
|
||||
why = format!("Steam closed it: {}", close.map(|c| format!("{} {}", c.code, c.reason)).unwrap_or_else(|| "no reason given".to_string()));
|
||||
break;
|
||||
}
|
||||
// Pings are answered by the library; text is not a thing
|
||||
// this server sends.
|
||||
_ => continue,
|
||||
};
|
||||
let packet = match Packet::decode(&bytes) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::debug!("steam: a packet that could not be read: {e:#}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let packets = if packet.emsg == emsg::MULTI {
|
||||
match wire::unpack_multi(&packet.body) {
|
||||
Ok(inside) => inside,
|
||||
Err(e) => {
|
||||
tracing::debug!("steam: a batch that could not be read: {e:#}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
vec![packet]
|
||||
};
|
||||
for packet in packets {
|
||||
// An answer goes to whoever asked; anything else is news.
|
||||
let waiting = match packet.job_target() {
|
||||
NO_JOB => None,
|
||||
job => jobs.lock().unwrap().by_job.remove(&job),
|
||||
};
|
||||
match waiting {
|
||||
Some(waiter) => {
|
||||
let _ = waiter.send(packet);
|
||||
}
|
||||
None => {
|
||||
if told.send(packet).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!("steam[{key}]: the connection ended: {why}");
|
||||
// Nobody is going to be answered now. Dropping the waiters is how
|
||||
// each of them finds out, and dropping `told` is how the owner of
|
||||
// the connection does. Marked ended in the same breath, so a
|
||||
// question asked from here on fails at once.
|
||||
let mut waiting = jobs.lock().unwrap();
|
||||
waiting.ended = true;
|
||||
waiting.by_job.clear();
|
||||
}
|
||||
});
|
||||
|
||||
let link = Link(Arc::new(Inner {
|
||||
out,
|
||||
jobs,
|
||||
next_job: AtomicU64::new(1),
|
||||
identity: Mutex::new(Identity::default()),
|
||||
timeout,
|
||||
tasks: [writer.abort_handle(), reader.abort_handle()],
|
||||
}));
|
||||
Ok((link, told_rx))
|
||||
}
|
||||
|
||||
impl Link {
|
||||
/// Who this connection says it is from now on.
|
||||
pub fn set_identity(&self, identity: Identity) {
|
||||
*self.0.identity.lock().unwrap() = identity;
|
||||
}
|
||||
|
||||
pub fn identity(&self) -> Identity {
|
||||
*self.0.identity.lock().unwrap()
|
||||
}
|
||||
|
||||
fn header(&self, signed_in: bool) -> Header {
|
||||
let id = self.identity();
|
||||
Header {
|
||||
steamid: (signed_in && id.steamid != 0).then_some(id.steamid),
|
||||
client_sessionid: (id.session != 0).then_some(id.session),
|
||||
..Header::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the connection is still there to send on.
|
||||
pub fn is_open(&self) -> bool {
|
||||
!self.0.out.is_closed() && !self.0.jobs.lock().unwrap().ended
|
||||
}
|
||||
|
||||
/// Sends a message that expects no answer.
|
||||
pub fn send<M: Message>(&self, emsg: u32, message: &M) -> Result<(), CallError> {
|
||||
let packet = Packet::new(emsg, self.header(true), message.encode_to_vec());
|
||||
self.0.out.send(packet.encode()).map_err(|_| CallError::Closed)
|
||||
}
|
||||
|
||||
/// A service method that is said and not answered: Steam's `SendNotification`,
|
||||
/// as against a call. Nothing waits for anything.
|
||||
pub fn notify<Q: Message>(&self, method: &str, request: &Q) -> Result<(), CallError> {
|
||||
let mut header = self.header(true);
|
||||
header.target_job_name = Some(method.to_string());
|
||||
header.realm = Some(1);
|
||||
let packet = Packet::new(emsg::SERVICE_METHOD_CALL_FROM_CLIENT, header, request.encode_to_vec());
|
||||
self.0.out.send(packet.encode()).map_err(|_| CallError::Closed)
|
||||
}
|
||||
|
||||
/// A question to Steam in one of its older messages, and the answer.
|
||||
///
|
||||
/// These are not service methods: there is no method name, and the
|
||||
/// result code is inside the answer rather than in its header, so it is
|
||||
/// the caller's to read. What matches the answer to the question is the
|
||||
/// same job id.
|
||||
pub async fn ask<Q: Message, A: Message + Default>(&self, emsg: u32, request: &Q) -> Result<A, CallError> {
|
||||
let job = self.0.next_job.fetch_add(1, Ordering::Relaxed);
|
||||
let (tx, rx) = oneshot::channel();
|
||||
{
|
||||
let mut waiting = self.0.jobs.lock().unwrap();
|
||||
if waiting.ended {
|
||||
return Err(CallError::Closed);
|
||||
}
|
||||
waiting.by_job.insert(job, tx);
|
||||
}
|
||||
let mut header = self.header(true);
|
||||
header.jobid_source = Some(job);
|
||||
let packet = Packet::new(emsg, header, request.encode_to_vec());
|
||||
if self.0.out.send(packet.encode()).is_err() {
|
||||
self.0.jobs.lock().unwrap().by_job.remove(&job);
|
||||
return Err(CallError::Closed);
|
||||
}
|
||||
let answer = match tokio::time::timeout(self.0.timeout, rx).await {
|
||||
Ok(Ok(answer)) => answer,
|
||||
Ok(Err(_)) => return Err(CallError::Closed),
|
||||
Err(_) => {
|
||||
self.0.jobs.lock().unwrap().by_job.remove(&job);
|
||||
return Err(CallError::TimedOut);
|
||||
}
|
||||
};
|
||||
A::decode(answer.body.as_slice()).map_err(|e| CallError::Unreadable(e.to_string()))
|
||||
}
|
||||
|
||||
/// A question to one of Steam's services, and its answer.
|
||||
///
|
||||
/// `method` is the service and method as Steam names them, `Service.Method#1`.
|
||||
/// `signed_in` says whether to speak as the account: the calls that make
|
||||
/// signing in possible are made before there is one.
|
||||
pub async fn call<Q: Message, A: Message + Default>(&self, method: &str, request: &Q, signed_in: bool) -> Result<A, CallError> {
|
||||
let job = self.0.next_job.fetch_add(1, Ordering::Relaxed);
|
||||
let (tx, rx) = oneshot::channel();
|
||||
{
|
||||
let mut waiting = self.0.jobs.lock().unwrap();
|
||||
if waiting.ended {
|
||||
return Err(CallError::Closed);
|
||||
}
|
||||
waiting.by_job.insert(job, tx);
|
||||
}
|
||||
|
||||
let mut header = self.header(signed_in);
|
||||
header.jobid_source = Some(job);
|
||||
header.target_job_name = Some(method.to_string());
|
||||
header.realm = Some(1);
|
||||
let kind = if signed_in { emsg::SERVICE_METHOD_CALL_FROM_CLIENT } else { emsg::SERVICE_METHOD_CALL_FROM_CLIENT_NON_AUTHED };
|
||||
let packet = Packet::new(kind, header, request.encode_to_vec());
|
||||
if self.0.out.send(packet.encode()).is_err() {
|
||||
self.0.jobs.lock().unwrap().by_job.remove(&job);
|
||||
return Err(CallError::Closed);
|
||||
}
|
||||
|
||||
let answer = match tokio::time::timeout(self.0.timeout, rx).await {
|
||||
Ok(Ok(answer)) => answer,
|
||||
Ok(Err(_)) => return Err(CallError::Closed),
|
||||
Err(_) => {
|
||||
self.0.jobs.lock().unwrap().by_job.remove(&job);
|
||||
return Err(CallError::TimedOut);
|
||||
}
|
||||
};
|
||||
let code = answer.eresult();
|
||||
if code != eresult::OK {
|
||||
// Steam asking for its agreement to be accepted puts the page for it in the answer, and
|
||||
// that page is the whole of what can be done about it.
|
||||
let said = answer.header.error_message.filter(|m| !m.is_empty()).or_else(|| (code == eresult::MUST_AGREE_TO_SSA).then(|| agreement_page(&answer.body)).flatten());
|
||||
return Err(CallError::Refused(code, said));
|
||||
}
|
||||
A::decode(answer.body.as_slice()).map_err(|e| CallError::Unreadable(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The address Steam gives for the agreement it wants accepted: the sign-in answers carry it as
|
||||
/// field 7 and the polling answer as field 8.
|
||||
fn agreement_page(body: &[u8]) -> Option<String> {
|
||||
#[derive(Message)]
|
||||
struct Page {
|
||||
#[prost(string, optional, tag = "7")]
|
||||
sign_in: Option<String>,
|
||||
#[prost(string, optional, tag = "8")]
|
||||
poll: Option<String>,
|
||||
}
|
||||
let page = Page::decode(body).ok()?;
|
||||
page.sign_in.or(page.poll).filter(|u| u.starts_with("https://"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod agreement_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_page_for_the_agreement_steam_wants_is_found_in_either_kind_of_answer() {
|
||||
#[derive(Message)]
|
||||
struct Begin {
|
||||
#[prost(string, optional, tag = "7")]
|
||||
url: Option<String>,
|
||||
}
|
||||
#[derive(Message)]
|
||||
struct Poll {
|
||||
#[prost(string, optional, tag = "8")]
|
||||
url: Option<String>,
|
||||
}
|
||||
assert_eq!(agreement_page(&Begin { url: Some("https://login.steampowered.com/a".into()) }.encode_to_vec()).as_deref(), Some("https://login.steampowered.com/a"));
|
||||
assert_eq!(agreement_page(&Poll { url: Some("https://login.steampowered.com/b".into()) }.encode_to_vec()).as_deref(), Some("https://login.steampowered.com/b"));
|
||||
assert_eq!(agreement_page(&Poll { url: Some("javascript:alert(1)".into()) }.encode_to_vec()), None, "only an https address is passed on");
|
||||
assert_eq!(agreement_page(&[0xff, 0xff]), None);
|
||||
}
|
||||
}
|
||||
|
||||
/// A connection manager that is not one: speaks the framing, over a plain
|
||||
/// WebSocket on the loopback, as a script says. For the tests of everything
|
||||
/// built on a connection.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod fake {
|
||||
use super::*;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
/// What a scripted server does with each packet it is sent: the packets to
|
||||
/// send back, in order. Returning `None` closes the connection.
|
||||
pub type Script = Box<dyn FnMut(Packet) -> Option<Vec<Packet>> + Send>;
|
||||
|
||||
/// A server on a free port; the `ws://` address to open.
|
||||
pub async fn serve(mut script: Script) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
let Ok((tcp, _)) = listener.accept().await else { return };
|
||||
let mut ws = tokio_tungstenite::accept_async(tcp).await.unwrap();
|
||||
while let Some(Ok(frame)) = ws.next().await {
|
||||
let Ws::Binary(bytes) = frame else { continue };
|
||||
let packet = Packet::decode(&bytes).expect("a packet from the client");
|
||||
match script(packet) {
|
||||
Some(replies) => {
|
||||
for reply in replies {
|
||||
if ws.send(Ws::Binary(reply.encode())).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let _ = ws.close(None).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
format!("ws://{addr}")
|
||||
}
|
||||
|
||||
/// The answer to a call: the request's job as its target, a result, a body.
|
||||
pub fn answer(request: &Packet, code: i32, body: Vec<u8>) -> Packet {
|
||||
Packet::new(
|
||||
emsg::SERVICE_METHOD_RESPONSE,
|
||||
Header { jobid_target: request.header.jobid_source, eresult: Some(code), ..Header::default() },
|
||||
body,
|
||||
)
|
||||
}
|
||||
|
||||
/// Something the server says unprompted.
|
||||
pub fn tell(emsg: u32, body: Vec<u8>) -> Packet {
|
||||
Packet::new(emsg, Header::default(), body)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::fake::*;
|
||||
use super::*;
|
||||
use crate::backend::steam::proto::*;
|
||||
|
||||
const KEY: &str = "steam:test";
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_question_gets_its_own_answer_whatever_arrives_between() {
|
||||
let url = serve(Box::new(|req| {
|
||||
assert_eq!(req.header.target_job_name.as_deref(), Some("Authentication.GetPasswordRSAPublicKey#1"));
|
||||
let ask = GetPasswordRsaPublicKeyRequest::decode(req.body.as_slice()).unwrap();
|
||||
assert_eq!(ask.account_name.as_deref(), Some("someone"));
|
||||
// News first, to be sure the answer is matched by its job and not
|
||||
// by being the next thing to arrive.
|
||||
Some(vec![
|
||||
tell(emsg::CLIENT_ACCOUNT_INFO, ClientAccountInfo { persona_name: Some("Some One".into()), ..Default::default() }.encode_to_vec()),
|
||||
answer(&req, 1, GetPasswordRsaPublicKeyResponse { publickey_mod: Some("AB".into()), publickey_exp: Some("010001".into()), timestamp: Some(7) }.encode_to_vec()),
|
||||
])
|
||||
}))
|
||||
.await;
|
||||
let (link, mut told) = open(KEY, &url).await.unwrap();
|
||||
let got: GetPasswordRsaPublicKeyResponse = link
|
||||
.call("Authentication.GetPasswordRSAPublicKey#1", &GetPasswordRsaPublicKeyRequest { account_name: Some("someone".into()) }, false)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!((got.publickey_mod.as_deref(), got.timestamp), (Some("AB"), Some(7)));
|
||||
let news = told.recv().await.unwrap();
|
||||
assert_eq!(news.emsg, emsg::CLIENT_ACCOUNT_INFO);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_question_says_who_is_asking_only_once_there_is_somebody() {
|
||||
let seen = Arc::new(Mutex::new(Vec::<Packet>::new()));
|
||||
let url = serve({
|
||||
let seen = seen.clone();
|
||||
Box::new(move |req| {
|
||||
seen.lock().unwrap().push(req.clone());
|
||||
Some(vec![answer(&req, 1, Vec::new())])
|
||||
})
|
||||
})
|
||||
.await;
|
||||
let (link, _told) = open(KEY, &url).await.unwrap();
|
||||
let _: ClientLogOff = link.call("A.B#1", &ClientLogOff {}, false).await.unwrap();
|
||||
link.set_identity(Identity { steamid: 76561197960287930, session: 55 });
|
||||
let _: ClientLogOff = link.call("A.B#1", &ClientLogOff {}, true).await.unwrap();
|
||||
|
||||
let seen = seen.lock().unwrap();
|
||||
assert_eq!(seen[0].emsg, emsg::SERVICE_METHOD_CALL_FROM_CLIENT_NON_AUTHED);
|
||||
assert_eq!(seen[0].header.steamid, None);
|
||||
assert_eq!(seen[0].header.realm, Some(1));
|
||||
assert_eq!(seen[1].emsg, emsg::SERVICE_METHOD_CALL_FROM_CLIENT);
|
||||
assert_eq!(seen[1].header.steamid, Some(76561197960287930));
|
||||
assert_eq!(seen[1].header.client_sessionid, Some(55));
|
||||
assert_ne!(seen[0].header.jobid_source, seen[1].header.jobid_source, "each question is its own job");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_no_is_an_error_carrying_the_code_and_steams_own_words() {
|
||||
let url = serve(Box::new(|req| {
|
||||
let mut no = answer(&req, eresult::RATE_LIMIT_EXCEEDED, Vec::new());
|
||||
no.header.error_message = Some("slow down".into());
|
||||
Some(vec![no])
|
||||
}))
|
||||
.await;
|
||||
let (link, _told) = open(KEY, &url).await.unwrap();
|
||||
let err = link.call::<_, ClientLogOff>("A.B#1", &ClientLogOff {}, false).await.unwrap_err();
|
||||
assert!(matches!(&err, CallError::Refused(84, Some(m)) if m == "slow down"));
|
||||
assert!(err.to_string().contains("limiting"));
|
||||
assert!(err.to_string().contains("(slow down)"), "Steam's own words are kept: {err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_batch_is_taken_apart_and_its_news_arrives_in_order() {
|
||||
let url = serve(Box::new(|_| {
|
||||
let inside = [
|
||||
tell(emsg::CLIENT_FRIENDS_LIST, ClientFriendsList { bincremental: Some(false), ..Default::default() }.encode_to_vec()),
|
||||
tell(emsg::CLIENT_PERSONA_STATE, ClientPersonaState::default().encode_to_vec()),
|
||||
];
|
||||
Some(vec![tell(emsg::MULTI, wire::pack_multi(&inside, true))])
|
||||
}))
|
||||
.await;
|
||||
let (link, mut told) = open(KEY, &url).await.unwrap();
|
||||
link.send(emsg::CLIENT_HEART_BEAT, &ClientHeartBeat::default()).unwrap();
|
||||
assert_eq!(told.recv().await.unwrap().emsg, emsg::CLIENT_FRIENDS_LIST);
|
||||
assert_eq!(told.recv().await.unwrap().emsg, emsg::CLIENT_PERSONA_STATE);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_connection_that_ends_fails_the_questions_waiting_and_closes_the_news() {
|
||||
let url = serve(Box::new(|_| None)).await;
|
||||
let (link, mut told) = open(KEY, &url).await.unwrap();
|
||||
let err = link.call::<_, ClientLogOff>("A.B#1", &ClientLogOff {}, false).await.unwrap_err();
|
||||
assert!(matches!(err, CallError::Closed), "{err:?}");
|
||||
assert!(told.recv().await.is_none(), "the news stops when the connection does");
|
||||
// And asking after it has gone fails at once rather than waiting.
|
||||
let err = link.call::<_, ClientLogOff>("A.B#1", &ClientLogOff {}, false).await.unwrap_err();
|
||||
assert!(matches!(err, CallError::Closed), "{err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_answer_is_a_timeout_and_the_job_is_forgotten() {
|
||||
let url = serve(Box::new(|_| Some(Vec::new()))).await;
|
||||
let (link, _told) = open_with(KEY, &url, Duration::from_millis(150)).await.unwrap();
|
||||
let err = link.call::<_, ClientLogOff>("A.B#1", &ClientLogOff {}, false).await.unwrap_err();
|
||||
assert!(matches!(err, CallError::TimedOut), "{err:?}");
|
||||
assert!(link.0.jobs.lock().unwrap().by_job.is_empty(), "a question nobody is waiting on is not kept");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_answer_that_is_not_what_was_asked_for_is_unreadable_not_a_panic() {
|
||||
let url = serve(Box::new(|req| Some(vec![answer(&req, 1, vec![0xff, 0xff, 0xff])]))).await;
|
||||
let (link, _told) = open(KEY, &url).await.unwrap();
|
||||
let err = link.call::<_, ClientFriendsList>("A.B#1", &ClientLogOff {}, false).await.unwrap_err();
|
||||
assert!(matches!(err, CallError::Unreadable(_)), "{err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dropping_the_last_handle_closes_the_connection() {
|
||||
let closed = Arc::new(tokio::sync::Notify::new());
|
||||
let url = {
|
||||
let closed = closed.clone();
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
let (tcp, _) = listener.accept().await.unwrap();
|
||||
let mut ws = tokio_tungstenite::accept_async(tcp).await.unwrap();
|
||||
while ws.next().await.is_some() {}
|
||||
closed.notify_one();
|
||||
});
|
||||
format!("ws://{addr}")
|
||||
};
|
||||
let (link, told) = open(KEY, &url).await.unwrap();
|
||||
let other = link.clone();
|
||||
drop(link);
|
||||
assert!(other.is_open());
|
||||
drop(other);
|
||||
drop(told);
|
||||
tokio::time::timeout(Duration::from_secs(5), closed.notified()).await.expect("the server saw the connection close");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
//! Adding an account: the sign-in a person takes part in.
|
||||
//!
|
||||
//! The password is typed once, here, and this is where it ends: it is
|
||||
//! encrypted for Steam, sent, and dropped when the sign-in finishes. What the
|
||||
//! account keeps afterwards is the token Steam hands back.
|
||||
//!
|
||||
//! Signing in is a conversation that can take minutes, because Steam may
|
||||
//! want something from a person on a phone. So it runs as its own task and
|
||||
//! talks to the window through events rather than answering one request:
|
||||
//!
|
||||
//! - `steamLoginStatus` - what it is doing, for a line of text
|
||||
//! - `steamLoginNeeds` - what Steam is asking for: a code from the
|
||||
//! authenticator, a code it emailed, an approval in the Steam app, or
|
||||
//! several of those (any one will do)
|
||||
//! - `steamLoginResult` - done, with the account or the reason it failed
|
||||
//!
|
||||
//! and the window answers a code with `submitSteamGuardCode`.
|
||||
|
||||
use super::auth::{self, Begun, Guard};
|
||||
use super::connect;
|
||||
use super::link::{self, Identity};
|
||||
use super::proto::ClientHello;
|
||||
use super::wire::emsg;
|
||||
use super::cm;
|
||||
use crate::accounts::SteamAccountConfig;
|
||||
use crate::state::AppState;
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// How long a sign-in may wait for a person. Steam gives up on a session after
|
||||
/// about this long, and a form left open overnight should not leave a task.
|
||||
const PATIENCE: Duration = Duration::from_secs(5 * 60);
|
||||
|
||||
/// The sign-ins waiting for a code, by id, and the way to give each one.
|
||||
fn waiting() -> &'static Mutex<HashMap<String, mpsc::UnboundedSender<String>>> {
|
||||
static WAITING: std::sync::OnceLock<Mutex<HashMap<String, mpsc::UnboundedSender<String>>>> = std::sync::OnceLock::new();
|
||||
WAITING.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// Gives a sign-in the code it asked for.
|
||||
pub fn submit_code(login_id: &str, code: &str) -> Result<()> {
|
||||
let code = code.trim();
|
||||
if code.is_empty() {
|
||||
bail!("type the code first");
|
||||
}
|
||||
let sender = waiting().lock().unwrap().get(login_id).cloned().ok_or_else(|| anyhow!("that sign-in is not waiting for a code any more"))?;
|
||||
sender.send(code.to_string()).map_err(|_| anyhow!("that sign-in has ended"))
|
||||
}
|
||||
|
||||
/// Stops a sign-in that is waiting, because the form was closed.
|
||||
pub fn cancel(login_id: &str) {
|
||||
waiting().lock().unwrap().remove(login_id);
|
||||
}
|
||||
|
||||
/// What the window is asked for, from what Steam allows.
|
||||
///
|
||||
/// Each option is a way in; any one of them will do. Ones this client has no
|
||||
/// answer to are left out, and if nothing is left the sign-in cannot go on.
|
||||
pub fn options_of(begun: &Begun) -> Vec<serde_json::Value> {
|
||||
begun
|
||||
.confirmations
|
||||
.iter()
|
||||
.filter_map(|c| match &c.guard {
|
||||
Guard::DeviceCode => Some(json!({ "kind": "deviceCode" })),
|
||||
Guard::EmailCode => Some(json!({ "kind": "emailCode", "hint": c.hint })),
|
||||
Guard::DeviceConfirmation => Some(json!({ "kind": "approval", "hint": c.hint })),
|
||||
Guard::EmailConfirmation => Some(json!({ "kind": "emailApproval", "hint": c.hint })),
|
||||
Guard::None | Guard::Other(_) => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Which kind of code to say a typed one is: the authenticator's if that is
|
||||
/// on offer, otherwise the emailed one.
|
||||
pub fn guard_for_code(begun: &Begun) -> Option<Guard> {
|
||||
[Guard::DeviceCode, Guard::EmailCode].into_iter().find(|g| begun.confirmations.iter().any(|c| &c.guard == g))
|
||||
}
|
||||
|
||||
/// Starts a sign-in. Answers at once; the rest is events.
|
||||
pub fn start(state: AppState, login_id: String, username: String, password: String) {
|
||||
let (code_tx, code_rx) = mpsc::unbounded_channel();
|
||||
waiting().lock().unwrap().insert(login_id.clone(), code_tx);
|
||||
tokio::spawn(async move {
|
||||
let outcome = async {
|
||||
let key = crate::net::route::pending_key("steam");
|
||||
let servers = cm::discover(&key, 0).await?;
|
||||
let urls: Vec<String> = servers.iter().take(4).map(|s| s.url()).collect();
|
||||
flow(&state, &login_id, &username, &password, &urls, code_rx).await
|
||||
}
|
||||
.await;
|
||||
waiting().lock().unwrap().remove(&login_id);
|
||||
match outcome {
|
||||
Ok(Some(saved)) => {
|
||||
let account = finish(&saved);
|
||||
connect::spawn(state.clone(), saved);
|
||||
state.events.emit("steamLoginResult", json!({ "loginId": login_id, "success": true, "account": account }));
|
||||
}
|
||||
// The form was closed; nobody is waiting for an answer.
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
let error = format!("{e:#}");
|
||||
tracing::info!("steam: sign-in {login_id}: {error}");
|
||||
state.events.emit("steamLoginResult", json!({ "loginId": login_id, "success": false, "error": error }));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Starts a sign-in by scanning a code with the Steam app on a phone: no name
|
||||
/// and no password are typed here at all. Answers at once; the code to draw
|
||||
/// and the end are events - `steamLoginQr` carries the picture, and the rest is
|
||||
/// as for a typed sign-in.
|
||||
pub fn start_qr(state: AppState, login_id: String) {
|
||||
let (code_tx, code_rx) = mpsc::unbounded_channel();
|
||||
waiting().lock().unwrap().insert(login_id.clone(), code_tx);
|
||||
tokio::spawn(async move {
|
||||
let outcome = async {
|
||||
let key = crate::net::route::pending_key("steam");
|
||||
let servers = cm::discover(&key, 0).await?;
|
||||
let urls: Vec<String> = servers.iter().take(4).map(|s| s.url()).collect();
|
||||
flow_qr(&state, &login_id, &urls, code_rx).await
|
||||
}
|
||||
.await;
|
||||
waiting().lock().unwrap().remove(&login_id);
|
||||
let _ = std::fs::remove_file(qr_path(&login_id));
|
||||
match outcome {
|
||||
Ok(Some(saved)) => {
|
||||
let account = finish(&saved);
|
||||
connect::spawn(state.clone(), saved);
|
||||
state.events.emit("steamLoginResult", json!({ "loginId": login_id, "success": true, "account": account }));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
let error = format!("{e:#}");
|
||||
tracing::info!("steam: sign-in {login_id}: {error}");
|
||||
state.events.emit("steamLoginResult", json!({ "loginId": login_id, "success": false, "error": error }));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn qr_path(login_id: &str) -> std::path::PathBuf {
|
||||
crate::media_cache::transient_dir().join(format!("moho-steam-qr-{}.png", login_id.replace(|c: char| !c.is_ascii_alphanumeric(), "-")))
|
||||
}
|
||||
|
||||
/// The address as a picture a phone can scan. The code is a credential for as
|
||||
/// long as it is shown: whoever scans and approves it is signed in as them.
|
||||
pub fn write_qr(address: &str, path: &std::path::Path) -> Result<()> {
|
||||
let code = qrcode::QrCode::new(address.as_bytes())?;
|
||||
let image = code.render::<image::Luma<u8>>().min_dimensions(320, 320).build();
|
||||
image.save_with_format(path, image::ImageFormat::Png).context("encoding the QR code as PNG")?;
|
||||
let _ = crate::secure::restrict_file_to_owner(path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The scan-to-sign-in itself, on the first of `urls` that answers.
|
||||
pub async fn flow_qr(state: &AppState, login_id: &str, urls: &[String], mut cancelled: mpsc::UnboundedReceiver<String>) -> Result<Option<SteamAccountConfig>> {
|
||||
let status = |detail: &str| state.events.emit("steamLoginStatus", json!({ "loginId": login_id, "detail": detail }));
|
||||
let key = crate::net::route::pending_key("steam");
|
||||
status("connecting to Steam");
|
||||
let mut opened = None;
|
||||
let mut last = anyhow!("no Steam server to connect to");
|
||||
for url in urls {
|
||||
match link::open(&key, url).await {
|
||||
Ok(ok) => {
|
||||
opened = Some(ok);
|
||||
break;
|
||||
}
|
||||
Err(e) => last = e,
|
||||
}
|
||||
}
|
||||
let (link, _told) = opened.ok_or(last).context("reaching Steam")?;
|
||||
link.send(emsg::CLIENT_HELLO, &ClientHello { protocol_version: Some(connect::PROTOCOL_VERSION) })?;
|
||||
|
||||
let (mut begun, address) = auth::begin_qr(&link).await.map_err(|e| anyhow!("{e}"))?;
|
||||
let path = qr_path(login_id);
|
||||
write_qr(&address, &path)?;
|
||||
state.events.emit("steamLoginQr", json!({ "loginId": login_id, "path": path.to_string_lossy() }));
|
||||
status("waiting for the code to be scanned");
|
||||
|
||||
let deadline = tokio::time::Instant::now() + PATIENCE;
|
||||
let tokens = loop {
|
||||
tokio::select! {
|
||||
// The form was closed (or the sender dropped): nobody is waiting.
|
||||
_ = cancelled.recv() => return Ok(None),
|
||||
_ = tokio::time::sleep(begun.interval) => {
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
bail!("the code was not scanned in time; start again");
|
||||
}
|
||||
let polled = auth::poll_session(&link, &mut begun).await.map_err(|e| anyhow!("{}", auth::waiting_failure(e)))?;
|
||||
// A code runs out and is replaced: the picture follows it.
|
||||
if let Some(next) = polled.new_challenge {
|
||||
write_qr(&next, &path)?;
|
||||
state.events.emit("steamLoginQr", json!({ "loginId": login_id, "path": path.to_string_lossy(), "renewed": true }));
|
||||
}
|
||||
if let Some(tokens) = polled.tokens {
|
||||
break tokens;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
status("signed in");
|
||||
let steamid = auth::steamid_of_token(&tokens.refresh_token).ok_or_else(|| anyhow!("Steam's token did not say whose it is"))?;
|
||||
let username = tokens.account_name.clone().map(|n| n.to_lowercase()).ok_or_else(|| anyhow!("Steam did not say the account's name"))?;
|
||||
let config = SteamAccountConfig {
|
||||
username,
|
||||
steamid,
|
||||
display_name: None,
|
||||
refresh_token: tokens.refresh_token,
|
||||
guard_data: tokens.guard_data,
|
||||
session: "chat".to_string(),
|
||||
use_tor: crate::net::route::router().wanted(&key),
|
||||
strict_route: false,
|
||||
};
|
||||
let saved = state.accounts.add_steam(config).context("saving the account")?;
|
||||
Ok(Some(saved))
|
||||
}
|
||||
|
||||
/// What a finished sign-in is worth to the window: the account as it lists,
|
||||
/// and the route the account will be reached by remembered for it.
|
||||
pub fn finish(saved: &SteamAccountConfig) -> serde_json::Value {
|
||||
crate::net::route::router().mark(&saved.account_id(), saved.use_tor);
|
||||
serde_json::to_value(crate::accounts::steam_account_to_json(saved, "connecting", None)).expect("an account serialises")
|
||||
}
|
||||
|
||||
/// The sign-in itself, on the first of `urls` that answers.
|
||||
pub async fn flow(
|
||||
state: &AppState,
|
||||
login_id: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
urls: &[String],
|
||||
mut codes: mpsc::UnboundedReceiver<String>,
|
||||
) -> Result<Option<SteamAccountConfig>> {
|
||||
let status = |detail: &str| state.events.emit("steamLoginStatus", json!({ "loginId": login_id, "detail": detail }));
|
||||
let key = crate::net::route::pending_key("steam");
|
||||
let username = username.trim().to_lowercase();
|
||||
if username.is_empty() || password.is_empty() {
|
||||
bail!("type the account name and the password");
|
||||
}
|
||||
|
||||
status("connecting to Steam");
|
||||
let mut opened = None;
|
||||
let mut last = anyhow!("no Steam server to connect to");
|
||||
for url in urls {
|
||||
match link::open(&key, url).await {
|
||||
Ok(ok) => {
|
||||
opened = Some(ok);
|
||||
break;
|
||||
}
|
||||
Err(e) => last = e,
|
||||
}
|
||||
}
|
||||
let (link, _told) = opened.ok_or(last).context("reaching Steam")?;
|
||||
link.send(emsg::CLIENT_HELLO, &ClientHello { protocol_version: Some(connect::PROTOCOL_VERSION) })?;
|
||||
|
||||
status("signing in");
|
||||
// The note Steam gave last time this machine signed this account in, if there
|
||||
// is one: sent so that Steam knows the machine and does not ask for a Guard
|
||||
// code again.
|
||||
let remembered = state.accounts.get_steam(&format!("steam:{username}")).and_then(|a| a.guard_data).filter(|g| !g.is_empty());
|
||||
let mut begun = auth::begin(&link, &username, password, remembered.as_deref()).await.map_err(|e| anyhow!("{e}"))?;
|
||||
let steamid = begun.steamid;
|
||||
|
||||
let options = options_of(&begun);
|
||||
if !begun.needs_nothing() {
|
||||
if options.is_empty() {
|
||||
bail!("Steam asked for a kind of confirmation this version of moho cannot give");
|
||||
}
|
||||
state.events.emit("steamLoginNeeds", json!({ "loginId": login_id, "options": options }));
|
||||
}
|
||||
|
||||
let deadline = tokio::time::Instant::now() + PATIENCE;
|
||||
let tokens = loop {
|
||||
tokio::select! {
|
||||
code = codes.recv() => {
|
||||
// The form was closed: nobody is waiting.
|
||||
let Some(code) = code else { return Ok(None) };
|
||||
let Some(guard) = guard_for_code(&begun) else { continue };
|
||||
if let Err(e) = auth::send_code(&link, &begun, &guard, &code).await {
|
||||
// Said and asked again: a mistyped code is the ordinary case.
|
||||
state.events.emit("steamLoginNeeds", json!({ "loginId": login_id, "options": options, "error": e.to_string() }));
|
||||
continue;
|
||||
}
|
||||
status("checking the code");
|
||||
}
|
||||
_ = tokio::time::sleep(begun.interval) => {
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
bail!("Steam was left waiting too long; start again");
|
||||
}
|
||||
if let Some(tokens) = auth::poll(&link, &mut begun).await.map_err(|e| anyhow!("{}", auth::waiting_failure(e)))? {
|
||||
break tokens;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let config = SteamAccountConfig {
|
||||
username: tokens.account_name.clone().map(|n| n.to_lowercase()).unwrap_or(username),
|
||||
steamid,
|
||||
display_name: None,
|
||||
refresh_token: tokens.refresh_token,
|
||||
guard_data: tokens.guard_data,
|
||||
// The kind is no longer chosen; kept so older files still read.
|
||||
session: "chat".to_string(),
|
||||
use_tor: crate::net::route::router().wanted(&key),
|
||||
strict_route: false,
|
||||
};
|
||||
if steamid != 0 {
|
||||
link.set_identity(Identity { steamid, session: 0 });
|
||||
}
|
||||
let saved = state.accounts.add_steam(config).context("saving the account")?;
|
||||
Ok(Some(saved))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::backend::kick::testkit::simulated_daemon;
|
||||
use crate::backend::steam::auth::Confirmation;
|
||||
use crate::backend::steam::eresult;
|
||||
use crate::backend::steam::link::fake::*;
|
||||
use crate::backend::steam::proto::*;
|
||||
use crate::backend::steam::wire::Packet;
|
||||
use prost::Message;
|
||||
use rsa::traits::PublicKeyParts;
|
||||
use rsa::RsaPrivateKey;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn begun(guards: &[(Guard, Option<&str>)]) -> Begun {
|
||||
Begun {
|
||||
client_id: 1,
|
||||
request_id: vec![],
|
||||
interval: Duration::from_millis(50),
|
||||
steamid: 76561197960287930,
|
||||
confirmations: guards.iter().map(|(g, h)| Confirmation { guard: g.clone(), hint: h.map(str::to_string) }).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_window_is_offered_every_way_in_that_this_client_can_do() {
|
||||
let b = begun(&[(Guard::DeviceCode, None), (Guard::DeviceConfirmation, Some("Steam app")), (Guard::EmailCode, Some("a***@x.com")), (Guard::Other(6), None)]);
|
||||
let kinds: Vec<_> = options_of(&b).iter().map(|o| o["kind"].as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(kinds, ["deviceCode", "approval", "emailCode"], "a way this client cannot answer is not offered");
|
||||
assert_eq!(options_of(&b)[2]["hint"], "a***@x.com");
|
||||
assert!(options_of(&begun(&[(Guard::None, None)])).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_typed_code_is_the_authenticators_when_that_is_offered_and_otherwise_the_emailed_one() {
|
||||
assert_eq!(guard_for_code(&begun(&[(Guard::EmailCode, None), (Guard::DeviceCode, None)])), Some(Guard::DeviceCode));
|
||||
assert_eq!(guard_for_code(&begun(&[(Guard::EmailCode, None)])), Some(Guard::EmailCode));
|
||||
assert_eq!(guard_for_code(&begun(&[(Guard::DeviceConfirmation, None)])), None, "an approval takes no code");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_code_for_a_sign_in_that_is_not_waiting_is_refused_with_a_reason() {
|
||||
assert!(submit_code("nobody", "ABCDE").unwrap_err().to_string().contains("not waiting"));
|
||||
assert!(submit_code("nobody", " ").unwrap_err().to_string().contains("type the code"));
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
waiting().lock().unwrap().insert("login-x".into(), tx);
|
||||
submit_code("login-x", " ab12c ").unwrap();
|
||||
assert_eq!(rx.try_recv().unwrap(), "ab12c");
|
||||
cancel("login-x");
|
||||
assert!(submit_code("login-x", "x").is_err(), "closing the form ends the wait");
|
||||
}
|
||||
|
||||
/// A Steam that wants the authenticator's code (and would take a phone
|
||||
/// approval), accepts only the right code, and then issues tokens.
|
||||
fn steam(private_n: String, private_e: String, right_code: &'static str, seen: Arc<Mutex<Vec<Packet>>>) -> Script {
|
||||
let mut approved = false;
|
||||
Box::new(move |req| {
|
||||
seen.lock().unwrap().push(req.clone());
|
||||
let method = req.header.target_job_name.clone().unwrap_or_default();
|
||||
let out = match method.as_str() {
|
||||
"Authentication.GetPasswordRSAPublicKey#1" => answer(&req, 1, GetPasswordRsaPublicKeyResponse { publickey_mod: Some(private_n.clone()), publickey_exp: Some(private_e.clone()), timestamp: Some(1) }.encode_to_vec()),
|
||||
"Authentication.BeginAuthSessionViaCredentials#1" => answer(
|
||||
&req,
|
||||
1,
|
||||
BeginAuthViaCredentialsResponse {
|
||||
client_id: Some(99),
|
||||
request_id: Some(vec![1]),
|
||||
interval: Some(0.5),
|
||||
steamid: Some(76561197960287930),
|
||||
allowed_confirmations: vec![
|
||||
AllowedConfirmation { confirmation_type: Some(3), associated_message: None },
|
||||
AllowedConfirmation { confirmation_type: Some(4), associated_message: Some("Steam app".into()) },
|
||||
],
|
||||
..Default::default()
|
||||
}
|
||||
.encode_to_vec(),
|
||||
),
|
||||
"Authentication.UpdateAuthSessionWithSteamGuardCode#1" => {
|
||||
let sent = UpdateAuthWithGuardCodeRequest::decode(req.body.as_slice()).unwrap();
|
||||
if sent.code.as_deref() == Some(right_code) {
|
||||
approved = true;
|
||||
answer(&req, 1, Vec::new())
|
||||
} else {
|
||||
answer(&req, eresult::TWO_FACTOR_CODE_MISMATCH, Vec::new())
|
||||
}
|
||||
}
|
||||
"Authentication.PollAuthSessionStatus#1" if approved => answer(
|
||||
&req,
|
||||
1,
|
||||
PollAuthSessionResponse { refresh_token: Some("refresh".into()), access_token: Some("access".into()), account_name: Some("Someone".into()), new_guard_data: Some("remember".into()), ..Default::default() }.encode_to_vec(),
|
||||
),
|
||||
"Authentication.PollAuthSessionStatus#1" => answer(&req, 1, Vec::new()),
|
||||
_ => answer(&req, 1, Vec::new()),
|
||||
};
|
||||
Some(vec![out])
|
||||
})
|
||||
}
|
||||
|
||||
fn keys() -> (String, String) {
|
||||
let private = RsaPrivateKey::new(&mut rand::thread_rng(), 1024).unwrap();
|
||||
let public = private.to_public_key();
|
||||
(public.n().to_str_radix(16), public.e().to_str_radix(16))
|
||||
}
|
||||
|
||||
async fn next_event(rx: &mut tokio::sync::broadcast::Receiver<crate::events::Event>, name: &str) -> serde_json::Value {
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
loop {
|
||||
let e = rx.recv().await.unwrap();
|
||||
if e.name == name {
|
||||
return e.data;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("no {name} event"))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_person_signs_in_mistypes_the_code_and_then_gets_it_right() {
|
||||
let state = simulated_daemon("steam-login-whole");
|
||||
let mut rx = state.events.subscribe();
|
||||
let (n, e) = keys();
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let url = serve(steam(n, e, "RIGHT", seen.clone())).await;
|
||||
let (code_tx, code_rx) = mpsc::unbounded_channel();
|
||||
|
||||
let task = tokio::spawn({
|
||||
let state = state.clone();
|
||||
async move { flow(&state, "login-1", " Someone ", "hunter2", &[url], code_rx).await }
|
||||
});
|
||||
|
||||
// Steam asks, offering the authenticator and a phone approval.
|
||||
let needs = next_event(&mut rx, "steamLoginNeeds").await;
|
||||
assert_eq!(needs["loginId"], "login-1");
|
||||
let kinds: Vec<_> = needs["options"].as_array().unwrap().iter().map(|o| o["kind"].as_str().unwrap()).collect();
|
||||
assert_eq!(kinds, ["deviceCode", "approval"]);
|
||||
assert!(needs.get("error").is_none());
|
||||
|
||||
// A wrong code is said, and asked for again, and the sign-in carries on.
|
||||
code_tx.send("WRONG".into()).unwrap();
|
||||
let again = next_event(&mut rx, "steamLoginNeeds").await;
|
||||
assert!(again["error"].as_str().unwrap().contains("Steam Guard"), "{again}");
|
||||
|
||||
code_tx.send("RIGHT".into()).unwrap();
|
||||
let saved = task.await.unwrap().unwrap().expect("an account");
|
||||
let account = finish(&saved);
|
||||
assert_eq!(account["id"], "steam:someone");
|
||||
assert_eq!(account["service"], "steam");
|
||||
assert_eq!(account["hasPassword"], true);
|
||||
|
||||
// What was kept: the token and the machine note, never the password.
|
||||
let saved = state.accounts.get_steam("steam:someone").expect("saved under the lower-case name Steam gave");
|
||||
assert_eq!((saved.refresh_token.as_str(), saved.guard_data.as_deref(), saved.steamid), ("refresh", Some("remember"), 76561197960287930));
|
||||
assert_eq!(saved.session, "chat");
|
||||
let file = std::fs::read_to_string(state.accounts.path_for_tests()).unwrap();
|
||||
assert!(file.contains("refresh") && !file.contains("hunter2"), "the token is on disk and the password is not: {file}");
|
||||
|
||||
// Everything Steam was sent about the password was encrypted.
|
||||
let begin = seen.lock().unwrap().iter().find(|p| p.header.target_job_name.as_deref() == Some("Authentication.BeginAuthSessionViaCredentials#1")).cloned().unwrap();
|
||||
assert!(!String::from_utf8_lossy(&begin.body).contains("hunter2"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_machine_that_signed_in_before_says_so() {
|
||||
let state = simulated_daemon("steam-login-remembered");
|
||||
let _ = state.accounts.add_steam(SteamAccountConfig { username: "someone".into(), steamid: 76561197960287930, refresh_token: "old".into(), guard_data: Some("remember-me".into()), session: "chat".into(), ..Default::default() });
|
||||
let (n, e) = keys();
|
||||
let begin_seen = Arc::new(Mutex::new(None::<BeginAuthViaCredentialsRequest>));
|
||||
let url = serve({
|
||||
let seen = begin_seen.clone();
|
||||
Box::new(move |req| {
|
||||
let method = req.header.target_job_name.clone().unwrap_or_default();
|
||||
Some(vec![match method.as_str() {
|
||||
"Authentication.GetPasswordRSAPublicKey#1" => answer(&req, 1, GetPasswordRsaPublicKeyResponse { publickey_mod: Some(n.clone()), publickey_exp: Some(e.clone()), timestamp: Some(1) }.encode_to_vec()),
|
||||
"Authentication.BeginAuthSessionViaCredentials#1" => {
|
||||
*seen.lock().unwrap() = Some(BeginAuthViaCredentialsRequest::decode(req.body.as_slice()).unwrap());
|
||||
// Steam knows the machine: nothing more is asked, and the next poll goes through.
|
||||
answer(&req, 1, BeginAuthViaCredentialsResponse { client_id: Some(9), request_id: Some(vec![1]), interval: Some(0.5), steamid: Some(76561197960287930), ..Default::default() }.encode_to_vec())
|
||||
}
|
||||
"Authentication.PollAuthSessionStatus#1" => answer(&req, 1, PollAuthSessionResponse { refresh_token: Some("new".into()), account_name: Some("someone".into()), new_guard_data: Some("remember-me-too".into()), ..Default::default() }.encode_to_vec()),
|
||||
_ => answer(&req, 1, Vec::new()),
|
||||
}])
|
||||
})
|
||||
})
|
||||
.await;
|
||||
let (_tx, rx) = mpsc::unbounded_channel();
|
||||
let saved = flow(&state, "login-r", "someone", "hunter2", &[url], rx).await.unwrap().expect("signed in");
|
||||
assert_eq!(begin_seen.lock().unwrap().as_ref().and_then(|b| b.guard_data.as_deref()), Some("remember-me"), "the note from last time went with the sign-in");
|
||||
assert_eq!(saved.guard_data.as_deref(), Some("remember-me-too"), "and the new one is kept");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_wrong_password_is_reported_by_the_form_in_steams_words() {
|
||||
let state = simulated_daemon("steam-login-badpass");
|
||||
let (n, e) = keys();
|
||||
let url = serve(Box::new(move |req| {
|
||||
let method = req.header.target_job_name.clone().unwrap_or_default();
|
||||
Some(vec![if method.contains("GetPasswordRSAPublicKey") {
|
||||
answer(&req, 1, GetPasswordRsaPublicKeyResponse { publickey_mod: Some(n.clone()), publickey_exp: Some(e.clone()), timestamp: Some(1) }.encode_to_vec())
|
||||
} else {
|
||||
answer(&req, eresult::INVALID_PASSWORD, Vec::new())
|
||||
}])
|
||||
}))
|
||||
.await;
|
||||
let (_tx, rx) = mpsc::unbounded_channel();
|
||||
let err = flow(&state, "login-2", "someone", "wrong", &[url], rx).await.unwrap_err();
|
||||
assert!(err.to_string().contains("password"), "{err:#}");
|
||||
assert!(state.accounts.get_steam("steam:someone").is_none(), "nothing is saved for a sign-in that failed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_empty_form_and_an_unreachable_steam_are_said_plainly() {
|
||||
let state = simulated_daemon("steam-login-bad");
|
||||
let (_tx, rx) = mpsc::unbounded_channel();
|
||||
assert!(flow(&state, "l", "", "x", &[], rx).await.unwrap_err().to_string().contains("account name"));
|
||||
let (_tx, rx) = mpsc::unbounded_channel();
|
||||
assert!(flow(&state, "l", "x", "", &[], rx).await.unwrap_err().to_string().contains("password"));
|
||||
let (_tx, rx) = mpsc::unbounded_channel();
|
||||
let err = flow(&state, "l", "someone", "x", &["ws://127.0.0.1:1".to_string()], rx).await.unwrap_err();
|
||||
assert!(format!("{err:#}").contains("reaching Steam"), "{err:#}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn closing_the_form_ends_the_wait_without_saving_anything() {
|
||||
let state = simulated_daemon("steam-login-closed");
|
||||
let (n, e) = keys();
|
||||
let url = serve(steam(n, e, "RIGHT", Arc::new(Mutex::new(Vec::new())))).await;
|
||||
let (code_tx, code_rx) = mpsc::unbounded_channel();
|
||||
let task = tokio::spawn({
|
||||
let state = state.clone();
|
||||
async move { flow(&state, "login-3", "someone", "x", &[url], code_rx).await }
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
drop(code_tx);
|
||||
assert!(tokio::time::timeout(Duration::from_secs(5), task).await.expect("it ends").unwrap().unwrap().is_none());
|
||||
assert!(state.accounts.get_steam("steam:someone").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Steam - the friends list and direct messages of a Steam account, over the
|
||||
//! same connection managers the official client uses.
|
||||
//!
|
||||
//! Steam is a directory of servers ("connection managers") that a client opens
|
||||
//! a WebSocket to and speaks protobuf over. Everything a chat client does is
|
||||
//! a message on that one connection: signing in, the friends list, who is
|
||||
//! online, a message sent, a message received. There is no separate chat
|
||||
//! server; the same socket that tells you a friend started a game delivers the
|
||||
//! message they sent.
|
||||
//!
|
||||
//! # How this folder is arranged
|
||||
//!
|
||||
//! - `proto` - the messages, written out from Valve's own definitions
|
||||
//! - `wire` - how a packet is laid out, and the batches that hold packets
|
||||
//! - `steamid` - the two forms of an account's identity
|
||||
//! - `eresult` - the result codes, said in words
|
||||
//! - `auth` - signing in with a name and password, through Steam Guard, to a token
|
||||
//! - `presence` - Steam's states and moho's
|
||||
//! - `friends` - who the friends are, how they are doing, what each conversation is called
|
||||
//! - `chat` - direct messages, in and out
|
||||
//! - `login` - adding an account: the sign-in a person takes part in
|
||||
//! - `connect` - an account's connection: sign in with the token, listen, reconnect
|
||||
//! - `publish` - the roster as the window sees it: a conversation each, named, with a dot
|
||||
//! - `cm` - finding a connection manager to connect to
|
||||
//! - `link` - one connection: asking it things, and hearing what it says
|
||||
|
||||
pub mod auth;
|
||||
pub mod bbcode;
|
||||
pub mod chat;
|
||||
pub mod chatgroups;
|
||||
pub mod cm;
|
||||
pub mod connect;
|
||||
pub mod emoticons;
|
||||
pub mod eresult;
|
||||
pub mod friends;
|
||||
pub mod friendship;
|
||||
pub mod groups;
|
||||
pub mod invites;
|
||||
pub mod history;
|
||||
pub mod link;
|
||||
pub mod login;
|
||||
pub mod account;
|
||||
pub mod apps;
|
||||
pub mod blocked;
|
||||
pub mod notices;
|
||||
pub mod prefs;
|
||||
pub mod presence;
|
||||
pub mod profile;
|
||||
#[cfg(test)]
|
||||
mod probe;
|
||||
pub mod proto;
|
||||
pub mod publish;
|
||||
pub mod reactions;
|
||||
pub mod steamid;
|
||||
pub mod web;
|
||||
pub mod voice;
|
||||
pub mod voicewire;
|
||||
pub mod wire;
|
||||
|
||||
pub use connect::spawn;
|
||||
pub use presence::apply_status;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Tells Steam this session is being left, so it is not shown as signed in
|
||||
/// for a while afterwards as one that was merely dropped. Written to the
|
||||
/// connection; the caller keeps it open long enough for that to be sent.
|
||||
pub fn say_goodbye(link: &link::Link) {
|
||||
let _ = link.send(wire::emsg::CLIENT_LOG_OFF, &proto::ClientLogOff {});
|
||||
}
|
||||
|
||||
/// Ends a session politely: says goodbye, and lets go of the connection a
|
||||
/// moment later, which is how long the goodbye takes to be written.
|
||||
pub fn sign_off(link: link::Link) {
|
||||
say_goodbye(&link);
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
|
||||
drop(link);
|
||||
});
|
||||
}
|
||||
|
||||
/// Says why a refusal may be what it is, when the account is one Steam holds back.
|
||||
pub fn explained(account_id: &str, why: String) -> String {
|
||||
let facts = account::held(account_id);
|
||||
if facts.family_view.is_some_and(|(_, holds_friends)| holds_friends) {
|
||||
return format!("{why} (Family View is on for this account and holds friends and chat back; unlock it in Steam to chat)");
|
||||
}
|
||||
why
|
||||
}
|
||||
|
||||
/// Sends a direct message in the conversation `buffer` is, and stores it.
|
||||
pub async fn send_message(state: &AppState, buffer: &crate::model::Buffer, body: &str) -> Result<(), String> {
|
||||
// A room of a chat group is spoken in as a group, not to a person.
|
||||
if chatgroups::is_room(buffer) {
|
||||
return chatgroups::send(state, buffer, body).await;
|
||||
}
|
||||
let link = state.runtime.steam_link(&buffer.account_id).ok_or("this Steam account is not connected")?;
|
||||
let recipient = buffer.remote_id.clone().ok_or("this conversation is not with a Steam account")?;
|
||||
let sent = chat::send(&link, &recipient, body).await.map_err(|why| {
|
||||
tracing::info!("steam[{}]: a message to {recipient} was not sent: {why}", buffer.account_id);
|
||||
explained(&buffer.account_id, why)
|
||||
})?;
|
||||
tracing::info!("steam[{}]: sent a message to {recipient} as {}", buffer.account_id, sent.id);
|
||||
let own = state.runtime.own_identity(&buffer.account_id).unwrap_or_default();
|
||||
state.runtime.record_message_at(
|
||||
state,
|
||||
&buffer.account_id,
|
||||
&buffer.name,
|
||||
"dm",
|
||||
&own,
|
||||
&sent.shown,
|
||||
false,
|
||||
"chat",
|
||||
None,
|
||||
Some(sent.id),
|
||||
false,
|
||||
None,
|
||||
sent.embeds,
|
||||
Vec::new(),
|
||||
None,
|
||||
Some(i64::from(sent.timestamp)).filter(|t| *t > 0),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sends files to a friend or into a room of a group, and the words that came with them.
|
||||
///
|
||||
/// Steam has no caption: the words go first as a message of their own and each
|
||||
/// file is a message after it, which is how its own client sends the two. The
|
||||
/// messages are written into the conversation by Steam and come back to this
|
||||
/// session as the echo of what another session sent, so nothing is recorded
|
||||
/// here. Stops at the first file Steam refuses and says why.
|
||||
pub async fn send_files(
|
||||
state: &AppState,
|
||||
buffer: &crate::model::Buffer,
|
||||
caption: &str,
|
||||
files: &[(String, bool)],
|
||||
progress: Option<&crate::upload::Progress>,
|
||||
) -> Result<(), String> {
|
||||
let link = state.runtime.steam_link(&buffer.account_id).ok_or("this Steam account is not connected")?;
|
||||
// A room of a group, or a friend.
|
||||
let room = chatgroups::ids_of(buffer).filter(|_| chatgroups::is_room(buffer));
|
||||
let friend = match room {
|
||||
Some(_) => String::new(),
|
||||
None => buffer.remote_id.clone().ok_or("this conversation is not with a Steam account")?,
|
||||
};
|
||||
let target = match room {
|
||||
Some((group, room)) => web::FileTarget::Room { group, room },
|
||||
None => web::FileTarget::Friend(&friend),
|
||||
};
|
||||
let config = state.accounts.get_steam(&buffer.account_id).ok_or("this Steam account is not connected")?;
|
||||
// Refused before anything is sent, so a bad file does not leave the words
|
||||
// sent and the pictures not.
|
||||
for (path, _) in files {
|
||||
let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or(path);
|
||||
let size = std::fs::metadata(path).map(|m| m.len()).map_err(|e| format!("reading {name}: {e}"))?;
|
||||
if let Some(why) = web::refusal(name, size) {
|
||||
return Err(why);
|
||||
}
|
||||
}
|
||||
if !caption.trim().is_empty() {
|
||||
send_message(state, buffer, caption).await?;
|
||||
}
|
||||
for (path, spoiler) in files {
|
||||
let url = web::send_file(&link, &buffer.account_id, config.steamid, &config.refresh_token, target, path, *spoiler, progress)
|
||||
.await
|
||||
.inspect_err(|why| tracing::info!("steam[{}]: a file to {} was not sent: {why}", buffer.account_id, buffer.name))?;
|
||||
tracing::info!("steam[{}]: sent a file to {}: {url}", buffer.account_id, buffer.name);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Steam's warning about a conversation.
|
||||
//!
|
||||
//! Steam can mark a conversation as suspicious - the person may not be who they
|
||||
//! appear to be, a common shape for a scam - and tells the account as a notice.
|
||||
//! It is said twice, so that it is not missed: once in the conversation, in
|
||||
//! words, as a warning line that stays in its place in the log, and again as a
|
||||
//! banner over the conversation that stays until the person dismisses it.
|
||||
//! Dismissing tells Steam, which does not raise it again; if Steam does raise a
|
||||
//! notice again - a new one - the banner comes back. The line is the same fact
|
||||
//! each time Steam raises it, so it is stored under an id of its own and said
|
||||
//! no more than once.
|
||||
|
||||
use super::proto::{DismissSessionNotice, SessionNoticeNotification};
|
||||
use crate::model::BufferNotice;
|
||||
use super::steamid;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// `EChatSessionNotice`.
|
||||
pub const SUSPICIOUS: i32 = 1;
|
||||
|
||||
/// What to tell somebody about a notice, or nothing for one moho does not know.
|
||||
pub fn words(notice_type: i32) -> Option<&'static str> {
|
||||
match notice_type {
|
||||
SUSPICIOUS => Some("Steam warns that this person may not be who they appear to be. Be careful with links, offers and requests for anything - and do not give out your account details."),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Says the notice in the conversation, once. `friend` is who it is about.
|
||||
pub fn say(state: &AppState, account_id: &str, conversation: &str, friend: u32, notice_type: i32) {
|
||||
let Some(text) = words(notice_type) else { return };
|
||||
state.runtime.record_message_at(
|
||||
state,
|
||||
account_id,
|
||||
conversation,
|
||||
"dm",
|
||||
"Steam",
|
||||
text,
|
||||
false,
|
||||
// A kind of its own, which the window draws in red so it is not scrolled past.
|
||||
"warning",
|
||||
None,
|
||||
Some(notice_id(friend, notice_type)),
|
||||
false,
|
||||
None,
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
/// The id a notice is kept under: the same in the log and in what says whether
|
||||
/// it has been dismissed.
|
||||
pub fn notice_id(friend: u32, notice_type: i32) -> String {
|
||||
format!("notice:{friend}:{notice_type}")
|
||||
}
|
||||
|
||||
/// Puts back the banners that were up when moho last ran and have not been
|
||||
/// dismissed. Steam does not say a notice again at every sign-in, so what was
|
||||
/// not seen would otherwise be gone with the restart. A warning that was said in
|
||||
/// the log before anything recorded it is one nobody dismissed, and is shown.
|
||||
pub fn restore(state: &AppState, account_id: &str) {
|
||||
show_pending(state, account_id, None);
|
||||
}
|
||||
|
||||
/// The same for one conversation, for the moment it is made or renamed: a
|
||||
/// conversation known by its number at the start is renamed when Steam says
|
||||
/// what it is called, and the new one has nothing on it until this is said again.
|
||||
pub fn restore_buffer(state: &AppState, account_id: &str, buffer_id: &str) {
|
||||
show_pending(state, account_id, Some(buffer_id));
|
||||
}
|
||||
|
||||
fn show_pending(state: &AppState, account_id: &str, only: Option<&str>) {
|
||||
let Ok(held) = state.store.account_notices(account_id) else { return };
|
||||
for (buffer, id, recorded) in held {
|
||||
if only.is_some_and(|only| only != buffer) {
|
||||
continue;
|
||||
}
|
||||
let kind = id.rsplit(':').next().and_then(|t| t.parse::<i32>().ok()).unwrap_or(SUSPICIOUS);
|
||||
let Some(text) = words(kind) else { continue };
|
||||
match recorded.as_deref() {
|
||||
Some("dismissed") => {}
|
||||
other => {
|
||||
if other.is_none() {
|
||||
let _ = state.store.set_notice_state(&buffer, &id, "pending");
|
||||
}
|
||||
state.runtime.set_buffer_notice(state, &buffer, Some(BufferNotice { kind: "suspicious".to_string(), text: text.to_string() }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dismissed: the banner is taken off, and it stays off across a restart until
|
||||
/// Steam raises a notice again.
|
||||
pub fn dismissed(state: &AppState, buffer_id: &str, friend: u32, notice_type: i32) {
|
||||
let _ = state.store.set_notice_state(buffer_id, ¬ice_id(friend, notice_type), "dismissed");
|
||||
state.runtime.set_buffer_notice(state, buffer_id, None);
|
||||
}
|
||||
|
||||
/// A notice that arrived live, for a conversation that exists.
|
||||
pub fn heard(state: &AppState, account_id: &str, conversation: &str, notice: &SessionNoticeNotification) {
|
||||
let (Some(friend), Some(kind)) = (notice.steamid_friend.and_then(steamid::account_of), notice.notice_type) else { return };
|
||||
let buffer = crate::model::buffer_id(account_id, conversation);
|
||||
// Raised: said in the log, and held over the conversation until dismissed.
|
||||
// Lifted: the banner goes, and the line stays where it was said.
|
||||
if notice.active.unwrap_or(true) {
|
||||
say(state, account_id, conversation, friend, kind);
|
||||
if let Some(text) = words(kind) {
|
||||
// Raised afresh, so not dismissed any more, whatever came before.
|
||||
let _ = state.store.set_notice_state(&buffer, ¬ice_id(friend, kind), "pending");
|
||||
state.runtime.set_buffer_notice(state, &buffer, Some(BufferNotice { kind: "suspicious".to_string(), text: text.to_string() }));
|
||||
}
|
||||
} else if words(kind).is_some() {
|
||||
dismissed(state, &buffer, friend, kind);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tells Steam a notice has been seen, so it is not raised again.
|
||||
pub fn dismiss(link: &super::link::Link, friend: u64, notice_type: i32) -> Result<(), String> {
|
||||
link.notify("FriendMessages.DismissSessionNotice#1", &DismissSessionNotice { steamid_friend: Some(friend), notice_type: Some(notice_type) }).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::backend::kick::testkit::simulated_daemon;
|
||||
|
||||
const ACCOUNT: &str = "steam:tester";
|
||||
|
||||
fn said(state: &AppState) -> Vec<(String, String, bool)> {
|
||||
state.store.get_backlog(&crate::model::buffer_id(ACCOUNT, "Gabe"), 0, 20).unwrap().into_iter().map(|m| (m.kind, m.body, m.is_own)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_suspicious_notice_is_a_line_in_the_conversation_said_once() {
|
||||
let state = simulated_daemon("steam-notice-once");
|
||||
let notice = SessionNoticeNotification { steamid_friend: Some(steamid::steamid_of(100)), notice_type: Some(SUSPICIOUS), active: Some(true) };
|
||||
heard(&state, ACCOUNT, "Gabe", ¬ice);
|
||||
heard(&state, ACCOUNT, "Gabe", ¬ice);
|
||||
let got = said(&state);
|
||||
assert_eq!(got.len(), 1, "raised twice, said once: {got:?}");
|
||||
assert_eq!(got[0].0, "warning");
|
||||
let held = state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, "Gabe")).unwrap().notice;
|
||||
assert_eq!(held.as_ref().map(|n| n.kind.as_str()), Some("suspicious"), "and held over the conversation");
|
||||
assert!(got[0].1.contains("may not be who they appear to be"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_banner_is_taken_off_when_dismissed_and_comes_back_when_steam_says_it_again() {
|
||||
let state = simulated_daemon("steam-notice-banner");
|
||||
let raised = SessionNoticeNotification { steamid_friend: Some(steamid::steamid_of(100)), notice_type: Some(SUSPICIOUS), active: Some(true) };
|
||||
heard(&state, ACCOUNT, "Gabe", &raised);
|
||||
let id = crate::model::buffer_id(ACCOUNT, "Gabe");
|
||||
assert!(state.runtime.get_buffer(&id).unwrap().notice.is_some());
|
||||
// Dismissed: the banner goes, and the line in the log stays.
|
||||
dismissed(&state, &id, 100, SUSPICIOUS);
|
||||
assert!(state.runtime.get_buffer(&id).unwrap().notice.is_none());
|
||||
assert_eq!(said(&state).len(), 1);
|
||||
// Steam raising it again is a new notice.
|
||||
heard(&state, ACCOUNT, "Gabe", &raised);
|
||||
assert!(state.runtime.get_buffer(&id).unwrap().notice.is_some(), "a new notice is shown");
|
||||
assert_eq!(said(&state).len(), 1, "though the log is not told twice");
|
||||
// Steam lifting it takes the banner off.
|
||||
heard(&state, ACCOUNT, "Gabe", &SessionNoticeNotification { active: Some(false), ..raised });
|
||||
assert!(state.runtime.get_buffer(&id).unwrap().notice.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_banner_not_dismissed_is_there_after_a_restart_and_one_dismissed_is_not() {
|
||||
let state = simulated_daemon("steam-notice-restart");
|
||||
let id = crate::model::buffer_id(ACCOUNT, "Gabe");
|
||||
heard(&state, ACCOUNT, "Gabe", &SessionNoticeNotification { steamid_friend: Some(steamid::steamid_of(100)), notice_type: Some(SUSPICIOUS), active: Some(true) });
|
||||
// The window and the daemon are restarted: nothing is held but what was kept.
|
||||
state.runtime.set_buffer_notice(&state, &id, None);
|
||||
restore(&state, ACCOUNT);
|
||||
assert!(state.runtime.get_buffer(&id).unwrap().notice.is_some(), "not dismissed, so still there");
|
||||
dismissed(&state, &id, 100, SUSPICIOUS);
|
||||
restore(&state, ACCOUNT);
|
||||
assert!(state.runtime.get_buffer(&id).unwrap().notice.is_none(), "dismissed, so not brought back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_warning_said_in_the_log_before_anything_recorded_it_is_shown_once() {
|
||||
let state = simulated_daemon("steam-notice-legacy");
|
||||
let id = crate::model::buffer_id(ACCOUNT, "Gabe");
|
||||
// As it was stored before there was a banner: a system line, nothing more.
|
||||
state.runtime.record_message_at(&state, ACCOUNT, "Gabe", "dm", "Steam", "Steam warns that this person may not be who they appear to be.", false, "system", None, Some(notice_id(100, SUSPICIOUS)), false, None, vec![], vec![], None, None, None, None);
|
||||
assert!(state.runtime.get_buffer(&id).unwrap().notice.is_none());
|
||||
restore(&state, ACCOUNT);
|
||||
assert!(state.runtime.get_buffer(&id).unwrap().notice.is_some(), "nobody dismissed it, so it is up");
|
||||
dismissed(&state, &id, 100, SUSPICIOUS);
|
||||
restore(&state, ACCOUNT);
|
||||
assert!(state.runtime.get_buffer(&id).unwrap().notice.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_notice_that_is_lifted_or_not_known_says_nothing() {
|
||||
let state = simulated_daemon("steam-notice-quiet");
|
||||
heard(&state, ACCOUNT, "Gabe", &SessionNoticeNotification { steamid_friend: Some(steamid::steamid_of(100)), notice_type: Some(SUSPICIOUS), active: Some(false) });
|
||||
heard(&state, ACCOUNT, "Gabe", &SessionNoticeNotification { steamid_friend: Some(steamid::steamid_of(100)), notice_type: Some(99), active: Some(true) });
|
||||
heard(&state, ACCOUNT, "Gabe", &SessionNoticeNotification { steamid_friend: None, notice_type: Some(SUSPICIOUS), active: Some(true) });
|
||||
assert!(said(&state).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dismissing_tells_steam_which_notice_about_whom() {
|
||||
use crate::backend::steam::link::{self, fake::*};
|
||||
use prost::Message;
|
||||
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let url = serve({
|
||||
let seen = seen.clone();
|
||||
Box::new(move |p| {
|
||||
seen.lock().unwrap().push(p);
|
||||
Some(vec![])
|
||||
})
|
||||
})
|
||||
.await;
|
||||
let (link, _told) = link::open_with("test", &url, std::time::Duration::from_secs(5)).await.unwrap();
|
||||
dismiss(&link, steamid::steamid_of(100), SUSPICIOUS).unwrap();
|
||||
for _ in 0..100 {
|
||||
if !seen.lock().unwrap().is_empty() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
}
|
||||
let seen = seen.lock().unwrap();
|
||||
assert_eq!(seen[0].header.target_job_name.as_deref(), Some("FriendMessages.DismissSessionNotice#1"));
|
||||
let body = DismissSessionNotice::decode(seen[0].body.as_slice()).unwrap();
|
||||
assert_eq!((body.steamid_friend, body.notice_type), (Some(steamid::steamid_of(100)), Some(1)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//! What an account keeps about each friend: the name it calls them, and whether
|
||||
//! their messages notify it.
|
||||
//!
|
||||
//! Steam holds these for the account, so they follow it to every client: a
|
||||
//! nickname set here is the name on the phone, and one set there is the name
|
||||
//! here. They are read when the account signs in, and changes - made anywhere -
|
||||
//! arrive as notifications. Setting one asks for what the account has now,
|
||||
//! changes the one thing, and sends it back whole, because the message that
|
||||
//! sets them replaces what was held and would otherwise forget the rest.
|
||||
|
||||
use super::friends::NOTIFY_NEVER;
|
||||
use super::link::Link;
|
||||
use super::proto::{GetPerFriendPreferencesRequest, GetPerFriendPreferencesResponse, IgnoreFriendRequest, IgnoreFriendResponse, PerFriendPreferences, SetPerFriendPreferencesRequest, SetPerFriendPreferencesResponse};
|
||||
|
||||
/// Everything the account keeps about its friends.
|
||||
pub async fn load(link: &Link) -> Result<Vec<PerFriendPreferences>, String> {
|
||||
let answer: GetPerFriendPreferencesResponse = link.call("Player.GetPerFriendPreferences#1", &GetPerFriendPreferencesRequest {}, true).await.map_err(|e| super::chat::refusal(&e))?;
|
||||
Ok(answer.preferences)
|
||||
}
|
||||
|
||||
/// What is held about one friend, or nothing yet.
|
||||
fn held_about(all: Vec<PerFriendPreferences>, account: u32) -> PerFriendPreferences {
|
||||
all.into_iter().find(|p| p.accountid == Some(account)).unwrap_or(PerFriendPreferences { accountid: Some(account), ..Default::default() })
|
||||
}
|
||||
|
||||
async fn change(link: &Link, account: u32, edit: impl FnOnce(&mut PerFriendPreferences)) -> Result<(), String> {
|
||||
let mut prefs = held_about(load(link).await?, account);
|
||||
edit(&mut prefs);
|
||||
let _: SetPerFriendPreferencesResponse = link
|
||||
.call("Player.SetPerFriendPreferences#1", &SetPerFriendPreferencesRequest { preferences: Some(prefs) }, true)
|
||||
.await
|
||||
.map_err(|e| super::chat::refusal(&e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sets what the account calls a friend; empty takes the name back.
|
||||
pub async fn set_nickname(link: &Link, account: u32, nickname: &str) -> Result<(), String> {
|
||||
let nickname = nickname.trim().chars().take(64).collect::<String>();
|
||||
change(link, account, |p| p.nickname = (!nickname.is_empty()).then_some(nickname)).await
|
||||
}
|
||||
|
||||
/// Whether a friend's messages notify the account: never, or as everybody's do.
|
||||
pub async fn set_muted(link: &Link, account: u32, muted: bool) -> Result<(), String> {
|
||||
change(link, account, |p| p.notifications_showmessages = muted.then_some(NOTIFY_NEVER)).await
|
||||
}
|
||||
|
||||
/// Blocks a friend, or lifts it. Blocking somebody takes them off the friends
|
||||
/// list and stops them writing to the account.
|
||||
pub async fn block(link: &Link, friend: u64, block: bool) -> Result<(), String> {
|
||||
let _: IgnoreFriendResponse = link.call("Player.IgnoreFriend#1", &IgnoreFriendRequest { steamid: Some(friend), unignore: Some(!block) }, true).await.map_err(|e| super::chat::refusal(&e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::backend::steam::link::{self, fake::*};
|
||||
use prost::Message;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
/// What a scripted Steam was asked, in order: the method and the body.
|
||||
type Seen = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// A Steam holding preferences for one friend, which keeps what it is sent.
|
||||
fn steam(held: Vec<PerFriendPreferences>, seen: Seen) -> Script {
|
||||
Box::new(move |p| {
|
||||
let method = p.header.target_job_name.clone().unwrap_or_default();
|
||||
seen.lock().unwrap().push((method.clone(), p.body.clone()));
|
||||
let body = match method.as_str() {
|
||||
"Player.GetPerFriendPreferences#1" => GetPerFriendPreferencesResponse { preferences: held.clone() }.encode_to_vec(),
|
||||
"Player.SetPerFriendPreferences#1" => SetPerFriendPreferencesResponse {}.encode_to_vec(),
|
||||
_ => IgnoreFriendResponse { friend_relationship: Some(5) }.encode_to_vec(),
|
||||
};
|
||||
Some(vec![answer(&p, 1, body)])
|
||||
})
|
||||
}
|
||||
|
||||
async fn link_to(script: Script) -> Link {
|
||||
let url = serve(script).await;
|
||||
link::open_with("test", &url, Duration::from_secs(5)).await.unwrap().0
|
||||
}
|
||||
|
||||
fn sent(seen: &Seen) -> PerFriendPreferences {
|
||||
let seen = seen.lock().unwrap();
|
||||
let (_, body) = seen.iter().rev().find(|(m, _)| m == "Player.SetPerFriendPreferences#1").expect("a set");
|
||||
SetPerFriendPreferencesRequest::decode(body.as_slice()).unwrap().preferences.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_nickname_is_set_without_forgetting_the_rest_of_what_is_held() {
|
||||
let held = vec![PerFriendPreferences { accountid: Some(100), nickname: Some("Old".into()), notifications_showmessages: Some(2), sounds_showmessages: Some(1), ..Default::default() }];
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let link = link_to(steam(held, seen.clone())).await;
|
||||
set_nickname(&link, 100, " Buddy ").await.unwrap();
|
||||
let first = sent(&seen);
|
||||
assert_eq!((first.accountid, first.nickname.as_deref()), (Some(100), Some("Buddy")));
|
||||
assert_eq!((first.notifications_showmessages, first.sounds_showmessages), (Some(2), Some(1)), "what else is held goes back as it was");
|
||||
set_nickname(&link, 100, " ").await.unwrap();
|
||||
assert_eq!(sent(&seen).nickname, None, "empty takes the name back");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn muting_a_friend_is_the_never_setting_and_unmuting_returns_it_to_the_default() {
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let link = link_to(steam(vec![PerFriendPreferences { accountid: Some(100), nickname: Some("Keep".into()), ..Default::default() }], seen.clone())).await;
|
||||
set_muted(&link, 100, true).await.unwrap();
|
||||
assert_eq!((sent(&seen).notifications_showmessages, sent(&seen).nickname.as_deref()), (Some(NOTIFY_NEVER), Some("Keep")));
|
||||
set_muted(&link, 100, false).await.unwrap();
|
||||
assert_eq!(sent(&seen).notifications_showmessages, None);
|
||||
// A friend nothing is held about starts from nothing.
|
||||
set_muted(&link, 7, true).await.unwrap();
|
||||
assert_eq!((sent(&seen).accountid, sent(&seen).nickname), (Some(7), None));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blocking_names_the_person_and_unblocking_says_so() {
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let link = link_to(steam(vec![], seen.clone())).await;
|
||||
block(&link, 76561197960265829, true).await.unwrap();
|
||||
block(&link, 76561197960265829, false).await.unwrap();
|
||||
let seen = seen.lock().unwrap();
|
||||
let asks: Vec<IgnoreFriendRequest> = seen.iter().filter(|(m, _)| m == "Player.IgnoreFriend#1").map(|(_, b)| IgnoreFriendRequest::decode(b.as_slice()).unwrap()).collect();
|
||||
assert_eq!(asks.iter().map(|a| (a.steamid, a.unignore)).collect::<Vec<_>>(), [(Some(76561197960265829), Some(false)), (Some(76561197960265829), Some(true))]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
//! Presence: the words moho uses for how a person is showing, and Steam's.
|
||||
//!
|
||||
//! Steam has eight states and moho's window draws four. The mapping is the
|
||||
//! only place that decides which is which, in both directions, so a friend's
|
||||
//! dot and our own plaque cannot disagree about what "Snooze" is.
|
||||
//!
|
||||
//! Steam's states are numbers: Offline 0, Online 1, Busy 2, Away 3, Snooze 4,
|
||||
//! Looking to Trade 5, Looking to Play 6, Invisible 7.
|
||||
|
||||
use super::link::{CallError, Link};
|
||||
use super::proto::ClientChangeStatus;
|
||||
use super::wire::emsg;
|
||||
|
||||
pub const ONLINE: u32 = 1;
|
||||
pub const BUSY: u32 = 2;
|
||||
pub const AWAY: u32 = 3;
|
||||
pub const SNOOZE: u32 = 4;
|
||||
pub const LOOKING_TO_TRADE: u32 = 5;
|
||||
pub const LOOKING_TO_PLAY: u32 = 6;
|
||||
pub const INVISIBLE: u32 = 7;
|
||||
|
||||
/// What to tell Steam for a status chosen in moho.
|
||||
///
|
||||
/// Do not disturb is moho's own switch for its own notifications and stays
|
||||
/// Online to Steam (as it does for IRC and Matrix); Steam's "Busy" is a
|
||||
/// different thing, a state friends see, and it is not set on anyone's behalf.
|
||||
pub fn persona_state_for(status: &str) -> u32 {
|
||||
match status {
|
||||
"idle" => AWAY,
|
||||
"invisible" => INVISIBLE,
|
||||
_ => ONLINE,
|
||||
}
|
||||
}
|
||||
|
||||
/// How a state Steam reported is drawn. Invisible is never seen on somebody
|
||||
/// else - Steam shows them as offline - but it is understood on ourselves.
|
||||
pub fn status_of(persona_state: u32) -> &'static str {
|
||||
match persona_state {
|
||||
ONLINE | LOOKING_TO_TRADE | LOOKING_TO_PLAY => "online",
|
||||
BUSY => "dnd",
|
||||
AWAY | SNOOZE => "idle",
|
||||
_ => "offline",
|
||||
}
|
||||
}
|
||||
|
||||
/// What a friend is doing, for the line under their name: the game they are
|
||||
/// in, or a state the four colours cannot tell apart (Busy and Do not disturb
|
||||
/// are one red, Away and Snooze one amber, and "looking to trade" is just
|
||||
/// online). Nothing for plainly online or offline, which the dot says; and
|
||||
/// nothing for a game when they are offline, since that is stale.
|
||||
///
|
||||
/// The kind decides the picture in front of it: a game has a gamepad, and a
|
||||
/// state has none, being words.
|
||||
pub fn activity(persona_state: u32, game: Option<&str>) -> Option<(&'static str, String)> {
|
||||
if status_of(persona_state) == "offline" {
|
||||
return None;
|
||||
}
|
||||
if let Some(game) = game.filter(|g| !g.is_empty()) {
|
||||
return Some(("game", game.to_string()));
|
||||
}
|
||||
let words = match persona_state {
|
||||
BUSY => "Busy",
|
||||
AWAY => "Away",
|
||||
SNOOZE => "Snoozing",
|
||||
LOOKING_TO_TRADE => "Looking to trade",
|
||||
LOOKING_TO_PLAY => "Looking to play",
|
||||
_ => return None,
|
||||
};
|
||||
Some(("custom", words.to_string()))
|
||||
}
|
||||
|
||||
/// Which kind of client somebody is on, for their card: from the flags on their
|
||||
/// state, and whether it is a Deck. Nothing for somebody who is not here.
|
||||
///
|
||||
/// `EPersonaStateFlag`: 256 web, 512 mobile, 1024 Big Picture, 2048 VR.
|
||||
pub fn client_of(persona_state: u32, flags: u32, on_deck: bool) -> Option<&'static str> {
|
||||
if status_of(persona_state) == "offline" {
|
||||
return None;
|
||||
}
|
||||
Some(if on_deck {
|
||||
"Steam Deck"
|
||||
} else if flags & 2048 != 0 {
|
||||
"VR"
|
||||
} else if flags & 1024 != 0 {
|
||||
"Big Picture"
|
||||
} else if flags & 512 != 0 {
|
||||
"Mobile"
|
||||
} else if flags & 256 != 0 {
|
||||
"Web"
|
||||
} else {
|
||||
"Desktop"
|
||||
})
|
||||
}
|
||||
|
||||
/// Tells Steam how this account is showing.
|
||||
///
|
||||
/// `by_user` says it was a choice, as against something done on their behalf
|
||||
/// (stepping away from the machine): Steam keeps the two apart, and does not
|
||||
/// move a state somebody picked.
|
||||
pub fn send_status(link: &Link, status: &str, by_user: bool) -> Result<(), CallError> {
|
||||
link.send(
|
||||
emsg::CLIENT_CHANGE_STATUS,
|
||||
&ClientChangeStatus {
|
||||
persona_state: Some(persona_state_for(status)),
|
||||
persona_set_by_user: Some(by_user),
|
||||
need_persona_response: Some(false),
|
||||
..ClientChangeStatus::default()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Changes how a signed-in account is showing. Whether it could: an account
|
||||
/// that is not connected has nothing to tell, and keeps the choice for the
|
||||
/// next time it is.
|
||||
pub fn apply_status(state: &crate::state::AppState, account_id: &str, status: &str, by_user: bool) -> bool {
|
||||
match state.runtime.steam_link(account_id) {
|
||||
Some(link) => send_status(&link, status, by_user).is_ok(),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn what_the_dot_cannot_say_is_said_under_the_name() {
|
||||
let words = |state, game| activity(state, game).map(|(k, t)| format!("{k}:{t}"));
|
||||
assert_eq!(words(ONLINE, None), None, "online is the dot");
|
||||
assert_eq!(words(0, None), None, "offline is the dot");
|
||||
assert_eq!(words(BUSY, None).as_deref(), Some("custom:Busy"));
|
||||
assert_eq!(words(AWAY, None).as_deref(), Some("custom:Away"));
|
||||
assert_eq!(words(SNOOZE, None).as_deref(), Some("custom:Snoozing"));
|
||||
assert_eq!(words(LOOKING_TO_TRADE, None).as_deref(), Some("custom:Looking to trade"));
|
||||
assert_eq!(words(LOOKING_TO_PLAY, None).as_deref(), Some("custom:Looking to play"));
|
||||
// A game outranks the state, is named bare - the gamepad says what it is
|
||||
// - and an empty name is no game.
|
||||
assert_eq!(words(ONLINE, Some("Counter-Strike 2")).as_deref(), Some("game:Counter-Strike 2"));
|
||||
assert_eq!(words(BUSY, Some("Dota 2")).as_deref(), Some("game:Dota 2"));
|
||||
assert_eq!(words(ONLINE, Some("")), None);
|
||||
// Somebody offline is not playing anything, whatever was last said.
|
||||
assert_eq!(words(0, Some("Counter-Strike 2")), None);
|
||||
// Invisible is never seen on somebody else, and reads as offline.
|
||||
assert_eq!(words(INVISIBLE, Some("x")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_kind_of_client_is_read_from_the_flags_and_is_nothing_for_somebody_away() {
|
||||
assert_eq!(client_of(ONLINE, 0, false), Some("Desktop"));
|
||||
assert_eq!(client_of(ONLINE, 512, false), Some("Mobile"));
|
||||
assert_eq!(client_of(ONLINE, 256 | 1, false), Some("Web"));
|
||||
assert_eq!(client_of(AWAY, 1024, false), Some("Big Picture"));
|
||||
assert_eq!(client_of(ONLINE, 2048 | 512, false), Some("VR"));
|
||||
assert_eq!(client_of(ONLINE, 512, true), Some("Steam Deck"), "a Deck is a Deck whatever else is set");
|
||||
assert_eq!(client_of(0, 512, false), None);
|
||||
assert_eq!(client_of(INVISIBLE, 0, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_moho_offers_is_what_steam_is_told() {
|
||||
assert_eq!(persona_state_for("online"), 1);
|
||||
assert_eq!(persona_state_for("idle"), 3, "idle is Steam's Away");
|
||||
assert_eq!(persona_state_for("invisible"), 7);
|
||||
assert_eq!(persona_state_for("dnd"), 1, "do not disturb is local; Steam still sees an online person");
|
||||
assert_eq!(persona_state_for("nonsense"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_state_steam_reports_is_drawn_as_one_of_the_four() {
|
||||
assert_eq!(status_of(0), "offline");
|
||||
assert_eq!(status_of(1), "online");
|
||||
assert_eq!(status_of(2), "dnd");
|
||||
assert_eq!(status_of(3), "idle");
|
||||
assert_eq!(status_of(4), "idle");
|
||||
assert_eq!(status_of(5), "online");
|
||||
assert_eq!(status_of(6), "online");
|
||||
assert_eq!(status_of(7), "offline");
|
||||
assert_eq!(status_of(99), "offline", "a state from a newer Steam is not a guess in the direction that shows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_we_set_is_read_back_the_way_we_set_it() {
|
||||
for status in ["online", "idle"] {
|
||||
assert_eq!(status_of(persona_state_for(status)), status);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_status_chosen_is_what_steam_is_told_and_by_whom() {
|
||||
use crate::backend::steam::link::{self, fake::*};
|
||||
use crate::backend::steam::wire::Packet;
|
||||
use prost::Message;
|
||||
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::<Packet>::new()));
|
||||
let url = serve({
|
||||
let seen = seen.clone();
|
||||
Box::new(move |p| {
|
||||
seen.lock().unwrap().push(p);
|
||||
Some(Vec::new())
|
||||
})
|
||||
})
|
||||
.await;
|
||||
let (link, _told) = link::open("steam:test", &url).await.unwrap();
|
||||
link.set_identity(link::Identity { steamid: 76561197960287930, session: 3 });
|
||||
send_status(&link, "invisible", true).unwrap();
|
||||
send_status(&link, "idle", false).unwrap();
|
||||
for _ in 0..100 {
|
||||
if seen.lock().unwrap().len() == 2 {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
}
|
||||
let seen = seen.lock().unwrap();
|
||||
assert_eq!(seen.len(), 2);
|
||||
assert!(seen.iter().all(|p| p.emsg == emsg::CLIENT_CHANGE_STATUS));
|
||||
let first = ClientChangeStatus::decode(seen[0].body.as_slice()).unwrap();
|
||||
assert_eq!((first.persona_state, first.persona_set_by_user), (Some(INVISIBLE), Some(true)));
|
||||
let second = ClientChangeStatus::decode(seen[1].body.as_slice()).unwrap();
|
||||
assert_eq!((second.persona_state, second.persona_set_by_user), (Some(AWAY), Some(false)), "stepping away is not a choice");
|
||||
assert_eq!(seen[0].header.steamid, Some(76561197960287930), "as the account");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_account_that_is_not_connected_has_nobody_to_tell() {
|
||||
let state = crate::backend::kick::testkit::simulated_daemon("steam-presence-offline");
|
||||
assert!(!apply_status(&state, "steam:nobody", "idle", true));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,231 @@
|
||||
//! A friend's card: what they wrote on their profile, their level, and what
|
||||
//! Steam knows of how they are showing.
|
||||
//!
|
||||
//! The facts that arrive with their presence - the kind of client, the last
|
||||
//! time they were seen, what their game says - are already held in the
|
||||
//! conversation's one-person roster, so the card reads them from there. What
|
||||
//! needs asking is the profile text and the level.
|
||||
|
||||
use super::link::Link;
|
||||
use super::proto::{FriendProfileInfoRequest, FriendProfileInfoResponse, FriendsSteamLevelsRequest, FriendsSteamLevelsResponse};
|
||||
use super::steamid;
|
||||
use super::wire::emsg;
|
||||
use crate::profile;
|
||||
use crate::state::AppState;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// What somebody wrote about where they are, as one line.
|
||||
fn place(p: &FriendProfileInfoResponse) -> String {
|
||||
[&p.city_name, &p.state_name, &p.country_name].iter().filter_map(|s| s.as_deref()).map(str::trim).filter(|s| !s.is_empty()).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
|
||||
/// A profile's summary, which is written with Steam's tags, as plain words.
|
||||
fn summary(text: &str) -> String {
|
||||
let mut plain = super::bbcode::to_markup(text).replace('\u{200b}', "");
|
||||
// The card shows words, not the marks the log draws bold and struck with.
|
||||
for mark in ["**", "~~", "||", "[u]", "[/u]"] {
|
||||
plain = plain.replace(mark, "");
|
||||
}
|
||||
let plain = pictures_as_words(&plain);
|
||||
plain.chars().take(600).collect::<String>().trim().to_string()
|
||||
}
|
||||
|
||||
/// `[steam:emote:name]` as `:name:`, which is how it is read in words.
|
||||
fn pictures_as_words(text: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut rest = text;
|
||||
while let Some(at) = rest.find("[steam:") {
|
||||
out.push_str(&rest[..at]);
|
||||
let tail = &rest[at..];
|
||||
match tail.find(']').and_then(|end| tail[7..end].split_once(':').map(|(_, name)| (end, name))) {
|
||||
Some((end, name)) => {
|
||||
out.push_str(&format!(":{name}:"));
|
||||
rest = &tail[end + 1..];
|
||||
}
|
||||
None => {
|
||||
out.push_str("[steam:");
|
||||
rest = &tail[7..];
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// Adds what the two answers say.
|
||||
fn fill(card: &mut Value, info: Option<&FriendProfileInfoResponse>, level: Option<u32>) {
|
||||
if let Some(level) = level.filter(|l| *l > 0) {
|
||||
profile::note(card, "Steam level", level.to_string());
|
||||
}
|
||||
let Some(info) = info else { return };
|
||||
profile::set(card, "createdTs", info.time_created.filter(|t| *t > 0).map(|t| json!(t)));
|
||||
profile::note(card, "Real name", info.real_name.clone().unwrap_or_default());
|
||||
profile::note(card, "Location", place(info));
|
||||
profile::note(card, "Headline", info.headline.clone().unwrap_or_default());
|
||||
profile::note(card, "Summary", summary(info.summary.as_deref().unwrap_or_default()));
|
||||
}
|
||||
|
||||
/// Adds the names they have had, newest first: the one they have now is the one
|
||||
/// the card is headed by, so it is not listed again.
|
||||
fn fill_names(card: &mut Value, names: &[(String, String)]) {
|
||||
let now = card["name"].as_str().unwrap_or_default().to_string();
|
||||
let history: Vec<Value> = names.iter().filter(|(n, _)| *n != now).map(|(name, when)| json!({ "name": name, "when": when })).collect();
|
||||
if !history.is_empty() {
|
||||
card["nameHistory"] = json!(history);
|
||||
}
|
||||
}
|
||||
|
||||
/// A length of time in the units a person says it in.
|
||||
fn how_long(seconds: u64) -> String {
|
||||
match seconds {
|
||||
0..=59 => "under a minute".to_string(),
|
||||
60..=3599 => format!("{} min", seconds / 60),
|
||||
_ => format!("{}h {:02}m", seconds / 3600, (seconds % 3600) / 60),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds what is already known from how they are showing.
|
||||
fn fill_from_presence(card: &mut Value, member: &Value) {
|
||||
profile::set(card, "status", member["status"].as_str().map(|s| json!(s)));
|
||||
profile::set(card, "avatarUrl", member["avatarUrl"].as_str().map(|s| json!(s)));
|
||||
profile::set(card, "activity", Some(member["activity"].clone()).filter(|a| !a.is_null()));
|
||||
profile::note(card, "On", member["client"].as_str().unwrap_or_default());
|
||||
// Only as long as moho has seen it: Steam does not say how long anybody has been in a game.
|
||||
if let Some(since) = member["playingSince"].as_u64().filter(|s| *s > 0) {
|
||||
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
||||
if now > since {
|
||||
profile::note(card, "Seen playing for", how_long(now - since));
|
||||
}
|
||||
}
|
||||
profile::note(card, "Doing", member["rich"].as_str().unwrap_or_default());
|
||||
// Only for somebody who is not here: for somebody who is, it is now.
|
||||
if member["status"].as_str() == Some("offline") {
|
||||
profile::set(card, "lastActiveTs", member["lastSeen"].as_u64().filter(|t| *t > 0).map(|t| json!(t)));
|
||||
}
|
||||
}
|
||||
|
||||
async fn ask_profile(link: &Link, friend: u64) -> Option<FriendProfileInfoResponse> {
|
||||
let answer: FriendProfileInfoResponse = link.ask(emsg::CLIENT_FRIEND_PROFILE_INFO, &FriendProfileInfoRequest { steamid_friend: Some(friend) }).await.ok()?;
|
||||
(answer.eresult.unwrap_or(super::eresult::FAIL) == super::eresult::OK).then_some(answer)
|
||||
}
|
||||
|
||||
async fn ask_level(link: &Link, friend: u32) -> Option<u32> {
|
||||
let answer: FriendsSteamLevelsResponse = link.ask(emsg::CLIENT_FS_GET_FRIENDS_STEAM_LEVELS, &FriendsSteamLevelsRequest { accountids: vec![friend] }).await.ok()?;
|
||||
answer.friends.iter().find(|f| f.accountid == Some(friend)).and_then(|f| f.level)
|
||||
}
|
||||
|
||||
/// The card for the person a conversation is with.
|
||||
pub async fn build(state: &AppState, account_id: &str, buffer_id: &str, who: &str, user_id: &str) -> Value {
|
||||
let mut card = profile::pending("steam", account_id, who);
|
||||
card["pending"] = json!(false);
|
||||
let buffer = state.runtime.get_buffer(buffer_id);
|
||||
// A conversation is with one person; a room of a group is with many, and the
|
||||
// member who was asked about is named.
|
||||
let in_room = buffer.as_ref().is_some_and(super::chatgroups::is_room);
|
||||
let friend = if in_room { user_id.parse::<u64>().ok() } else { buffer.as_ref().and_then(|b| b.remote_id.as_deref()).and_then(|id| id.parse::<u64>().ok()) }.filter(|id| steamid::account_of(*id).is_some());
|
||||
let Some(friend) = friend else { return card };
|
||||
card["id"] = json!(friend.to_string());
|
||||
let listed = state.runtime.get_presence(buffer_id).and_then(|m| m.as_array().cloned()).unwrap_or_default();
|
||||
let member = if in_room { listed.into_iter().find(|m| m["userId"].as_str() == Some(&friend.to_string())) } else { listed.into_iter().next() };
|
||||
if let Some(member) = member {
|
||||
fill_from_presence(&mut card, &member);
|
||||
if in_room {
|
||||
if let Some(roles) = member["roles"].as_array().filter(|r| !r.is_empty()) {
|
||||
card["roles"] = json!(roles);
|
||||
card["isModerator"] = json!(member["powerLevel"].as_i64().unwrap_or(0) >= super::chatgroups::RANK_MODERATOR as i64);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(link) = state.runtime.steam_link(account_id) {
|
||||
let number = steamid::account_of(friend).unwrap_or_default();
|
||||
let config = state.accounts.get_steam(account_id);
|
||||
let names = async {
|
||||
match &config {
|
||||
Some(c) => super::web::aliases(&link, account_id, c.steamid, &c.refresh_token, friend).await.unwrap_or_default(),
|
||||
None => Vec::new(),
|
||||
}
|
||||
};
|
||||
let (info, level, names) = tokio::join!(ask_profile(&link, friend), ask_level(&link, number), names);
|
||||
fill(&mut card, info.as_ref(), level);
|
||||
fill_names(&mut card, &names);
|
||||
}
|
||||
card
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn labels(card: &Value) -> Vec<(String, String)> {
|
||||
card["extra"].as_array().map(|a| a.iter().map(|e| (e["label"].as_str().unwrap().to_string(), e["value"].as_str().unwrap().to_string())).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn how_long_they_have_been_seen_playing_is_said_in_a_person_s_units() {
|
||||
assert_eq!(how_long(20), "under a minute");
|
||||
assert_eq!(how_long(600), "10 min");
|
||||
assert_eq!(how_long(3600 + 5 * 60), "1h 05m");
|
||||
let mut card = profile::pending("steam", "steam:me", "Gabe");
|
||||
let since = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() - 4000;
|
||||
fill_from_presence(&mut card, &json!({ "status": "online", "activity": { "kind": "game", "text": "X" }, "playingSince": since }));
|
||||
assert_eq!(labels(&card), [("Seen playing for".to_string(), "1h 06m".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_names_they_have_had_are_listed_except_the_one_they_have() {
|
||||
let mut card = profile::pending("steam", "steam:me", "Gabe");
|
||||
fill_names(&mut card, &[("Gabe".into(), "1 Jan, 2024 @ 1:00pm".into()), ("Gabe Newell".into(), "3 Mar, 2019 @ 1:00am".into())]);
|
||||
assert_eq!(card["nameHistory"], json!([{ "name": "Gabe Newell", "when": "3 Mar, 2019 @ 1:00am" }]));
|
||||
let mut none = profile::pending("steam", "steam:me", "Gabe");
|
||||
fill_names(&mut none, &[("Gabe".into(), "x".into())]);
|
||||
assert!(none.get("nameHistory").is_none(), "only the present name: nothing to list");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_they_wrote_is_on_the_card_in_words() {
|
||||
let mut card = profile::pending("steam", "steam:me", "Gabe");
|
||||
let info = FriendProfileInfoResponse {
|
||||
eresult: Some(1),
|
||||
time_created: Some(1300000000),
|
||||
real_name: Some("Gabe N".into()),
|
||||
city_name: Some("Bellevue".into()),
|
||||
state_name: Some("".into()),
|
||||
country_name: Some("United States".into()),
|
||||
headline: Some("Hello".into()),
|
||||
summary: Some("About [b]me[/b] and [emoticon]steamhappy[/emoticon] \\[x]".into()),
|
||||
..Default::default()
|
||||
};
|
||||
fill(&mut card, Some(&info), Some(42));
|
||||
assert_eq!(card["createdTs"], 1300000000);
|
||||
assert_eq!(
|
||||
labels(&card),
|
||||
[("Steam level", "42"), ("Real name", "Gabe N"), ("Location", "Bellevue, United States"), ("Headline", "Hello"), ("Summary", "About me and :steamhappy: [x]")]
|
||||
.map(|(a, b)| (a.to_string(), b.to_string())),
|
||||
"an empty part of a place is left out, and the summary is words"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_known_is_nothing_shown() {
|
||||
let mut card = profile::pending("steam", "steam:me", "Gabe");
|
||||
fill(&mut card, None, None);
|
||||
fill(&mut card, Some(&FriendProfileInfoResponse::default()), Some(0));
|
||||
assert!(labels(&card).is_empty());
|
||||
assert!(card.get("createdTs").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn how_they_are_showing_comes_from_their_presence() {
|
||||
let mut card = profile::pending("steam", "steam:me", "Gabe");
|
||||
fill_from_presence(&mut card, &json!({ "status": "online", "client": "Mobile", "rich": "In a match", "avatarUrl": "https://a/b.jpg", "activity": { "kind": "game", "text": "Dota 2" }, "lastSeen": 100 }));
|
||||
assert_eq!(card["status"], "online");
|
||||
assert_eq!(card["activity"]["text"], "Dota 2");
|
||||
assert!(card.get("lastActiveTs").is_none(), "last seen is for somebody who is away");
|
||||
assert_eq!(labels(&card), [("On".to_string(), "Mobile".to_string()), ("Doing".to_string(), "In a match".to_string())]);
|
||||
|
||||
let mut gone = profile::pending("steam", "steam:me", "Gabe");
|
||||
fill_from_presence(&mut gone, &json!({ "status": "offline", "lastSeen": 1790000000 }));
|
||||
assert_eq!(gone["lastActiveTs"], 1790000000u64);
|
||||
assert!(labels(&gone).is_empty(), "nothing to say of a client that is not there");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,444 @@
|
||||
//! What the window sees of the roster: a conversation for each person, a
|
||||
//! name that follows them, and how they are doing.
|
||||
//!
|
||||
//! A friend is a direct-message conversation. Its name is their name, so it
|
||||
//! changes when they rename themselves - and then the conversation is moved,
|
||||
//! history and all, rather than a second one begun. Its presence is a
|
||||
//! one-person member list, which is what the window reads a direct message's
|
||||
//! dot from.
|
||||
//!
|
||||
//! This runs after every batch of updates and so is written to say nothing
|
||||
//! when nothing changed. Signing in brings hundreds of persona updates in a
|
||||
//! few seconds, and a window told "this friend is online" four times for each
|
||||
//! of them would spend its first moments redrawing.
|
||||
|
||||
use super::friends::Roster;
|
||||
use super::{presence, steamid};
|
||||
use crate::state::AppState;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// What the window was last told about one person.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Shown {
|
||||
name: String,
|
||||
status: &'static str,
|
||||
/// What is said under the name: the game, or a state the colour cannot give.
|
||||
activity: Option<(&'static str, String)>,
|
||||
avatar: Option<String>,
|
||||
/// Which kind of client, when they were last seen, and what their game says: for their card.
|
||||
detail: (Option<&'static str>, Option<u32>, Option<String>),
|
||||
}
|
||||
|
||||
/// Which kind of device a person is on, in words: a Steam Machine is said by what Steam calls the
|
||||
/// device, the rest by what their client says.
|
||||
fn client_name(p: &super::friends::Persona) -> Option<&'static str> {
|
||||
let said = presence::client_of(p.state, p.flags, p.on_deck)?;
|
||||
Some(if p.device == super::friends::DEVICE_STEAM_MACHINE && !p.on_deck { "Steam Machine" } else { said })
|
||||
}
|
||||
|
||||
pub struct Conversations {
|
||||
account_id: String,
|
||||
shown: HashMap<u32, Shown>,
|
||||
}
|
||||
|
||||
impl Conversations {
|
||||
pub fn new(account_id: &str) -> Self {
|
||||
Self { account_id: account_id.to_string(), shown: HashMap::new() }
|
||||
}
|
||||
|
||||
/// Takes up the conversations that already exist for this account, as the
|
||||
/// daemon restored them, so that a friend who was renamed while moho was
|
||||
/// closed has their conversation moved rather than a second one made.
|
||||
/// A conversation says who it is with in its `remote_id`.
|
||||
pub fn adopt(&mut self, state: &AppState, account_id: &str) {
|
||||
for buffer in state.runtime.list_buffers() {
|
||||
if buffer.account_id != account_id || buffer.kind != "dm" {
|
||||
continue;
|
||||
}
|
||||
let Some(account) = buffer.remote_id.as_deref().and_then(|id| id.parse::<u64>().ok()).and_then(steamid::account_of) else { continue };
|
||||
self.shown.entry(account).or_insert(Shown { name: buffer.name, status: "offline", activity: None, avatar: None, detail: (None, None, None) });
|
||||
}
|
||||
}
|
||||
|
||||
/// Follows the account's mutes - set in another client, or here - onto the
|
||||
/// conversations: muted on Steam is silent here, and the row says so.
|
||||
pub fn apply_mutes(&self, state: &AppState, roster: &Roster) {
|
||||
for (account, shown) in &self.shown {
|
||||
state.runtime.set_silenced(state, &crate::model::buffer_id(&self.account_id, &shown.name), roster.muted(*account));
|
||||
}
|
||||
}
|
||||
|
||||
/// The name of the conversation with this person, as it is on screen now.
|
||||
pub fn name_of(&self, account: u32) -> Option<&str> {
|
||||
self.shown.get(&account).map(|s| s.name.as_str())
|
||||
}
|
||||
|
||||
/// Brings the conversations in line with the roster, telling the window
|
||||
/// only about what is different from last time.
|
||||
pub fn publish(&mut self, state: &AppState, roster: &Roster) {
|
||||
let names = roster.conversation_names();
|
||||
for account in roster.participants() {
|
||||
let Some(mut name) = names.get(&account).cloned() else { continue };
|
||||
// A session that has only just begun knows its friends by number
|
||||
// until their names arrive. A conversation already called something
|
||||
// keeps that: renamed to a number and back, a friend list would
|
||||
// flicker into digits at every reconnect, and a connection that
|
||||
// keeps dropping would leave it that way.
|
||||
if !roster.has_name(account) {
|
||||
if let Some(held) = self.shown.get(&account).map(|s| s.name.clone()).filter(|n| *n != steamid::text_of(account)) {
|
||||
name = held;
|
||||
}
|
||||
}
|
||||
let persona = roster.persona(account);
|
||||
let status = persona.map_or("offline", |p| presence::status_of(p.state));
|
||||
let avatar = persona.and_then(|p| p.avatar.clone());
|
||||
let activity = persona.and_then(|p| presence::activity(p.state, roster.game_of(account).as_deref()));
|
||||
let detail = persona.map_or((None, None, None), |p| (client_name(p), p.last_seen, p.rich.clone()));
|
||||
let next = Shown { name: name.clone(), status, activity: activity.clone(), avatar: avatar.clone(), detail: detail.clone() };
|
||||
let before = self.shown.get(&account);
|
||||
if before == Some(&next) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// A person who changed their name keeps their conversation.
|
||||
if let Some(old) = before.map(|b| b.name.clone()).filter(|old| *old != name) {
|
||||
state.runtime.rename_buffer(state, &self.account_id, &old, &name, "dm");
|
||||
}
|
||||
let buffer = state.runtime.ensure_buffer(state, &self.account_id, &name, "dm");
|
||||
// Made new, or renamed from its number: a warning on it comes back.
|
||||
super::notices::restore_buffer(state, &self.account_id, &buffer.id);
|
||||
// And whether the account has muted them, wherever it did.
|
||||
state.runtime.set_silenced(state, &buffer.id, roster.muted(account));
|
||||
state.runtime.set_buffer_remote_id(state, &buffer.id, &steamid::text_of(account));
|
||||
// Under no heading of a service's: see groups::unfile_conversations.
|
||||
super::groups::unfile_one(state, &buffer.id);
|
||||
if let Some(url) = &avatar {
|
||||
state.runtime.set_buffer_avatar(state, &buffer.id, url);
|
||||
}
|
||||
|
||||
// The window reads a direct message's dot from its member list;
|
||||
// for a one-to-one that is the other person alone.
|
||||
let member = json!([{
|
||||
"nick": persona.and_then(|p| p.name.clone()).unwrap_or_else(|| name.clone()),
|
||||
"userId": steamid::text_of(account),
|
||||
"status": status,
|
||||
"activity": activity.map(|(kind, text)| json!({ "kind": kind, "text": text })),
|
||||
"playingSince": persona.filter(|p| p.app.is_some() || p.game.is_some()).and_then(|p| p.playing_since),
|
||||
"client": detail.0,
|
||||
"lastSeen": detail.1,
|
||||
"rich": detail.2,
|
||||
"away": status == "idle",
|
||||
"avatarUrl": avatar,
|
||||
}]);
|
||||
state.runtime.set_presence(&buffer.id, member.clone());
|
||||
state.events.emit("presenceChange", json!({ "bufferId": buffer.id, "members": member }));
|
||||
|
||||
self.shown.insert(account, next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::backend::kick::testkit::simulated_daemon;
|
||||
use crate::backend::steam::friends::{REL_FRIEND, Roster};
|
||||
use crate::backend::steam::proto::*;
|
||||
|
||||
const ACCOUNT: &str = "steam:tester";
|
||||
|
||||
fn friends(accounts: &[u32]) -> ClientFriendsList {
|
||||
ClientFriendsList {
|
||||
bincremental: Some(false),
|
||||
friends: accounts.iter().map(|a| FriendsListEntry { ulfriendid: Some(steamid::steamid_of(*a)), efriendrelationship: Some(REL_FRIEND) }).collect(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn who(account: u32, name: &str, state: u32) -> PersonaFriend {
|
||||
PersonaFriend { friendid: Some(steamid::steamid_of(account)), player_name: Some(name.into()), persona_state: Some(state), avatar_hash: Some(vec![7; 20]), ..Default::default() }
|
||||
}
|
||||
|
||||
fn update(people: Vec<PersonaFriend>) -> ClientPersonaState {
|
||||
ClientPersonaState { friends: people, ..Default::default() }
|
||||
}
|
||||
|
||||
fn events(rx: &mut tokio::sync::broadcast::Receiver<crate::events::Event>, name: &str) -> Vec<serde_json::Value> {
|
||||
let mut out = Vec::new();
|
||||
while let Ok(e) = rx.try_recv() {
|
||||
if e.name == name {
|
||||
out.push(e.data);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_friend_becomes_a_direct_message_with_a_face_and_a_dot() {
|
||||
let state = simulated_daemon("steam-publish-basic");
|
||||
let mut rx = state.events.subscribe();
|
||||
let mut roster = Roster::default();
|
||||
roster.apply_friends_list(&friends(&[22202]));
|
||||
roster.apply_persona(&update(vec![who(22202, "Gabe", 1)]));
|
||||
let mut shown = Conversations::new(ACCOUNT);
|
||||
shown.publish(&state, &roster);
|
||||
|
||||
let buffer = state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, "Gabe")).expect("a conversation for the friend");
|
||||
assert_eq!(buffer.kind, "dm");
|
||||
assert_eq!(buffer.remote_id.as_deref(), Some("76561197960287930"), "the person, not the name, is what identifies them");
|
||||
assert!(buffer.avatar_url.as_deref().is_some_and(|u| u.starts_with("https://avatars.steamstatic.com/07070707")));
|
||||
|
||||
let presence = events(&mut rx, "presenceChange");
|
||||
assert_eq!(presence.len(), 1);
|
||||
let members = &presence[0]["members"];
|
||||
assert_eq!(members.as_array().unwrap().len(), 1, "a direct message's list is the other person alone");
|
||||
assert_eq!((members[0]["nick"].as_str(), members[0]["status"].as_str(), members[0]["away"].as_bool()), (Some("Gabe"), Some("online"), Some(false)));
|
||||
assert_eq!(shown.name_of(22202), Some("Gabe"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reconnect_does_not_turn_the_friends_back_into_numbers() {
|
||||
let state = simulated_daemon("steam-publish-reconnect");
|
||||
let mut roster = Roster::default();
|
||||
roster.apply_friends_list(&friends(&[22202]));
|
||||
roster.apply_persona(&update(vec![who(22202, "Gabe", 1)]));
|
||||
Conversations::new(ACCOUNT).publish(&state, &roster);
|
||||
assert!(state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, "Gabe")).is_some());
|
||||
|
||||
// A new session: the conversations as they were, and a roster that has
|
||||
// the friends list and nobody's name yet.
|
||||
let mut shown = Conversations::new(ACCOUNT);
|
||||
shown.adopt(&state, ACCOUNT);
|
||||
let mut fresh = Roster::default();
|
||||
fresh.apply_friends_list(&friends(&[22202]));
|
||||
shown.publish(&state, &fresh);
|
||||
assert_eq!(shown.name_of(22202), Some("Gabe"), "still called what they were called");
|
||||
assert!(state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, "76561197960287930")).is_none(), "and no conversation made under the number");
|
||||
assert!(state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, "Gabe")).is_some());
|
||||
|
||||
// When the name does arrive, a changed one is honoured.
|
||||
fresh.apply_persona(&update(vec![who(22202, "Gabriel", 1)]));
|
||||
shown.publish(&state, &fresh);
|
||||
assert_eq!(shown.name_of(22202), Some("Gabriel"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_warning_survives_the_conversation_being_renamed_from_its_number() {
|
||||
let state = simulated_daemon("steam-publish-warning-rename");
|
||||
// As the last run left it: known by its number, with a warning said in it.
|
||||
let number = steamid::text_of(22202);
|
||||
state.runtime.record_message_at(&state, ACCOUNT, &number, "dm", "Steam", "Steam warns that this person may not be who they appear to be.", false, "system", None, Some(crate::backend::steam::notices::notice_id(22202, 1)), false, None, vec![], vec![], None, None, None, None);
|
||||
let mut shown = Conversations::new(ACCOUNT);
|
||||
let mut roster = Roster::default();
|
||||
roster.apply_friends_list(&friends(&[22202]));
|
||||
shown.adopt(&state, ACCOUNT);
|
||||
shown.publish(&state, &roster);
|
||||
crate::backend::steam::notices::restore(&state, ACCOUNT);
|
||||
assert!(state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, &number)).unwrap().notice.is_some());
|
||||
// Steam says who it is: the conversation becomes Gabe, and the banner is on it.
|
||||
roster.apply_persona(&update(vec![who(22202, "Gabe", 1)]));
|
||||
shown.publish(&state, &roster);
|
||||
let gabe = state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, "Gabe")).expect("renamed");
|
||||
assert!(gabe.notice.is_some(), "the banner is still up on the renamed conversation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_game_on_steam_is_in_game_until_its_name_is_known_and_then_its_name() {
|
||||
let state = simulated_daemon("steam-publish-game-by-number");
|
||||
let mut roster = Roster::default();
|
||||
roster.apply_friends_list(&friends(&[22202, 22203]));
|
||||
// Steam names no game of its own: the number alone, as it sends it.
|
||||
let playing = |account: u32, app: u64| PersonaFriend { friendid: Some(steamid::steamid_of(account)), player_name: Some(format!("P{account}")), persona_state: Some(1), gameid: Some(app), game_played_app_id: Some(app as u32), ..Default::default() };
|
||||
roster.apply_persona(&update(vec![playing(22202, 730), playing(22203, 730)]));
|
||||
assert_eq!(roster.unnamed_apps(), vec![730], "one question for two people in the same game");
|
||||
let mut shown = Conversations::new(ACCOUNT);
|
||||
let mut rx = state.events.subscribe();
|
||||
shown.publish(&state, &roster);
|
||||
let said = events(&mut rx, "presenceChange");
|
||||
let text = |said: &[serde_json::Value], who: &str| said.iter().rev().find(|e| e["bufferId"].as_str().is_some_and(|b| b.ends_with(who))).and_then(|e| e["members"][0]["activity"].clone().as_object().cloned());
|
||||
assert_eq!(text(&said, "P22202").unwrap()["text"], "In game");
|
||||
roster.set_app_name(730, "Counter-Strike 2".into());
|
||||
assert!(roster.unnamed_apps().is_empty());
|
||||
shown.publish(&state, &roster);
|
||||
let said = events(&mut rx, "presenceChange");
|
||||
assert_eq!(text(&said, "P22202").unwrap()["text"], "Counter-Strike 2", "named, and said again");
|
||||
assert_eq!(text(&said, "P22203").unwrap()["kind"], "game");
|
||||
// A game that has a name from Steam is not asked about, and stopping is stopping.
|
||||
roster.apply_persona(&update(vec![PersonaFriend { friendid: Some(steamid::steamid_of(22202)), game_name: Some("My Mod".into()), gameid: Some(1 << 40), ..Default::default() }]));
|
||||
assert_eq!(roster.game_of(22202).as_deref(), Some("My Mod"));
|
||||
roster.apply_persona(&update(vec![PersonaFriend { friendid: Some(steamid::steamid_of(22202)), gameid: Some(0), ..Default::default() }]));
|
||||
assert_eq!(roster.game_of(22202), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mute_set_on_steam_silences_the_conversation_here_and_lifting_it_unsilences() {
|
||||
let state = simulated_daemon("steam-publish-mute");
|
||||
let mut roster = Roster::default();
|
||||
roster.apply_friends_list(&friends(&[22202]));
|
||||
roster.apply_persona(&update(vec![who(22202, "Gabe", 1)]));
|
||||
let mut shown = Conversations::new(ACCOUNT);
|
||||
shown.publish(&state, &roster);
|
||||
let id = crate::model::buffer_id(ACCOUNT, "Gabe");
|
||||
assert!(!state.runtime.get_buffer(&id).unwrap().server_muted);
|
||||
// Muted in another client: it arrives as the account's preferences for them.
|
||||
roster.set_preferences(&PerFriendPreferences { accountid: Some(22202), notifications_showmessages: Some(crate::backend::steam::friends::NOTIFY_NEVER), ..Default::default() });
|
||||
shown.apply_mutes(&state, &roster);
|
||||
assert!(state.runtime.get_buffer(&id).unwrap().server_muted, "the row says it is muted");
|
||||
roster.set_preferences(&PerFriendPreferences { accountid: Some(22202), ..Default::default() });
|
||||
shown.apply_mutes(&state, &roster);
|
||||
assert!(!state.runtime.get_buffer(&id).unwrap().server_muted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_is_said_again_when_nothing_changed() {
|
||||
let state = simulated_daemon("steam-publish-quiet");
|
||||
let mut roster = Roster::default();
|
||||
roster.apply_friends_list(&friends(&[1, 2]));
|
||||
roster.apply_persona(&update(vec![who(1, "One", 1), who(2, "Two", 3)]));
|
||||
let mut shown = Conversations::new(ACCOUNT);
|
||||
shown.publish(&state, &roster);
|
||||
|
||||
let mut rx = state.events.subscribe();
|
||||
shown.publish(&state, &roster);
|
||||
assert!(events(&mut rx, "presenceChange").is_empty());
|
||||
assert!(events(&mut rx, "bufferListChange").is_empty());
|
||||
|
||||
// One of them changes: only that one is said.
|
||||
roster.apply_persona(&update(vec![who(2, "Two", 1)]));
|
||||
shown.publish(&state, &roster);
|
||||
let said = events(&mut rx, "presenceChange");
|
||||
assert_eq!(said.len(), 1);
|
||||
assert_eq!(said[0]["members"][0]["nick"], "Two");
|
||||
assert_eq!(said[0]["members"][0]["status"], "online");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_game_and_the_states_the_colours_share_are_said_beside_the_dot_and_change_when_they_do() {
|
||||
let state = simulated_daemon("steam-publish-text");
|
||||
let mut roster = Roster::default();
|
||||
roster.apply_friends_list(&friends(&[1, 2, 3]));
|
||||
roster.apply_persona(&update(vec![who(1, "Plain", 1), who(2, "Snoozer", 4), who(3, "Gamer", 1)]));
|
||||
let game = |name: &str| PersonaFriend { friendid: Some(steamid::steamid_of(3)), gameid: Some(730), game_name: Some(name.into()), ..Default::default() };
|
||||
roster.apply_persona(&update(vec![game("Counter-Strike 2")]));
|
||||
let mut shown = Conversations::new(ACCOUNT);
|
||||
let mut rx = state.events.subscribe();
|
||||
shown.publish(&state, &roster);
|
||||
let text = |said: &[serde_json::Value], nick: &str| said.iter().find(|e| e["members"][0]["nick"] == nick).map(|e| e["members"][0]["activity"].clone());
|
||||
let said = events(&mut rx, "presenceChange");
|
||||
assert_eq!(text(&said, "Plain"), Some(serde_json::Value::Null), "online says nothing under the name");
|
||||
assert_eq!(text(&said, "Snoozer"), Some(serde_json::json!({ "kind": "custom", "text": "Snoozing" })));
|
||||
assert_eq!(text(&said, "Gamer"), Some(serde_json::json!({ "kind": "game", "text": "Counter-Strike 2" })));
|
||||
// The dot is still what it was for each: a game does not change it.
|
||||
assert_eq!(said.iter().find(|e| e["members"][0]["nick"] == "Gamer").unwrap()["members"][0]["status"], "online");
|
||||
|
||||
// They stop playing: said once, for that person alone.
|
||||
roster.apply_persona(&update(vec![PersonaFriend { friendid: Some(steamid::steamid_of(3)), gameid: Some(0), ..Default::default() }]));
|
||||
shown.publish(&state, &roster);
|
||||
let said = events(&mut rx, "presenceChange");
|
||||
assert_eq!(said.len(), 1);
|
||||
assert_eq!(said[0]["members"][0]["nick"], "Gamer");
|
||||
assert!(said[0]["members"][0]["activity"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn away_is_idle_and_offline_is_offline_and_a_game_does_not_change_either() {
|
||||
let state = simulated_daemon("steam-publish-states");
|
||||
let mut roster = Roster::default();
|
||||
roster.apply_friends_list(&friends(&[1, 2, 3, 4]));
|
||||
roster.apply_persona(&update(vec![who(1, "Away", 3), who(2, "Busy", 2), who(3, "Gone", 0), who(4, "Hidden", 7)]));
|
||||
let mut rx = state.events.subscribe();
|
||||
Conversations::new(ACCOUNT).publish(&state, &roster);
|
||||
let said = events(&mut rx, "presenceChange");
|
||||
let by_name: HashMap<String, (String, bool)> = said
|
||||
.iter()
|
||||
.map(|e| (e["members"][0]["nick"].as_str().unwrap().to_string(), (e["members"][0]["status"].as_str().unwrap().to_string(), e["members"][0]["away"].as_bool().unwrap())))
|
||||
.collect();
|
||||
assert_eq!(by_name["Away"], ("idle".to_string(), true));
|
||||
assert_eq!(by_name["Busy"], ("dnd".to_string(), false));
|
||||
assert_eq!(by_name["Gone"], ("offline".to_string(), false));
|
||||
assert_eq!(by_name["Hidden"].0, "offline", "invisible reads as gone, as it does in Steam");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_friend_who_renames_keeps_their_conversation_and_its_history() {
|
||||
let state = simulated_daemon("steam-publish-rename");
|
||||
let mut roster = Roster::default();
|
||||
roster.apply_friends_list(&friends(&[5]));
|
||||
roster.apply_persona(&update(vec![who(5, "Old Name", 1)]));
|
||||
let mut shown = Conversations::new(ACCOUNT);
|
||||
shown.publish(&state, &roster);
|
||||
state.runtime.record_message(&state, ACCOUNT, "Old Name", "dm", "Old Name", "hello there", false, "chat", None, Some("1:1".into()), false, None, Vec::new(), Vec::new(), None);
|
||||
|
||||
roster.apply_persona(&update(vec![who(5, "New Name", 1)]));
|
||||
shown.publish(&state, &roster);
|
||||
|
||||
assert!(state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, "Old Name")).is_none(), "not left behind as a second conversation");
|
||||
assert!(state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, "New Name")).is_some());
|
||||
let history = state.store.get_backlog(&crate::model::buffer_id(ACCOUNT, "New Name"), 0, 10).unwrap();
|
||||
assert_eq!(history.iter().map(|m| m.body.as_str()).collect::<Vec<_>>(), ["hello there"], "the history moved with them");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_friends_who_share_a_name_get_two_conversations_and_stay_two() {
|
||||
let state = simulated_daemon("steam-publish-shared");
|
||||
let mut roster = Roster::default();
|
||||
roster.apply_friends_list(&friends(&[10, 20]));
|
||||
roster.apply_persona(&update(vec![who(10, "Alex", 1)]));
|
||||
let mut shown = Conversations::new(ACCOUNT);
|
||||
shown.publish(&state, &roster);
|
||||
assert_eq!(shown.name_of(10), Some("Alex"));
|
||||
|
||||
roster.apply_persona(&update(vec![who(20, "Alex", 1)]));
|
||||
shown.publish(&state, &roster);
|
||||
assert_eq!((shown.name_of(10), shown.name_of(20)), (Some("Alex (10)"), Some("Alex (20)")));
|
||||
let one = state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, "Alex (10)")).unwrap();
|
||||
let two = state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, "Alex (20)")).unwrap();
|
||||
assert_ne!(one.remote_id, two.remote_id);
|
||||
assert!(state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, "Alex")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_friend_renamed_while_moho_was_closed_keeps_their_conversation() {
|
||||
let state = simulated_daemon("steam-publish-adopt");
|
||||
// What a previous run left: a conversation with its person recorded.
|
||||
let buffer = state.runtime.ensure_buffer(&state, ACCOUNT, "Before", "dm");
|
||||
state.runtime.set_buffer_remote_id(&state, &buffer.id, &steamid::text_of(9));
|
||||
state.runtime.record_message(&state, ACCOUNT, "Before", "dm", "Before", "an old message", false, "chat", None, Some("1:0".into()), false, None, Vec::new(), Vec::new(), None);
|
||||
|
||||
let mut roster = Roster::default();
|
||||
roster.apply_friends_list(&friends(&[9]));
|
||||
roster.apply_persona(&update(vec![who(9, "After", 1)]));
|
||||
let mut shown = Conversations::new(ACCOUNT);
|
||||
shown.adopt(&state, ACCOUNT);
|
||||
shown.publish(&state, &roster);
|
||||
|
||||
assert!(state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, "Before")).is_none());
|
||||
let moved = state.store.get_backlog(&crate::model::buffer_id(ACCOUNT, "After"), 0, 10).unwrap();
|
||||
assert_eq!(moved.iter().map(|m| m.body.as_str()).collect::<Vec<_>>(), ["an old message"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_accounts_conversations_are_not_adopted() {
|
||||
let state = simulated_daemon("steam-publish-adopt-other");
|
||||
let theirs = state.runtime.ensure_buffer(&state, "steam:someone-else", "Friend", "dm");
|
||||
state.runtime.set_buffer_remote_id(&state, &theirs.id, &steamid::text_of(9));
|
||||
let mut shown = Conversations::new(ACCOUNT);
|
||||
shown.adopt(&state, ACCOUNT);
|
||||
assert_eq!(shown.name_of(9), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn somebody_who_has_not_been_named_yet_is_called_by_their_id_and_renamed_when_known() {
|
||||
let state = simulated_daemon("steam-publish-unnamed");
|
||||
let mut roster = Roster::default();
|
||||
roster.apply_friends_list(&friends(&[22202]));
|
||||
let mut shown = Conversations::new(ACCOUNT);
|
||||
shown.publish(&state, &roster);
|
||||
assert_eq!(shown.name_of(22202), Some("76561197960287930"), "until Steam says who they are, their id");
|
||||
roster.apply_persona(&update(vec![who(22202, "Gabe", 1)]));
|
||||
shown.publish(&state, &roster);
|
||||
assert_eq!(shown.name_of(22202), Some("Gabe"));
|
||||
assert!(state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, "76561197960287930")).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Reacting to a message with an emoticon or a sticker, and seeing others do.
|
||||
//!
|
||||
//! A reaction on Steam is an emoticon or sticker named by a word, held by a list
|
||||
//! of people. moho keeps them as it keeps everyone's: a count, and whether it
|
||||
//! is ours, under a token - `[steam:emote:steamhappy]` - that the window draws
|
||||
//! as the picture. The message is found by the id it is stored under, the
|
||||
//! second Steam stamped it and its place in that second, which is also how
|
||||
//! Steam names it.
|
||||
|
||||
use super::proto::{MessageReactionNotice, PastReaction, UpdateMessageReactionRequest, UpdateMessageReactionResponse};
|
||||
use super::steamid;
|
||||
use crate::model::{Buffer, Reaction};
|
||||
use crate::state::AppState;
|
||||
use serde_json::json;
|
||||
|
||||
/// `EMessageReactionType`.
|
||||
pub const EMOTICON: i32 = 1;
|
||||
pub const STICKER: i32 = 2;
|
||||
|
||||
fn valid_name(kind: i32, name: &str) -> bool {
|
||||
if kind == STICKER {
|
||||
return super::emoticons::valid_sticker(name);
|
||||
}
|
||||
!name.is_empty() && name.len() <= 64 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
}
|
||||
|
||||
/// How a reaction is held here: the token the window draws as a picture.
|
||||
pub fn token(kind: i32, name: &str) -> Option<String> {
|
||||
let name = name.trim().trim_matches(':');
|
||||
valid_name(kind, name).then(|| format!("[steam:{}:{name}]", if kind == STICKER { "sticker" } else { "emote" }))
|
||||
}
|
||||
|
||||
/// What a token held here is, as Steam names it: its kind and its word.
|
||||
pub fn parse_token(emoji: &str) -> Option<(i32, String)> {
|
||||
let inner = emoji.strip_prefix("[steam:")?.strip_suffix(']')?;
|
||||
let (kind, name) = inner.split_once(':')?;
|
||||
let kind = match kind {
|
||||
"emote" => EMOTICON,
|
||||
"sticker" => STICKER,
|
||||
_ => return None,
|
||||
};
|
||||
valid_name(kind, name).then(|| (kind, name.to_string()))
|
||||
}
|
||||
|
||||
/// How Steam spells a reaction: an emoticon between colons, as in a message, and a
|
||||
/// sticker by its bare name. (Found in Steam's own client, and refused as
|
||||
/// anything else.)
|
||||
fn on_the_wire(kind: i32, name: &str) -> String {
|
||||
if kind == EMOTICON { format!(":{name}:") } else { name.to_string() }
|
||||
}
|
||||
|
||||
/// A message's id here, as the second and ordinal Steam names it by.
|
||||
pub fn parse_message_id(id: &str) -> Option<(u32, u32)> {
|
||||
let (ts, ord) = id.split_once(':')?;
|
||||
Some((ts.parse().ok()?, ord.parse().ok()?))
|
||||
}
|
||||
|
||||
/// The reactions out of a history entry, as held here.
|
||||
pub fn from_history(own: u32, list: &[PastReaction]) -> Vec<Reaction> {
|
||||
list.iter()
|
||||
.filter_map(|r| {
|
||||
let emoji = token(r.reaction_type.unwrap_or(EMOTICON), r.reaction.as_deref()?)?;
|
||||
(!r.reactors.is_empty()).then(|| Reaction { emoji, count: r.reactors.len() as i64, me: r.reactors.contains(&own), animated: false })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Puts the list of people who hold one reaction on a message, replacing what
|
||||
/// was held: Steam's answer to a change is who holds it now, which is truer
|
||||
/// than adding one to a count.
|
||||
fn set_reactors(state: &AppState, buffer_id: &str, msg_id: &str, emoji: &str, reactors: &[u32], own: u32) -> Result<(), String> {
|
||||
let message = state.store.get_message(buffer_id, msg_id).map_err(|e| e.to_string())?.ok_or("that message is not in the log")?;
|
||||
let mut held: Vec<Reaction> = message.reactions;
|
||||
held.retain(|r| r.emoji != emoji);
|
||||
if !reactors.is_empty() {
|
||||
held.push(Reaction { emoji: emoji.to_string(), count: reactors.len() as i64, me: reactors.contains(&own), animated: false });
|
||||
}
|
||||
state.store.set_reactions(buffer_id, msg_id, &held).map_err(|e| e.to_string())?;
|
||||
state.events.emit("reactionsChanged", json!({ "bufferId": buffer_id, "id": msg_id, "reactions": held }));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Adds or takes off our reaction on a message.
|
||||
pub async fn toggle(state: &AppState, buffer: &Buffer, msg_id: &str, emoji: &str, add: bool) -> Result<(), String> {
|
||||
let link = state.runtime.steam_link(&buffer.account_id).ok_or("this Steam account is not connected")?;
|
||||
let (kind, name) = parse_token(emoji).ok_or("Steam reactions are its own emoticons and stickers")?;
|
||||
let (timestamp, ordinal) = parse_message_id(msg_id).ok_or("that message has no place in Steam's log")?;
|
||||
let friend: u64 = buffer.remote_id.as_deref().and_then(|id| id.parse().ok()).filter(|id| steamid::account_of(*id).is_some()).ok_or("this conversation is not with a Steam account")?;
|
||||
let own = state.accounts.get_steam(&buffer.account_id).and_then(|c| steamid::account_of(c.steamid)).ok_or("this Steam account is not set up")?;
|
||||
let request = UpdateMessageReactionRequest { steamid: Some(friend), server_timestamp: Some(timestamp), ordinal: Some(ordinal), reaction_type: Some(kind), reaction: Some(on_the_wire(kind, &name)), is_add: Some(add) };
|
||||
let answer: UpdateMessageReactionResponse = link.call("FriendMessages.UpdateMessageReaction#1", &request, true).await.map_err(|e| super::chat::refusal(&e))?;
|
||||
set_reactors(state, &buffer.id, msg_id, emoji, &answer.reactors, own)
|
||||
}
|
||||
|
||||
/// Somebody reacted, or took it back, in a conversation that exists.
|
||||
pub fn heard(state: &AppState, buffer_id: &str, own: u32, notice: &MessageReactionNotice) {
|
||||
let (Some(ts), Some(ord)) = (notice.server_timestamp, notice.ordinal) else { return };
|
||||
let Some(emoji) = token(notice.reaction_type.unwrap_or(EMOTICON), notice.reaction.as_deref().unwrap_or_default()) else { return };
|
||||
let by_us = notice.reactor.and_then(steamid::account_of) == Some(own);
|
||||
state.runtime.update_reaction(state, buffer_id, &super::chat::message_id(ts, ord), &emoji, by_us, notice.is_add.unwrap_or(true));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::backend::kick::testkit::simulated_daemon;
|
||||
use crate::backend::steam::link::{self, fake::*};
|
||||
use prost::Message;
|
||||
use std::time::Duration;
|
||||
|
||||
const ACCOUNT: &str = "steam:tester";
|
||||
const ME: u32 = 22202;
|
||||
const FRIEND: u32 = 100;
|
||||
|
||||
#[test]
|
||||
fn a_reaction_is_a_token_for_a_picture_and_back() {
|
||||
assert_eq!(token(EMOTICON, "steamhappy").as_deref(), Some("[steam:emote:steamhappy]"));
|
||||
assert_eq!(token(EMOTICON, ":steamhappy:").as_deref(), Some("[steam:emote:steamhappy]"), "colons are not part of it");
|
||||
assert_eq!(token(STICKER, "CatSurprise").as_deref(), Some("[steam:sticker:CatSurprise]"));
|
||||
assert_eq!(token(EMOTICON, "a b"), None);
|
||||
assert_eq!(token(EMOTICON, ""), None);
|
||||
assert_eq!(parse_token("[steam:emote:steamhappy]"), Some((EMOTICON, "steamhappy".to_string())));
|
||||
assert_eq!(parse_token("[steam:sticker:CatSurprise]"), Some((STICKER, "CatSurprise".to_string())));
|
||||
assert_eq!(token(STICKER, "Nyan GLaDOS").as_deref(), Some("[steam:sticker:Nyan GLaDOS]"), "a sticker's name has spaces in it");
|
||||
assert_eq!(parse_token("[steam:sticker:Nyan GLaDOS]"), Some((STICKER, "Nyan GLaDOS".to_string())));
|
||||
for not in ["👍", "<:name:123>", "[steam:other:x]", "[steam:emote:a b]", "[steam:emote:]", "steam:emote:x"] {
|
||||
assert_eq!(parse_token(not), None, "{not}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steam_spells_an_emoticon_with_colons_and_a_sticker_without() {
|
||||
assert_eq!(on_the_wire(EMOTICON, "steamhappy"), ":steamhappy:");
|
||||
assert_eq!(on_the_wire(STICKER, "CatSurprise"), "CatSurprise");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_message_is_named_by_its_second_and_ordinal() {
|
||||
assert_eq!(parse_message_id("1790000000:3"), Some((1790000000, 3)));
|
||||
for not in ["", "1790000000", "a:b", "1:", ":1", "-1:0", "1:2:3"] {
|
||||
assert_eq!(parse_message_id(not), None, "{not:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_reactions_count_who_and_whether_it_is_ours() {
|
||||
let list = vec![
|
||||
PastReaction { reaction_type: Some(1), reaction: Some("steamhappy".into()), reactors: vec![FRIEND, ME] },
|
||||
PastReaction { reaction_type: Some(2), reaction: Some("CatSurprise".into()), reactors: vec![FRIEND] },
|
||||
PastReaction { reaction_type: Some(1), reaction: Some("empty".into()), reactors: vec![] },
|
||||
PastReaction { reaction_type: Some(1), reaction: Some("bad name".into()), reactors: vec![FRIEND] },
|
||||
];
|
||||
let got = from_history(ME, &list);
|
||||
assert_eq!(got.iter().map(|r| (r.emoji.as_str(), r.count, r.me)).collect::<Vec<_>>(), [("[steam:emote:steamhappy]", 2, true), ("[steam:sticker:CatSurprise]", 1, false)]);
|
||||
}
|
||||
|
||||
fn conversation(name: &str) -> (AppState, Buffer, String) {
|
||||
let state = simulated_daemon(name);
|
||||
state.accounts.add_steam(crate::accounts::SteamAccountConfig { username: "tester".into(), steamid: steamid::steamid_of(ME), ..Default::default() }).unwrap();
|
||||
let buffer = state.runtime.ensure_buffer(&state, ACCOUNT, "Gabe", "dm");
|
||||
state.runtime.set_buffer_remote_id(&state, &buffer.id, &steamid::text_of(FRIEND));
|
||||
let buffer = state.runtime.get_buffer(&buffer.id).unwrap();
|
||||
state.runtime.record_message_at(&state, ACCOUNT, "Gabe", "dm", "Gabe", "hello", false, "chat", None, Some("1790000000:1".into()), false, None, vec![], vec![], None, Some(1790000000), None, None);
|
||||
(state, buffer, "1790000000:1".to_string())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reacting_asks_steam_and_holds_who_it_says_now_has_it() {
|
||||
let (state, buffer, msg) = conversation("steam-react-toggle");
|
||||
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let url = serve({
|
||||
let seen = seen.clone();
|
||||
Box::new(move |p| {
|
||||
seen.lock().unwrap().push(UpdateMessageReactionRequest::decode(p.body.as_slice()).unwrap());
|
||||
let is_add = seen.lock().unwrap().last().unwrap().is_add == Some(true);
|
||||
// Steam answers who holds it now: the friend always, and us while added.
|
||||
let reactors = if is_add { vec![FRIEND, ME] } else { vec![FRIEND] };
|
||||
Some(vec![answer(&p, 1, UpdateMessageReactionResponse { reactors }.encode_to_vec())])
|
||||
})
|
||||
})
|
||||
.await;
|
||||
state.runtime.set_steam_link(ACCOUNT, link::open_with("test", &url, Duration::from_secs(5)).await.unwrap().0);
|
||||
let mut events = state.events.subscribe();
|
||||
|
||||
toggle(&state, &buffer, &msg, "[steam:emote:steamhappy]", true).await.unwrap();
|
||||
let held = state.store.get_message(&buffer.id, &msg).unwrap().unwrap().reactions;
|
||||
assert_eq!(held.iter().map(|r| (r.emoji.as_str(), r.count, r.me)).collect::<Vec<_>>(), [("[steam:emote:steamhappy]", 2, true)]);
|
||||
let sent = seen.lock().unwrap()[0].clone();
|
||||
assert_eq!((sent.steamid, sent.server_timestamp, sent.ordinal, sent.reaction_type, sent.reaction.as_deref(), sent.is_add), (Some(steamid::steamid_of(FRIEND)), Some(1790000000), Some(1), Some(1), Some(":steamhappy:"), Some(true)));
|
||||
assert!(std::iter::from_fn(|| events.try_recv().ok()).any(|e| e.name == "reactionsChanged"), "the window is told");
|
||||
|
||||
toggle(&state, &buffer, &msg, "[steam:emote:steamhappy]", false).await.unwrap();
|
||||
let held = state.store.get_message(&buffer.id, &msg).unwrap().unwrap().reactions;
|
||||
assert_eq!(held.iter().map(|r| (r.count, r.me)).collect::<Vec<_>>(), [(1, false)], "ours is off, theirs stays");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn what_is_not_one_of_steams_is_refused_before_it_is_asked() {
|
||||
let (state, buffer, msg) = conversation("steam-react-refused");
|
||||
let url = serve(Box::new(|_| panic!("nothing should be asked"))).await;
|
||||
state.runtime.set_steam_link(ACCOUNT, link::open_with("test", &url, Duration::from_secs(5)).await.unwrap().0);
|
||||
assert!(toggle(&state, &buffer, &msg, "👍", true).await.unwrap_err().contains("emoticons"));
|
||||
assert!(toggle(&state, &buffer, "nonsense", "[steam:emote:x]", true).await.is_err());
|
||||
// A message that is not in the log has nothing to put it on.
|
||||
let url = serve(Box::new(|p| Some(vec![answer(&p, 1, UpdateMessageReactionResponse { reactors: vec![ME] }.encode_to_vec())]))).await;
|
||||
state.runtime.set_steam_link(ACCOUNT, link::open_with("test", &url, Duration::from_secs(5)).await.unwrap().0);
|
||||
assert!(toggle(&state, &buffer, "5:5", "[steam:emote:x]", true).await.unwrap_err().contains("not in the log"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn somebody_else_reacting_adds_to_the_count_and_taking_it_back_removes_it() {
|
||||
let (state, buffer, msg) = conversation("steam-react-heard");
|
||||
let notice = |reactor: u32, add: bool| MessageReactionNotice {
|
||||
steamid_friend: Some(steamid::steamid_of(FRIEND)),
|
||||
server_timestamp: Some(1790000000),
|
||||
ordinal: Some(1),
|
||||
reactor: Some(steamid::steamid_of(reactor)),
|
||||
reaction_type: Some(EMOTICON),
|
||||
reaction: Some("steamhappy".into()),
|
||||
is_add: Some(add),
|
||||
};
|
||||
heard(&state, &buffer.id, ME, ¬ice(FRIEND, true));
|
||||
heard(&state, &buffer.id, ME, ¬ice(ME, true));
|
||||
let held = state.store.get_message(&buffer.id, &msg).unwrap().unwrap().reactions;
|
||||
assert_eq!(held.iter().map(|r| (r.count, r.me)).collect::<Vec<_>>(), [(2, true)], "a friend's and ours, from another client");
|
||||
heard(&state, &buffer.id, ME, ¬ice(FRIEND, false));
|
||||
heard(&state, &buffer.id, ME, ¬ice(ME, false));
|
||||
assert!(state.store.get_message(&buffer.id, &msg).unwrap().unwrap().reactions.is_empty(), "none left, none shown");
|
||||
// Nonsense is nothing.
|
||||
heard(&state, &buffer.id, ME, &MessageReactionNotice { reaction: Some("bad name".into()), ..notice(FRIEND, true) });
|
||||
heard(&state, &buffer.id, ME, &MessageReactionNotice { server_timestamp: None, ..notice(FRIEND, true) });
|
||||
assert!(state.store.get_message(&buffer.id, &msg).unwrap().unwrap().reactions.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! A Steam account's identity: the 32-bit account number a friends list and a
|
||||
//! chat message use, and the 64-bit SteamID the connection and the profile
|
||||
//! pages use.
|
||||
//!
|
||||
//! A SteamID64 is that account number with three more things packed in beside
|
||||
//! it: the universe (1, the public one), the type of account (1, an
|
||||
//! individual - groups and games have their own) and an instance (1, the
|
||||
//! desktop one, which every ordinary person's account has). So for a person it
|
||||
//! is always the account number added to one constant, and these two functions
|
||||
//! are the whole conversion.
|
||||
|
||||
/// Universe 1, type 1, instance 1, account 0: what a person's id starts from.
|
||||
pub const INDIVIDUAL_BASE: u64 = (1 << 56) | (1 << 52) | (1 << 32);
|
||||
|
||||
/// The 64-bit id of the person with this account number.
|
||||
pub fn steamid_of(account: u32) -> u64 {
|
||||
INDIVIDUAL_BASE + u64::from(account)
|
||||
}
|
||||
|
||||
/// The account number in a person's 64-bit id, or `None` for an id that is not
|
||||
/// a person's (a group's, a game server's, or nonsense).
|
||||
pub fn account_of(steamid: u64) -> Option<u32> {
|
||||
// The universe, type and instance must be exactly an individual's; what is
|
||||
// left is the account number, which is 32 bits.
|
||||
let rest = steamid.checked_sub(INDIVIDUAL_BASE)?;
|
||||
u32::try_from(rest).ok()
|
||||
}
|
||||
|
||||
/// The id the way it is written in a profile address.
|
||||
pub fn text_of(account: u32) -> String {
|
||||
steamid_of(account).to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Gabe Newell's account, whose id is the most photographed in existence:
|
||||
/// account number 22202 is 76561197960287930.
|
||||
#[test]
|
||||
fn the_two_forms_of_an_id_convert_both_ways() {
|
||||
assert_eq!(steamid_of(22202), 76561197960287930);
|
||||
assert_eq!(account_of(76561197960287930), Some(22202));
|
||||
assert_eq!(INDIVIDUAL_BASE, 76561197960265728);
|
||||
assert_eq!(account_of(steamid_of(u32::MAX)), Some(u32::MAX));
|
||||
assert_eq!(account_of(steamid_of(0)), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn something_that_is_not_a_person_is_not_converted() {
|
||||
// A group's id has type 7 where a person's has 1.
|
||||
let group = (1u64 << 56) | (7 << 52) | (1 << 32) | 12345;
|
||||
assert_eq!(account_of(group), None);
|
||||
assert_eq!(account_of(0), None);
|
||||
assert_eq!(account_of(INDIVIDUAL_BASE - 1), None);
|
||||
// Too large to be an account number added to the base.
|
||||
assert_eq!(account_of(INDIVIDUAL_BASE + u64::from(u32::MAX) + 1), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
//! Steam voice: calls to a friend and voice in a chat group's room.
|
||||
//!
|
||||
//! Steam's voice is WebRTC with Valve's own server as the one peer: the client
|
||||
//! offers, the server answers with an ICE-lite description of a relay it holds
|
||||
//! for the session, media goes there, and the server mixes and forwards it. A
|
||||
//! browser speaks all of that already, so the media lives in the window - the
|
||||
//! same division `lib/webrtc.ts` draws for Matrix - and this carries what is
|
||||
//! the daemon's: the signalling, which is calls and notifications on the
|
||||
//! account's own connection.
|
||||
//!
|
||||
//! None of it is in the definitions extracted from the client binary. It is in
|
||||
//! the Steam client's own JavaScript, which is where `voicewire.rs` and the
|
||||
//! order of calls below were read from: offer, then `InitiateWebRTCConnection`;
|
||||
//! the server says the session is connected; then either `JoinVoiceChat` for a
|
||||
//! room or `RequestOneOnOneChat` for a friend; then `UpdateVoiceChatWebRTCData`
|
||||
//! tells the chat which stream is ours. While it runs the server renegotiates
|
||||
//! as people come and go, and says who is who by stream number.
|
||||
//!
|
||||
//! The window sends a call as `{ op, ... }` and hears everything the server says
|
||||
//! as a `steamVoice` event. Every 64-bit number crosses as text, since the
|
||||
//! window cannot hold one.
|
||||
|
||||
use super::chat::refusal;
|
||||
use super::chatgroups;
|
||||
use super::link::Link;
|
||||
use super::steamid;
|
||||
use super::voicewire::*;
|
||||
use crate::state::AppState;
|
||||
use prost::Message;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
fn id_of(v: &Value, key: &str) -> Option<u64> {
|
||||
match &v[key] {
|
||||
Value::String(s) => s.parse().ok(),
|
||||
Value::Number(n) => n.as_u64(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn num(v: &Value, key: &str) -> Option<u32> {
|
||||
v[key].as_u64().and_then(|n| u32::try_from(n).ok())
|
||||
}
|
||||
|
||||
fn text(v: &Value, key: &str) -> Option<String> {
|
||||
v[key].as_str().map(str::to_string)
|
||||
}
|
||||
|
||||
/// A call that is only said, with no answer to wait for.
|
||||
fn say<Q: Message>(link: &Link, method: &str, request: &Q) -> Result<(), String> {
|
||||
link.notify(method, request).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn ask<Q: Message, A: Message + Default>(link: &Link, method: &str, request: &Q) -> Result<A, String> {
|
||||
link.call(method, request, true).await.map_err(|e| voice_refusal(&e, method))
|
||||
}
|
||||
|
||||
/// What the window reads at the front of a failure that may be gone in a moment, as the Steam client
|
||||
/// reads them: it tries a call again, after a little longer each time, for up to a minute.
|
||||
pub const AGAIN: &str = "[again] ";
|
||||
|
||||
/// Why a voice call was refused, in the words that fit it. A failure that is the server's and
|
||||
/// passing is marked [`AGAIN`] for the window to try again; being refused for good is said.
|
||||
fn voice_refusal(error: &super::link::CallError, method: &str) -> String {
|
||||
use super::link::CallError;
|
||||
use super::eresult as e;
|
||||
let transient = match error {
|
||||
CallError::TimedOut => true,
|
||||
CallError::Refused(code, _) => matches!(*code, e::FAIL | e::BUSY | e::TIMEOUT | e::SERVICE_UNAVAILABLE | 32 | 33 | 37 | 55),
|
||||
_ => false,
|
||||
};
|
||||
if transient {
|
||||
return format!("{AGAIN}{}", refusal(error));
|
||||
}
|
||||
match error {
|
||||
CallError::Refused(e::ACCESS_DENIED, _) if method.starts_with("ChatRoom.JoinVoiceChat") => "you may not join voice in this room".to_string(),
|
||||
CallError::Refused(e::ACCESS_DENIED, _) if method.starts_with("VoiceChat.RequestOneOnOneChat") => "this person is not taking calls from you".to_string(),
|
||||
CallError::Refused(e::BLOCKED, _) | CallError::Refused(e::IGNORED, _) => "this person cannot be called".to_string(),
|
||||
CallError::Refused(e::LIMIT_EXCEEDED, _) => "the voice chat is full".to_string(),
|
||||
CallError::Refused(e::NO_MATCH | e::FILE_NOT_FOUND | e::INVALID_PARAM | e::INVALID_STATE, _) if method.starts_with("ChatRoom.JoinVoiceChat") => "this room has no voice".to_string(),
|
||||
CallError::Refused(e::NO_MATCH | e::FILE_NOT_FOUND | e::INVALID_STATE, _) => "that call is over".to_string(),
|
||||
other => refusal(other),
|
||||
}
|
||||
}
|
||||
|
||||
/// One thing the window asks of the voice, and what Steam answers.
|
||||
pub async fn act(state: &AppState, account_id: &str, params: &Value) -> Result<Value, String> {
|
||||
let link = state.runtime.steam_link(account_id).ok_or("this Steam account is not connected")?;
|
||||
let op = params["op"].as_str().ok_or("steamVoice needs an \"op\"")?;
|
||||
match op {
|
||||
// The offer, and the answer from the server that will carry the session.
|
||||
"initiate" => {
|
||||
let request = InitiateWebRTCConnectionRequest { sdp: text(params, "sdp"), browser_name: text(params, "browserName"), browser_version: text(params, "browserVersion") };
|
||||
let answer: InitiateWebRTCConnectionResponse = ask(&link, "WebRTCClient.InitiateWebRTCConnection#1", &request).await?;
|
||||
Ok(json!({ "remoteDescription": answer.remote_description }))
|
||||
}
|
||||
// A new offer on a session that is up (the browser's own renegotiation).
|
||||
"update" => {
|
||||
let request = UpdateWebRTCConnectionRequest {
|
||||
ip_webrtc_server: num(params, "serverIp"),
|
||||
port_webrtc_server: num(params, "serverPort"),
|
||||
ip_webrtc_session_client: num(params, "clientIp"),
|
||||
port_webrtc_session_client: num(params, "clientPort"),
|
||||
sdp: text(params, "sdp"),
|
||||
};
|
||||
let answer: UpdateWebRTCConnectionResponse = ask(&link, "WebRTCClient.UpdateWebRTCConnection#1", &request).await?;
|
||||
Ok(json!({ "remoteDescription": answer.remote_description }))
|
||||
}
|
||||
// The window has taken the server's new offer and answered it.
|
||||
"acknowledge" => {
|
||||
let request = AcknowledgeUpdatedRemoteDescriptionRequest {
|
||||
ip_webrtc_server: num(params, "serverIp"),
|
||||
port_webrtc_server: num(params, "serverPort"),
|
||||
ip_webrtc_session_client: num(params, "clientIp"),
|
||||
port_webrtc_session_client: num(params, "clientPort"),
|
||||
remote_description_version: id_of(params, "version"),
|
||||
};
|
||||
let _: AcknowledgeUpdatedRemoteDescriptionResponse = ask(&link, "WebRTCClient.AcknowledgeUpdatedRemoteDescription#1", &request).await?;
|
||||
Ok(json!({}))
|
||||
}
|
||||
"joinRoom" => {
|
||||
let (Some(group), Some(room)) = (id_of(params, "groupId"), id_of(params, "roomId")) else { return Err("joinRoom needs a group and a room".into()) };
|
||||
let answer: RoomJoinVoiceChatResponse = ask(&link, "ChatRoom.JoinVoiceChat#1", &RoomJoinVoiceChatRequest { chat_group_id: Some(group), chat_id: Some(room) }).await?;
|
||||
Ok(json!({ "voiceChatId": answer.voice_chatid.map(|v| v.to_string()) }))
|
||||
}
|
||||
"leaveRoom" => {
|
||||
let (Some(group), Some(room)) = (id_of(params, "groupId"), id_of(params, "roomId")) else { return Err("leaveRoom needs a group and a room".into()) };
|
||||
let _: RoomLeaveVoiceChatResponse = ask(&link, "ChatRoom.LeaveVoiceChat#1", &RoomLeaveVoiceChatRequest { chat_group_id: Some(group), chat_id: Some(room) }).await?;
|
||||
Ok(json!({}))
|
||||
}
|
||||
// Which stream is ours, once the session is connected: what makes the chat
|
||||
// count this connection as being in it.
|
||||
"data" => {
|
||||
let request = UpdateVoiceChatWebRTCDataRequest {
|
||||
voice_chatid: id_of(params, "voiceChatId"),
|
||||
ip_webrtc_server: num(params, "serverIp"),
|
||||
port_webrtc_server: num(params, "serverPort"),
|
||||
ip_webrtc_client: num(params, "clientIp"),
|
||||
port_webrtc_client: num(params, "clientPort"),
|
||||
ssrc_my_sending_stream: num(params, "ssrc"),
|
||||
user_agent: text(params, "userAgent"),
|
||||
has_audio_worklets_support: Some(params["worklets"].as_bool().unwrap_or(false)),
|
||||
has_webrtc_data_channel: Some(params["dataChannel"].as_bool().unwrap_or(false)),
|
||||
accepts_webrtc_video: Some(false),
|
||||
sends_webrtc_video: Some(false),
|
||||
};
|
||||
let answer: UpdateVoiceChatWebRTCDataResponse = ask(&link, "VoiceChat.UpdateVoiceChatWebRTCData#1", &request).await?;
|
||||
Ok(json!({ "sendLogs": answer.send_client_voice_logs.unwrap_or(false) }))
|
||||
}
|
||||
// How we are showing to the others in the call: muted, deafened, no microphone.
|
||||
"status" => {
|
||||
let own = state.accounts.get_steam(account_id).map(|c| c.steamid).ok_or("this Steam account is not set up")?;
|
||||
let request = UserVoiceStatusNotification {
|
||||
voice_chatid: id_of(params, "voiceChatId"),
|
||||
user_steamid: Some(own),
|
||||
user_muted_mic_locally: Some(params["micMuted"].as_bool().unwrap_or(false)),
|
||||
user_muted_output_locally: Some(params["outputMuted"].as_bool().unwrap_or(false)),
|
||||
user_has_no_mic_for_session: Some(params["noMic"].as_bool().unwrap_or(false)),
|
||||
user_webaudio_sample_rate: params["sampleRate"].as_i64().and_then(|n| i32::try_from(n).ok()),
|
||||
user_sends_video: Some(false),
|
||||
};
|
||||
say(&link, "VoiceChat.NotifyUserVoiceStatus#1", &request)?;
|
||||
Ok(json!({}))
|
||||
}
|
||||
"requestCall" => {
|
||||
let partner = id_of(params, "steamid").ok_or("requestCall needs a person")?;
|
||||
let answer: RequestOneOnOneChatResponse = ask(&link, "VoiceChat.RequestOneOnOneChat#1", &RequestOneOnOneChatRequest { steamid_partner: Some(partner) }).await?;
|
||||
Ok(json!({ "voiceChatId": answer.voice_chatid.map(|v| v.to_string()) }))
|
||||
}
|
||||
"answerCall" => {
|
||||
let partner = id_of(params, "steamid").ok_or("answerCall needs a person")?;
|
||||
let request = AnswerOneOnOneChatRequest { voice_chatid: id_of(params, "voiceChatId"), steamid_partner: Some(partner), accepted_request: Some(params["accept"].as_bool().unwrap_or(true)) };
|
||||
let _: AnswerOneOnOneChatResponse = ask(&link, "VoiceChat.AnswerOneOnOneChat#1", &request).await?;
|
||||
Ok(json!({}))
|
||||
}
|
||||
// Hanging up on them: it ends the call for both.
|
||||
"endCall" => {
|
||||
let partner = id_of(params, "steamid").ok_or("endCall needs a person")?;
|
||||
let _: EndOneOnOneChatResponse = ask(&link, "VoiceChat.EndOneOnOneChat#1", &EndOneOnOneChatRequest { steamid_partner: Some(partner) }).await?;
|
||||
Ok(json!({}))
|
||||
}
|
||||
// Leaving it running for them, where they can come back to it.
|
||||
"leaveCall" => {
|
||||
let partner = id_of(params, "steamid").ok_or("leaveCall needs a person")?;
|
||||
let request = LeaveOneOnOneChatRequest { steamid_partner: Some(partner), voice_chatid: id_of(params, "voiceChatId") };
|
||||
let _: LeaveOneOnOneChatResponse = ask(&link, "VoiceChat.LeaveOneOnOneChat#1", &request).await?;
|
||||
Ok(json!({}))
|
||||
}
|
||||
other => Err(format!("{other} is not something Steam voice can be asked to do")),
|
||||
}
|
||||
}
|
||||
|
||||
fn who(account: u64) -> String {
|
||||
steamid::text_of(steamid::account_of(account).unwrap_or(0))
|
||||
}
|
||||
|
||||
fn status_json(s: &UserVoiceStatusNotification) -> Value {
|
||||
json!({
|
||||
"voiceChatId": s.voice_chatid.map(|v| v.to_string()),
|
||||
"steamid": s.user_steamid.map(who),
|
||||
"micMuted": s.user_muted_mic_locally.unwrap_or(false),
|
||||
"outputMuted": s.user_muted_output_locally.unwrap_or(false),
|
||||
"noMic": s.user_has_no_mic_for_session.unwrap_or(false),
|
||||
})
|
||||
}
|
||||
|
||||
/// Tells the window what the server said about the call, if this is one of its notifications.
|
||||
/// Returns whether it was.
|
||||
pub fn heard(state: &AppState, account_id: &str, method: &str, body: &[u8]) -> bool {
|
||||
let tell = |kind: &str, data: Value| {
|
||||
state.events.emit("steamVoice", json!({ "accountId": account_id, "kind": kind, "data": data }));
|
||||
};
|
||||
match method {
|
||||
"WebRTCClientNotifications.NotifyWebRTCSessionConnected#1" => {
|
||||
let Ok(n) = WebRTCSessionConnectedNotification::decode(body) else { return true };
|
||||
tell("sessionConnected", json!({ "ssrc": n.ssrc, "clientIp": n.client_ip, "clientPort": n.client_port, "serverIp": n.server_ip, "serverPort": n.server_port }));
|
||||
}
|
||||
"WebRTCClientNotifications.NotifyWebRTCUpdateRemoteDescription#1" => {
|
||||
let Ok(n) = WebRTCUpdateRemoteDescriptionNotification::decode(body) else { return true };
|
||||
tell(
|
||||
"remoteDescription",
|
||||
json!({
|
||||
"sdp": n.remote_description,
|
||||
"version": n.remote_description_version.map(|v| v.to_string()),
|
||||
"ssrcs": n.ssrcs_to_accountids.iter().map(|m| json!({ "ssrc": m.ssrc, "steamid": m.accountid.map(steamid::text_of) })).collect::<Vec<_>>(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
"VoiceChatClient.NotifyUserJoinedVoiceChat#1" | "VoiceChatClient.NotifyUserLeftVoiceChat#1" => {
|
||||
let joined = method.contains("Joined");
|
||||
let Ok(n) = UserJoinedVoiceChatNotification::decode(body) else { return true };
|
||||
let (group, room) = (n.chat_group_id.filter(|g| *g != 0), n.chatid.filter(|c| *c != 0));
|
||||
if let (Some(group), Some(room), Some(user)) = (group, room, n.user_steamid.and_then(steamid::account_of)) {
|
||||
chatgroups::voice_changed(state, account_id, group, room, user, joined);
|
||||
}
|
||||
tell(if joined { "userJoined" } else { "userLeft" }, json!({ "voiceChatId": n.voice_chatid.map(|v| v.to_string()), "steamid": n.user_steamid.map(who), "groupId": group.map(|g| g.to_string()), "roomId": room.map(|r| r.to_string()) }));
|
||||
}
|
||||
"VoiceChatClient.NotifyVoiceChatEnded#1" => {
|
||||
let Ok(n) = VoiceChatEndedNotification::decode(body) else { return true };
|
||||
tell("ended", json!({ "voiceChatId": n.voice_chatid.map(|v| v.to_string()), "groupId": n.chat_group_id.map(|g| g.to_string()), "roomId": n.chatid.map(|r| r.to_string()) }));
|
||||
}
|
||||
"VoiceChatClient.NotifyUserVoiceStatus#1" => {
|
||||
let Ok(n) = UserVoiceStatusNotification::decode(body) else { return true };
|
||||
tell("status", status_json(&n));
|
||||
}
|
||||
"VoiceChatClient.NotifyAllUsersVoiceStatus#1" => {
|
||||
let Ok(n) = AllMembersStatusNotification::decode(body) else { return true };
|
||||
tell("allStatus", json!({ "voiceChatId": n.voice_chatid.map(|v| v.to_string()), "users": n.users.iter().map(status_json).collect::<Vec<_>>() }));
|
||||
}
|
||||
"VoiceChatClient.NotifyOneOnOneChatRequested#1" => {
|
||||
let Ok(n) = OneOnOneChatRequestedNotification::decode(body) else { return true };
|
||||
tell("callRequested", json!({ "voiceChatId": n.voice_chatid.map(|v| v.to_string()), "steamid": n.steamid_partner.map(who) }));
|
||||
}
|
||||
"VoiceChatClient.NotifyOneOnOneChatResponse#1" => {
|
||||
let Ok(n) = OneOnOneChatRequestResponseNotification::decode(body) else { return true };
|
||||
tell("callResponse", json!({ "voiceChatId": n.voicechat_id.map(|v| v.to_string()), "steamid": n.steamid_partner.map(who), "accepted": n.accepted_request.unwrap_or(false) }));
|
||||
}
|
||||
"ChatRoomClient.NotifyShouldRejoinChatRoomVoiceChat#1" => {
|
||||
let Ok(n) = RoomNotifyShouldRejoinChatRoomVoiceChatNotification::decode(body) else { return true };
|
||||
tell("shouldRejoin", json!({ "groupId": n.chat_group_id.map(|g| g.to_string()), "roomId": n.chat_id.map(|r| r.to_string()) }));
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::backend::kick::testkit::simulated_daemon;
|
||||
|
||||
/// What the window is told when the server says something, as `(kind, data)` of the next event.
|
||||
fn told(state: &AppState, method: &str, body: Vec<u8>) -> (bool, Option<Value>) {
|
||||
let mut rx = state.events.subscribe();
|
||||
let known = heard(state, "steam:voice-test", method, &body);
|
||||
let event = loop {
|
||||
match rx.try_recv() {
|
||||
Ok(e) if e.name == "steamVoice" => break Some(e.data),
|
||||
Ok(_) => continue,
|
||||
Err(_) => break None,
|
||||
}
|
||||
};
|
||||
(known, event)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn what_the_server_says_of_the_session_reaches_the_window_in_the_window_s_words() {
|
||||
let state = simulated_daemon("steam-voice-heard");
|
||||
let connected = WebRTCSessionConnectedNotification { ssrc: Some(5), client_ip: Some(1), client_port: Some(2), server_ip: Some(3), server_port: Some(4) };
|
||||
let (known, event) = told(&state, "WebRTCClientNotifications.NotifyWebRTCSessionConnected#1", connected.encode_to_vec());
|
||||
assert!(known);
|
||||
let event = event.unwrap();
|
||||
assert_eq!((event["kind"].as_str(), event["data"]["ssrc"].as_u64(), event["data"]["serverPort"].as_u64()), (Some("sessionConnected"), Some(5), Some(4)));
|
||||
|
||||
let offer = WebRTCUpdateRemoteDescriptionNotification {
|
||||
remote_description: Some("{}".into()),
|
||||
remote_description_version: Some(u64::MAX - 1),
|
||||
ssrcs_to_accountids: vec![WebRTCUpdateRemoteDescriptionNotificationSsrcAccount { ssrc: Some(9), accountid: Some(100) }],
|
||||
};
|
||||
let (_, event) = told(&state, "WebRTCClientNotifications.NotifyWebRTCUpdateRemoteDescription#1", offer.encode_to_vec());
|
||||
let data = event.unwrap()["data"].clone();
|
||||
assert_eq!(data["version"], (u64::MAX - 1).to_string(), "a 64-bit number crosses as text, since the window cannot hold it");
|
||||
assert_eq!((data["ssrcs"][0]["ssrc"].as_u64(), data["ssrcs"][0]["steamid"].as_str()), (Some(9), Some("76561197960265828")));
|
||||
|
||||
let call = OneOnOneChatRequestedNotification { voice_chatid: Some(u64::MAX), steamid_partner: Some(steamid::steamid_of(100)) };
|
||||
let (_, event) = told(&state, "VoiceChatClient.NotifyOneOnOneChatRequested#1", call.encode_to_vec());
|
||||
let data = event.unwrap()["data"].clone();
|
||||
assert_eq!((data["voiceChatId"].as_str(), data["steamid"].as_str()), (Some("18446744073709551615"), Some("76561197960265828")));
|
||||
|
||||
let (known, event) = told(&state, "VoiceChatClient.NotifyNothingInParticular#1", Vec::new());
|
||||
assert!(!known && event.is_none(), "a method this does not know is not told");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_passing_failure_is_marked_for_trying_again_and_being_refused_for_good_is_said() {
|
||||
use crate::backend::steam::link::CallError;
|
||||
assert!(voice_refusal(&CallError::Refused(2, None), "ChatRoom.JoinVoiceChat#1").starts_with(AGAIN));
|
||||
assert!(voice_refusal(&CallError::TimedOut, "WebRTCClient.InitiateWebRTCConnection#1").starts_with(AGAIN));
|
||||
assert_eq!(voice_refusal(&CallError::Refused(15, None), "ChatRoom.JoinVoiceChat#1"), "you may not join voice in this room");
|
||||
assert_eq!(voice_refusal(&CallError::Refused(15, None), "VoiceChat.RequestOneOnOneChat#1"), "this person is not taking calls from you");
|
||||
assert_eq!(voice_refusal(&CallError::Refused(40, None), "VoiceChat.RequestOneOnOneChat#1"), "this person cannot be called");
|
||||
assert_eq!(voice_refusal(&CallError::Refused(25, None), "ChatRoom.JoinVoiceChat#1"), "the voice chat is full");
|
||||
assert_eq!(voice_refusal(&CallError::Refused(42, None), "ChatRoom.JoinVoiceChat#1"), "this room has no voice");
|
||||
assert!(!voice_refusal(&CallError::Closed, "VoiceChat.EndOneOnOneChat#1").starts_with(AGAIN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_cross_as_text_and_a_number_too_big_for_its_place_is_not_one() {
|
||||
assert_eq!(id_of(&json!({ "a": "18446744073709551615" }), "a"), Some(u64::MAX));
|
||||
assert_eq!(id_of(&json!({ "a": 5 }), "a"), Some(5));
|
||||
assert_eq!(id_of(&json!({}), "a"), None);
|
||||
assert_eq!(num(&json!({ "n": 4294967296u64 }), "n"), None, "a number too big for 32 bits is not one");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
//! Steam voice: the messages of the VoiceChat and WebRTCClient services and the voice
|
||||
//! calls of ChatRoom, written out by `golden/make_voice.py` from the Steam client's own
|
||||
//! JavaScript. Do not edit by hand: change the generator and run it again.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use prost::Message;
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct RequestOneOnOneChatRequest {
|
||||
#[prost(fixed64, optional, tag = "1")]
|
||||
pub steamid_partner: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct RequestOneOnOneChatResponse {
|
||||
#[prost(fixed64, optional, tag = "1")]
|
||||
pub voice_chatid: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct OneOnOneChatRequestedNotification {
|
||||
#[prost(fixed64, optional, tag = "1")]
|
||||
pub voice_chatid: Option<u64>,
|
||||
#[prost(fixed64, optional, tag = "2")]
|
||||
pub steamid_partner: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct AnswerOneOnOneChatRequest {
|
||||
#[prost(fixed64, optional, tag = "1")]
|
||||
pub voice_chatid: Option<u64>,
|
||||
#[prost(fixed64, optional, tag = "2")]
|
||||
pub steamid_partner: Option<u64>,
|
||||
#[prost(bool, optional, tag = "3")]
|
||||
pub accepted_request: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct AnswerOneOnOneChatResponse {
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct OneOnOneChatRequestResponseNotification {
|
||||
#[prost(fixed64, optional, tag = "1")]
|
||||
pub voicechat_id: Option<u64>,
|
||||
#[prost(fixed64, optional, tag = "2")]
|
||||
pub steamid_partner: Option<u64>,
|
||||
#[prost(bool, optional, tag = "3")]
|
||||
pub accepted_request: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct EndOneOnOneChatRequest {
|
||||
#[prost(fixed64, optional, tag = "1")]
|
||||
pub steamid_partner: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct EndOneOnOneChatResponse {
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct LeaveOneOnOneChatRequest {
|
||||
#[prost(fixed64, optional, tag = "1")]
|
||||
pub steamid_partner: Option<u64>,
|
||||
#[prost(fixed64, optional, tag = "2")]
|
||||
pub voice_chatid: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct LeaveOneOnOneChatResponse {
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct UserJoinedVoiceChatNotification {
|
||||
#[prost(fixed64, optional, tag = "1")]
|
||||
pub voice_chatid: Option<u64>,
|
||||
#[prost(fixed64, optional, tag = "2")]
|
||||
pub user_steamid: Option<u64>,
|
||||
#[prost(uint64, optional, tag = "3")]
|
||||
pub chatid: Option<u64>,
|
||||
#[prost(fixed64, optional, tag = "4")]
|
||||
pub one_on_one_steamid_lower: Option<u64>,
|
||||
#[prost(fixed64, optional, tag = "5")]
|
||||
pub one_on_one_steamid_higher: Option<u64>,
|
||||
#[prost(uint64, optional, tag = "6")]
|
||||
pub chat_group_id: Option<u64>,
|
||||
#[prost(uint32, optional, tag = "7")]
|
||||
pub user_sessionid: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct UserVoiceStatusNotification {
|
||||
#[prost(fixed64, optional, tag = "1")]
|
||||
pub voice_chatid: Option<u64>,
|
||||
#[prost(fixed64, optional, tag = "2")]
|
||||
pub user_steamid: Option<u64>,
|
||||
#[prost(bool, optional, tag = "3")]
|
||||
pub user_muted_mic_locally: Option<bool>,
|
||||
#[prost(bool, optional, tag = "4")]
|
||||
pub user_muted_output_locally: Option<bool>,
|
||||
#[prost(bool, optional, tag = "5")]
|
||||
pub user_has_no_mic_for_session: Option<bool>,
|
||||
#[prost(int32, optional, tag = "6")]
|
||||
pub user_webaudio_sample_rate: Option<i32>,
|
||||
#[prost(bool, optional, tag = "7")]
|
||||
pub user_sends_video: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct AllMembersStatusNotification {
|
||||
#[prost(fixed64, optional, tag = "1")]
|
||||
pub voice_chatid: Option<u64>,
|
||||
#[prost(message, repeated, tag = "2")]
|
||||
pub users: Vec<UserVoiceStatusNotification>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct UpdateVoiceChatWebRTCDataRequest {
|
||||
#[prost(fixed64, optional, tag = "1")]
|
||||
pub voice_chatid: Option<u64>,
|
||||
#[prost(uint32, optional, tag = "2")]
|
||||
pub ip_webrtc_server: Option<u32>,
|
||||
#[prost(uint32, optional, tag = "3")]
|
||||
pub port_webrtc_server: Option<u32>,
|
||||
#[prost(uint32, optional, tag = "4")]
|
||||
pub ip_webrtc_client: Option<u32>,
|
||||
#[prost(uint32, optional, tag = "5")]
|
||||
pub port_webrtc_client: Option<u32>,
|
||||
#[prost(uint32, optional, tag = "6")]
|
||||
pub ssrc_my_sending_stream: Option<u32>,
|
||||
#[prost(string, optional, tag = "7")]
|
||||
pub user_agent: Option<String>,
|
||||
#[prost(bool, optional, tag = "8")]
|
||||
pub has_audio_worklets_support: Option<bool>,
|
||||
#[prost(bool, optional, tag = "9")]
|
||||
pub has_webrtc_data_channel: Option<bool>,
|
||||
#[prost(bool, optional, tag = "10")]
|
||||
pub accepts_webrtc_video: Option<bool>,
|
||||
#[prost(bool, optional, tag = "11")]
|
||||
pub sends_webrtc_video: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct UpdateVoiceChatWebRTCDataResponse {
|
||||
#[prost(bool, optional, tag = "1")]
|
||||
pub send_client_voice_logs: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct UpdateUserVideoStatusRequest {
|
||||
#[prost(fixed64, optional, tag = "1")]
|
||||
pub voice_chatid: Option<u64>,
|
||||
#[prost(fixed64, optional, tag = "2")]
|
||||
pub user_steamid: Option<u64>,
|
||||
#[prost(bool, optional, tag = "3")]
|
||||
pub accept_webrtc_video: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct UpdateUserVideoStatusResponse {
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct UploadClientVoiceChatLogsRequest {
|
||||
#[prost(fixed64, optional, tag = "1")]
|
||||
pub voice_chatid: Option<u64>,
|
||||
#[prost(string, optional, tag = "2")]
|
||||
pub client_voice_logs_new_lines: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct UploadClientVoiceChatLogsResponse {
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct LeaveVoiceChatRequest {
|
||||
#[prost(fixed64, optional, tag = "1")]
|
||||
pub voice_chatid: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct LeaveVoiceChatResponse {
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct UserLeftVoiceChatNotification {
|
||||
#[prost(fixed64, optional, tag = "1")]
|
||||
pub voice_chatid: Option<u64>,
|
||||
#[prost(fixed64, optional, tag = "2")]
|
||||
pub user_steamid: Option<u64>,
|
||||
#[prost(uint64, optional, tag = "3")]
|
||||
pub chatid: Option<u64>,
|
||||
#[prost(fixed64, optional, tag = "4")]
|
||||
pub one_on_one_steamid_lower: Option<u64>,
|
||||
#[prost(fixed64, optional, tag = "5")]
|
||||
pub one_on_one_steamid_higher: Option<u64>,
|
||||
#[prost(uint64, optional, tag = "6")]
|
||||
pub chat_group_id: Option<u64>,
|
||||
#[prost(uint32, optional, tag = "7")]
|
||||
pub user_sessionid: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct VoiceChatEndedNotification {
|
||||
#[prost(fixed64, optional, tag = "1")]
|
||||
pub voice_chatid: Option<u64>,
|
||||
#[prost(fixed64, optional, tag = "2")]
|
||||
pub one_on_one_steamid_lower: Option<u64>,
|
||||
#[prost(fixed64, optional, tag = "3")]
|
||||
pub one_on_one_steamid_higher: Option<u64>,
|
||||
#[prost(uint64, optional, tag = "4")]
|
||||
pub chatid: Option<u64>,
|
||||
#[prost(uint64, optional, tag = "5")]
|
||||
pub chat_group_id: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct InitiateWebRTCConnectionRequest {
|
||||
#[prost(string, optional, tag = "1")]
|
||||
pub sdp: Option<String>,
|
||||
#[prost(string, optional, tag = "2")]
|
||||
pub browser_name: Option<String>,
|
||||
#[prost(string, optional, tag = "3")]
|
||||
pub browser_version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct InitiateWebRTCConnectionResponse {
|
||||
#[prost(string, optional, tag = "1")]
|
||||
pub remote_description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct UpdateWebRTCConnectionRequest {
|
||||
#[prost(uint32, optional, tag = "1")]
|
||||
pub ip_webrtc_server: Option<u32>,
|
||||
#[prost(uint32, optional, tag = "2")]
|
||||
pub port_webrtc_server: Option<u32>,
|
||||
#[prost(uint32, optional, tag = "3")]
|
||||
pub ip_webrtc_session_client: Option<u32>,
|
||||
#[prost(uint32, optional, tag = "4")]
|
||||
pub port_webrtc_session_client: Option<u32>,
|
||||
#[prost(string, optional, tag = "5")]
|
||||
pub sdp: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct UpdateWebRTCConnectionResponse {
|
||||
#[prost(string, optional, tag = "1")]
|
||||
pub remote_description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct WebRTCSessionConnectedNotification {
|
||||
#[prost(uint32, optional, tag = "1")]
|
||||
pub ssrc: Option<u32>,
|
||||
#[prost(uint32, optional, tag = "2")]
|
||||
pub client_ip: Option<u32>,
|
||||
#[prost(uint32, optional, tag = "3")]
|
||||
pub client_port: Option<u32>,
|
||||
#[prost(uint32, optional, tag = "4")]
|
||||
pub server_ip: Option<u32>,
|
||||
#[prost(uint32, optional, tag = "5")]
|
||||
pub server_port: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct WebRTCUpdateRemoteDescriptionNotification {
|
||||
#[prost(string, optional, tag = "1")]
|
||||
pub remote_description: Option<String>,
|
||||
#[prost(uint64, optional, tag = "2")]
|
||||
pub remote_description_version: Option<u64>,
|
||||
#[prost(message, repeated, tag = "3")]
|
||||
pub ssrcs_to_accountids: Vec<WebRTCUpdateRemoteDescriptionNotificationSsrcAccount>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct WebRTCUpdateRemoteDescriptionNotificationSsrcAccount {
|
||||
#[prost(uint32, optional, tag = "1")]
|
||||
pub ssrc: Option<u32>,
|
||||
#[prost(uint32, optional, tag = "2")]
|
||||
pub accountid: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct AcknowledgeUpdatedRemoteDescriptionRequest {
|
||||
#[prost(uint32, optional, tag = "1")]
|
||||
pub ip_webrtc_server: Option<u32>,
|
||||
#[prost(uint32, optional, tag = "2")]
|
||||
pub port_webrtc_server: Option<u32>,
|
||||
#[prost(uint32, optional, tag = "3")]
|
||||
pub ip_webrtc_session_client: Option<u32>,
|
||||
#[prost(uint32, optional, tag = "4")]
|
||||
pub port_webrtc_session_client: Option<u32>,
|
||||
#[prost(uint64, optional, tag = "5")]
|
||||
pub remote_description_version: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct AcknowledgeUpdatedRemoteDescriptionResponse {
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct RoomJoinVoiceChatRequest {
|
||||
#[prost(uint64, optional, tag = "1")]
|
||||
pub chat_group_id: Option<u64>,
|
||||
#[prost(uint64, optional, tag = "2")]
|
||||
pub chat_id: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct RoomJoinVoiceChatResponse {
|
||||
#[prost(uint64, optional, tag = "1")]
|
||||
pub voice_chatid: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct RoomLeaveVoiceChatRequest {
|
||||
#[prost(uint64, optional, tag = "1")]
|
||||
pub chat_group_id: Option<u64>,
|
||||
#[prost(uint64, optional, tag = "2")]
|
||||
pub chat_id: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct RoomLeaveVoiceChatResponse {
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct RoomNotifyShouldRejoinChatRoomVoiceChatNotification {
|
||||
#[prost(uint64, optional, tag = "1")]
|
||||
pub chat_id: Option<u64>,
|
||||
#[prost(uint64, optional, tag = "2")]
|
||||
pub chat_group_id: Option<u64>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const GOLDEN: &str = include_str!("golden/voice_golden.txt");
|
||||
|
||||
fn unhex(s: &str) -> Vec<u8> {
|
||||
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
|
||||
}
|
||||
|
||||
fn round_trips<M: Message + Default>(name: &str, bytes: &[u8]) {
|
||||
let decoded = M::decode(bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(decoded.encode_to_vec(), bytes, "{name} does not encode to what the reference library wrote");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_voice_message_matches_the_reference_encoding() {
|
||||
let mut seen = 0;
|
||||
for line in GOLDEN.lines().filter(|l| !l.trim().is_empty()) {
|
||||
let (name, hex) = line.split_once(' ').expect("name and bytes");
|
||||
let bytes = unhex(hex);
|
||||
macro_rules! cases {
|
||||
($($t:ident),* $(,)?) => {
|
||||
match name {
|
||||
$( stringify!($t) => round_trips::<$t>(name, &bytes), )*
|
||||
other => panic!("voice_golden.txt has {other}, which voicewire.rs does not"),
|
||||
}
|
||||
};
|
||||
}
|
||||
cases!(RequestOneOnOneChatRequest, RequestOneOnOneChatResponse, OneOnOneChatRequestedNotification, AnswerOneOnOneChatRequest, AnswerOneOnOneChatResponse, OneOnOneChatRequestResponseNotification, EndOneOnOneChatRequest, EndOneOnOneChatResponse, LeaveOneOnOneChatRequest, LeaveOneOnOneChatResponse, UserJoinedVoiceChatNotification, UserVoiceStatusNotification, AllMembersStatusNotification, UpdateVoiceChatWebRTCDataRequest, UpdateVoiceChatWebRTCDataResponse, UpdateUserVideoStatusRequest, UpdateUserVideoStatusResponse, UploadClientVoiceChatLogsRequest, UploadClientVoiceChatLogsResponse, LeaveVoiceChatRequest, LeaveVoiceChatResponse, UserLeftVoiceChatNotification, VoiceChatEndedNotification, InitiateWebRTCConnectionRequest, InitiateWebRTCConnectionResponse, UpdateWebRTCConnectionRequest, UpdateWebRTCConnectionResponse, WebRTCSessionConnectedNotification, WebRTCUpdateRemoteDescriptionNotification, WebRTCUpdateRemoteDescriptionNotificationSsrcAccount, AcknowledgeUpdatedRemoteDescriptionRequest, AcknowledgeUpdatedRemoteDescriptionResponse, RoomJoinVoiceChatRequest, RoomJoinVoiceChatResponse, RoomLeaveVoiceChatRequest, RoomLeaveVoiceChatResponse, RoomNotifyShouldRejoinChatRoomVoiceChatNotification);
|
||||
seen += 1;
|
||||
}
|
||||
assert_eq!(seen, 37);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
//! Files in a conversation: pictures and short videos.
|
||||
//!
|
||||
//! Steam's chat takes a file through its website, not the connection that
|
||||
//! carries the messages. The official client does the same three steps, and so
|
||||
//! does this:
|
||||
//!
|
||||
//! 1. Tell `steamcommunity.com/chat/beginfileupload` what is coming - size,
|
||||
//! name, type, picture size. It answers with where to put it.
|
||||
//! 2. `PUT` the file there.
|
||||
//! 3. Tell `steamcommunity.com/chat/commitfileupload` it arrived, and to whom.
|
||||
//! This is what sends the message: Steam writes it into the conversation
|
||||
//! itself, and the other side - and our own other sessions, this one
|
||||
//! included - hear it the way they hear any message. The spoiler flag is a
|
||||
//! field here, so a picture is hidden by Steam and not by anything moho
|
||||
//! wraps around it.
|
||||
//!
|
||||
//! The website knows an account by cookies, which a refresh token makes: it
|
||||
//! can be exchanged, over the same connection, for a short-lived access token,
|
||||
//! and the site accepts that as the login cookie.
|
||||
//!
|
||||
//! The types and the size are what the official client checks before it will
|
||||
//! start, read out of the client: a file Steam will not take is refused here
|
||||
//! with the same words, rather than after the upload.
|
||||
|
||||
use super::link::Link;
|
||||
use super::proto::{GenerateAccessTokenRequest, GenerateAccessTokenResponse};
|
||||
use crate::upload::{Phase, Progress};
|
||||
use rand::Rng;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
const SITE: &str = "https://steamcommunity.com";
|
||||
|
||||
/// The most a file in chat may be, in bytes: the client's own 30 MB.
|
||||
pub const MAX_BYTES: u64 = 30 * 1024 * 1024;
|
||||
|
||||
/// What the client accepts: its extensions, and the type it tells the site.
|
||||
pub const TYPES: &[(&str, &str)] = &[
|
||||
("jpg", "image/jpeg"),
|
||||
("jpeg", "image/jpeg"),
|
||||
("png", "image/png"),
|
||||
("gif", "image/gif"),
|
||||
("webp", "image/webp"),
|
||||
("avif", "image/avif"),
|
||||
("mp4", "video/mp4"),
|
||||
("webm", "video/webm"),
|
||||
("mpg", "video/mpeg"),
|
||||
("mpeg", "video/mpeg"),
|
||||
("ogv", "video/ogg"),
|
||||
];
|
||||
|
||||
/// The file's type if Steam takes it, going by its extension as the client does.
|
||||
pub fn type_of(file_name: &str) -> Option<&'static str> {
|
||||
let extension = file_name.rsplit_once('.')?.1.to_ascii_lowercase();
|
||||
TYPES.iter().find(|(e, _)| *e == extension).map(|(_, t)| *t)
|
||||
}
|
||||
|
||||
/// Why this file will not go, or nothing if it will.
|
||||
pub fn refusal(file_name: &str, size: u64) -> Option<String> {
|
||||
if size == 0 {
|
||||
return Some(format!("{file_name} is empty"));
|
||||
}
|
||||
if type_of(file_name).is_none() {
|
||||
let extension = file_name.rsplit_once('.').map(|(_, e)| e).unwrap_or(file_name);
|
||||
let known = TYPES.iter().map(|(e, _)| *e).collect::<Vec<_>>().join(", ");
|
||||
return Some(format!("Steam does not take .{extension} files in chat - it takes {known}"));
|
||||
}
|
||||
if size > MAX_BYTES {
|
||||
return Some(format!("{file_name} is over the {} MB Steam allows in chat", MAX_BYTES / 1024 / 1024));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The picture's size in pixels, or nothing for a file that is not one the
|
||||
/// reader knows (a video) - which Steam is told as zero, as the client does.
|
||||
fn dimensions(bytes: &[u8]) -> (usize, usize) {
|
||||
imagesize::blob_size(bytes).map(|s| (s.width, s.height)).unwrap_or((0, 0))
|
||||
}
|
||||
|
||||
/// What is shown of a failure: Steam's own words where it gave any.
|
||||
#[derive(Deserialize, Default)]
|
||||
struct Answer {
|
||||
success: Option<i64>,
|
||||
message: Option<String>,
|
||||
hmac: Option<String>,
|
||||
timestamp: Option<i64>,
|
||||
result: Option<serde_json::Value>,
|
||||
/// What an avatar upload is known by afterwards.
|
||||
sha: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Begun {
|
||||
ugcid: serde_json::Value,
|
||||
url_host: String,
|
||||
url_path: String,
|
||||
/// A number on the wire (`1`), where the client reads it as a truth value.
|
||||
#[serde(default, deserialize_with = "truthy")]
|
||||
use_https: bool,
|
||||
#[serde(default)]
|
||||
request_headers: Vec<Header>,
|
||||
}
|
||||
|
||||
fn truthy<'de, D: serde::Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
|
||||
Ok(match serde_json::Value::deserialize(d)? {
|
||||
serde_json::Value::Bool(b) => b,
|
||||
serde_json::Value::Number(n) => n.as_i64().unwrap_or(0) != 0,
|
||||
serde_json::Value::String(s) => s == "1" || s.eq_ignore_ascii_case("true"),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Header {
|
||||
name: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
/// The cookies the site reads a login from, for `steamid`.
|
||||
fn cookies(steamid: &str, access_token: &str, session_id: &str) -> String {
|
||||
format!("sessionid={session_id}; steamLoginSecure={steamid}%7C%7C{access_token}")
|
||||
}
|
||||
|
||||
/// A short-lived token for the website, made from the refresh token.
|
||||
pub async fn access_token(link: &Link, steamid: u64, refresh_token: &str) -> Result<String, String> {
|
||||
let request = GenerateAccessTokenRequest { refresh_token: Some(refresh_token.to_string()), steamid: Some(steamid), renewal_type: Some(0) };
|
||||
let answer: GenerateAccessTokenResponse = link
|
||||
.call("Authentication.GenerateAccessTokenForApp#1", &request, true)
|
||||
.await
|
||||
.map_err(|e| format!("Steam would not give this account a web session: {e}"))?;
|
||||
answer.access_token.filter(|t| !t.is_empty()).ok_or_else(|| "Steam gave no web session for this account".to_string())
|
||||
}
|
||||
|
||||
/// The website as this account: a client, the cookie it signs in with, and the
|
||||
/// session id the forms repeat.
|
||||
pub(super) async fn login(link: &Link, key: &str, steamid: u64, refresh_token: &str) -> Result<(reqwest::Client, String, String), String> {
|
||||
let router = crate::net::route::router();
|
||||
let token = access_token(link, steamid, refresh_token).await?;
|
||||
let session_id = {
|
||||
let mut rng = rand::thread_rng();
|
||||
(0..24).map(|_| format!("{:x}", rng.gen_range(0..16))).collect::<String>()
|
||||
};
|
||||
let cookie = cookies(&steamid.to_string(), &token, &session_id);
|
||||
let http = router.client_if("steam", router.routed(key), |b| {
|
||||
b.timeout(std::time::Duration::from_secs(600)).user_agent(concat!("moho/", env!("CARGO_PKG_VERSION")))
|
||||
});
|
||||
Ok((http, cookie, session_id))
|
||||
}
|
||||
|
||||
/// Asks Steam whether this account may upload a picture, without sending
|
||||
/// anything: the first step is a question, and nothing exists until the file is
|
||||
/// put where the answer says. For the live check.
|
||||
#[cfg(test)]
|
||||
pub async fn may_upload(link: &Link, key: &str, steamid: u64, refresh_token: &str) -> Result<String, String> {
|
||||
let (http, cookie, session_id) = login(link, key, steamid, refresh_token).await?;
|
||||
let form = reqwest::multipart::Form::new()
|
||||
.text("sessionid", session_id)
|
||||
.text("l", "english")
|
||||
.text("file_size", "1000")
|
||||
.text("file_name", format!("{}_probe_check.png", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or_default()))
|
||||
.text("file_sha", (0..40).map(|_| format!("{:x}", rand::thread_rng().gen_range(0..16))).collect::<String>())
|
||||
.text("file_image_width", "10")
|
||||
.text("file_image_height", "10")
|
||||
.text("file_type", "image/png");
|
||||
let answer = site(&http, &cookie, &format!("{SITE}/chat/beginfileupload/?l=english"), form).await?;
|
||||
let shape = answer.result.as_ref().map(|r| match r {
|
||||
serde_json::Value::Object(o) => o.iter().map(|(k, v)| format!("{k}:{}", match v { serde_json::Value::String(_) => "string", serde_json::Value::Number(_) => "number", serde_json::Value::Bool(_) => "bool", serde_json::Value::Array(_) => "array", serde_json::Value::Object(_) => "object", serde_json::Value::Null => "null" })).collect::<Vec<_>>().join(", "),
|
||||
_ => "not an object".to_string(),
|
||||
});
|
||||
Ok(format!("answer keys: {shape:?}; top-level hmac {} timestamp {}", answer.hmac.is_some(), answer.timestamp.is_some()))
|
||||
}
|
||||
|
||||
/// Sends one file to a friend, hidden behind a spoiler if asked. Returns
|
||||
/// where Steam keeps it.
|
||||
///
|
||||
/// `key` is the account's id, which decides whether the traffic is routed.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
/// Who a file is for: a friend, or a room of a chat group. What the commit names is all that differs.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum FileTarget<'a> {
|
||||
Friend(&'a str),
|
||||
Room { group: u64, room: u64 },
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn send_file(
|
||||
link: &Link,
|
||||
key: &str,
|
||||
steamid: u64,
|
||||
refresh_token: &str,
|
||||
target: FileTarget<'_>,
|
||||
path: &str,
|
||||
spoiler: bool,
|
||||
progress: Option<&Progress>,
|
||||
) -> Result<String, String> {
|
||||
let file_name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("file").to_string();
|
||||
let size = tokio::fs::metadata(path).await.map_err(|e| format!("reading {file_name}: {e}"))?.len();
|
||||
if let Some(why) = refusal(&file_name, size) {
|
||||
return Err(why);
|
||||
}
|
||||
let mime = type_of(&file_name).unwrap_or("application/octet-stream");
|
||||
if let Some(p) = progress {
|
||||
p.at(Phase::Preparing, size as usize, "steam");
|
||||
}
|
||||
let router = crate::net::route::router();
|
||||
if router.routed(key) {
|
||||
router.ready_for_general().await.map_err(|e| format!("reaching Steam by the route chosen: {e:#}"))?;
|
||||
}
|
||||
let bytes = tokio::fs::read(path).await.map_err(|e| format!("reading {file_name}: {e}"))?;
|
||||
let (width, height) = dimensions(&bytes);
|
||||
// Random, as the official client sends it. Not the file's own hash, for
|
||||
// all that the field is called one: Steam refuses a second upload that
|
||||
// names a hash it has already been given, so the same picture sent twice
|
||||
// would be turned away the second time.
|
||||
let sha = (0..40).map(|_| format!("{:x}", rand::thread_rng().gen_range(0..16))).collect::<String>();
|
||||
let upload_name = format!("{}_{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or_default(), file_name);
|
||||
|
||||
let (http, cookie, session_id) = login(link, key, steamid, refresh_token).await?;
|
||||
let begin = reqwest::multipart::Form::new()
|
||||
.text("sessionid", session_id.clone())
|
||||
.text("l", "english")
|
||||
.text("file_size", size.to_string())
|
||||
.text("file_name", upload_name.clone())
|
||||
.text("file_sha", sha.clone())
|
||||
.text("file_image_width", width.to_string())
|
||||
.text("file_image_height", height.to_string())
|
||||
.text("file_type", mime.to_string());
|
||||
let answer = site(&http, &cookie, &format!("{SITE}/chat/beginfileupload/?l=english"), begin).await?;
|
||||
let (hmac, timestamp) = (answer.hmac.clone().unwrap_or_default(), answer.timestamp.unwrap_or_default());
|
||||
let begun: Begun = serde_json::from_value(answer.result.clone().unwrap_or_default()).ok()
|
||||
.filter(|b: &Begun| !b.url_host.is_empty())
|
||||
.ok_or_else(|| "Steam would not take the file just now - it did not say where to put it".to_string())?;
|
||||
|
||||
if let Some(p) = progress {
|
||||
p.at(Phase::Sending, bytes.len(), "steam");
|
||||
}
|
||||
let url = format!("{}://{}{}", if begun.use_https { "https" } else { "http" }, begun.url_host, begun.url_path);
|
||||
let mut put = http.put(&url).body(bytes);
|
||||
for h in begun.request_headers.iter().filter(|h| !h.name.eq_ignore_ascii_case("content-length") && !h.name.eq_ignore_ascii_case("host")) {
|
||||
put = put.header(h.name.as_str(), h.value.as_str());
|
||||
}
|
||||
let stored = put.send().await.map_err(|e| format!("sending {file_name} to Steam: {e}"))?;
|
||||
if !stored.status().is_success() {
|
||||
return Err(format!("Steam's file server refused {file_name} ({})", stored.status()));
|
||||
}
|
||||
|
||||
if let Some(p) = progress {
|
||||
p.at(Phase::Waiting, size as usize, "steam");
|
||||
}
|
||||
let ugcid = match &begun.ugcid {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
let mut commit = reqwest::multipart::Form::new()
|
||||
.text("sessionid", session_id)
|
||||
.text("l", "english")
|
||||
.text("file_name", upload_name)
|
||||
.text("file_sha", sha)
|
||||
.text("success", "1")
|
||||
.text("ugcid", ugcid)
|
||||
.text("file_type", mime.to_string())
|
||||
.text("file_image_width", width.to_string())
|
||||
.text("file_image_height", height.to_string())
|
||||
.text("timestamp", timestamp.to_string())
|
||||
.text("hmac", hmac)
|
||||
.text("spoiler", if spoiler { "1" } else { "0" });
|
||||
// A friend is named by their id, a room by its group and its own number: as the client's own
|
||||
// two kinds of conversation name theirs.
|
||||
commit = match target {
|
||||
FileTarget::Friend(friend) => commit.text("friend_steamid", friend.to_string()),
|
||||
FileTarget::Room { group, room } => commit.text("chat_group_id", group.to_string()).text("chat_id", room.to_string()),
|
||||
};
|
||||
let done = site(&http, &cookie, &format!("{SITE}/chat/commitfileupload/"), commit).await?;
|
||||
let result = done.result.unwrap_or_default();
|
||||
// Valve's own nesting: the call succeeded, and inside it there is a second
|
||||
// verdict on whether the file did.
|
||||
if result.get("success").and_then(|v| v.as_i64()).is_some_and(|v| v != 1) {
|
||||
return Err(done.message.unwrap_or_else(|| "Steam did not send the file".to_string()));
|
||||
}
|
||||
Ok(result.pointer("/details/url").and_then(|v| v.as_str()).unwrap_or_default().to_string())
|
||||
}
|
||||
|
||||
/// What a group's picture may be, as the client's own picker says.
|
||||
pub const AVATAR_TYPES: &[&str] = &["jpg", "jpeg", "png", "gif"];
|
||||
|
||||
/// Sends a picture for a chat group to the site, which keeps it and answers with what it is
|
||||
/// known by; the group is then told to use that (`SetChatRoomGroupAvatar`).
|
||||
pub async fn upload_avatar(link: &Link, key: &str, steamid: u64, refresh_token: &str, path: &str) -> Result<Vec<u8>, String> {
|
||||
let file_name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("avatar").to_string();
|
||||
let extension = file_name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default();
|
||||
let mime = match extension.as_str() {
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"png" => "image/png",
|
||||
"gif" => "image/gif",
|
||||
_ => return Err(format!("a group's picture is one of {}", AVATAR_TYPES.join(", "))),
|
||||
};
|
||||
let bytes = tokio::fs::read(path).await.map_err(|e| format!("reading {file_name}: {e}"))?;
|
||||
if bytes.is_empty() {
|
||||
return Err(format!("{file_name} is empty"));
|
||||
}
|
||||
let (http, cookie, session_id) = login(link, key, steamid, refresh_token).await?;
|
||||
let part = reqwest::multipart::Part::bytes(bytes).file_name(file_name).mime_str(mime).map_err(|e| e.to_string())?;
|
||||
let form = reqwest::multipart::Form::new().text("sessionid", session_id).part("avatar", part);
|
||||
let answer = site_coded(&http, &cookie, &format!("{SITE}/chat/avatarfileupload/"), form).await.map_err(|(code, said)| avatar_refusal(code, said))?;
|
||||
let sha = answer.sha.filter(|s| s.len() == 40).ok_or("Steam took the picture and did not say what it is called")?;
|
||||
(0..sha.len()).step_by(2).map(|i| u8::from_str_radix(&sha[i..i + 2], 16).map_err(|_| "Steam named the picture in a way that is not understood".to_string())).collect()
|
||||
}
|
||||
|
||||
/// An answer's text. The site compresses what it likes whatever it is asked,
|
||||
/// and a refusal's reason is in the body, so it is unpacked here.
|
||||
pub(super) fn plain(raw: &[u8]) -> String {
|
||||
use std::io::Read;
|
||||
if raw.starts_with(&[0x1f, 0x8b]) {
|
||||
let mut out = String::new();
|
||||
if flate2::read::GzDecoder::new(raw).read_to_string(&mut out).is_ok() {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(raw).into_owned()
|
||||
}
|
||||
|
||||
/// The names somebody has had, newest first, each with when Steam says they
|
||||
/// took it - in Steam's own words for the moment, which are not a date a
|
||||
/// program can rely on ("12 Oct, 2025 @ 6:03pm") and are shown as given.
|
||||
pub async fn aliases(link: &Link, key: &str, steamid: u64, refresh_token: &str, friend: u64) -> Result<Vec<(String, String)>, String> {
|
||||
let (http, cookie, _) = login(link, key, steamid, refresh_token).await?;
|
||||
let response = http
|
||||
.get(format!("{SITE}/profiles/{friend}/ajaxaliases?l=english"))
|
||||
.header("Cookie", cookie)
|
||||
.header("Referer", format!("{SITE}/profiles/{friend}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("could not reach Steam's website: {e}"))?;
|
||||
let status = response.status();
|
||||
let raw = response.bytes().await.map_err(|e| e.to_string())?;
|
||||
if !status.is_success() {
|
||||
return Err(explain(status, &serde_json::from_str(&plain(&raw)).unwrap_or_default()));
|
||||
}
|
||||
Ok(read_aliases(&plain(&raw)))
|
||||
}
|
||||
|
||||
/// Reads the answer: a list of `{newname, timechanged}`.
|
||||
pub fn read_aliases(text: &str) -> Vec<(String, String)> {
|
||||
let Ok(Value::Array(list)) = serde_json::from_str::<Value>(text) else { return Vec::new() };
|
||||
list.iter()
|
||||
.filter_map(|entry| {
|
||||
let name = entry["newname"].as_str()?.trim();
|
||||
let when = entry["timechanged"].as_str().unwrap_or_default().trim();
|
||||
(!name.is_empty()).then(|| (name.chars().take(64).collect(), when.chars().take(40).collect()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One call to the site, read as the client reads it: the body is JSON even
|
||||
/// when the status is an error, and its `message` is what to show.
|
||||
async fn site(http: &reqwest::Client, cookie: &str, url: &str, form: reqwest::multipart::Form) -> Result<Answer, String> {
|
||||
site_coded(http, cookie, url, form).await.map_err(|(_, why)| why)
|
||||
}
|
||||
|
||||
/// The same, with the result code the site gave, for a caller that has words of its own for some.
|
||||
async fn site_coded(http: &reqwest::Client, cookie: &str, url: &str, form: reqwest::multipart::Form) -> Result<Answer, (Option<i64>, String)> {
|
||||
let response = http
|
||||
.post(url)
|
||||
.header("Cookie", cookie)
|
||||
.header("Referer", format!("{SITE}/chat/"))
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| (None, format!("could not reach Steam's website: {e}")))?;
|
||||
let status = response.status();
|
||||
let raw = response.bytes().await.unwrap_or_default();
|
||||
let text = plain(&raw);
|
||||
let answer: Answer = serde_json::from_str(&text).unwrap_or_default();
|
||||
// Never the body: it carries the signed address the file is sent to.
|
||||
tracing::debug!("steam web: {} -> {status}, success {:?}", url.split('?').next().unwrap_or(url), answer.success);
|
||||
if !status.is_success() || answer.success.is_some_and(|s| s != 1) {
|
||||
let why = answer.message.clone().filter(|m| !m.is_empty()).unwrap_or_else(|| match (status.as_u16(), answer.success) {
|
||||
// The site answers in Steam's own result codes, which say why far better than the status.
|
||||
(_, Some(code)) if code > 1 => format!("Steam refused it: {}", super::eresult::describe(code as i32)),
|
||||
(401 | 403, _) => "Steam's website did not accept this sign-in".to_string(),
|
||||
_ => format!("Steam's website refused it ({status})"),
|
||||
});
|
||||
return Err((answer.success, why));
|
||||
}
|
||||
Ok(answer)
|
||||
}
|
||||
|
||||
/// Why the site refused something, from its status and what it said: its own result code in the
|
||||
/// words of the general table when it gave one, and otherwise what the status means.
|
||||
pub(super) fn explain(status: reqwest::StatusCode, answer: &serde_json::Value) -> String {
|
||||
if let Some(message) = answer["message"].as_str().filter(|m| !m.is_empty()) {
|
||||
return message.to_string();
|
||||
}
|
||||
match (status.as_u16(), answer["success"].as_i64()) {
|
||||
(_, Some(code)) if code > 1 => format!("Steam refused it: {}", super::eresult::describe(code as i32)),
|
||||
(401 | 403, _) => "Steam's website did not accept this sign-in; try again in a moment".to_string(),
|
||||
(429, _) => "Steam's website is limiting how often this can be done; wait a while".to_string(),
|
||||
(500..=599, _) => "Steam's website is having trouble just now; try again in a moment".to_string(),
|
||||
_ => format!("Steam's website refused it ({status})"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Why the site refused a group's picture, in the words the client has for each: not signed in, a
|
||||
/// limited account, not allowed, failed to upload, too large, not a usable picture, a server error.
|
||||
fn avatar_refusal(code: Option<i64>, said: String) -> String {
|
||||
match code {
|
||||
Some(21) => "Steam's website says this account is not signed in; try again in a moment".to_string(),
|
||||
Some(112) => "a limited account cannot change a group's picture; Steam asks for a purchase first".to_string(),
|
||||
Some(15) => "you may not change this group's picture".to_string(),
|
||||
Some(2) => "Steam could not take the picture; try again".to_string(),
|
||||
Some(25) => "that picture is too large for Steam".to_string(),
|
||||
Some(8) => "Steam cannot use that as a picture".to_string(),
|
||||
Some(37) => "Steam had a problem on its side taking the picture; try again in a moment".to_string(),
|
||||
_ => said,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn what_the_site_refuses_is_said_by_its_own_code_and_then_by_its_status() {
|
||||
let said = |status: u16, body: serde_json::Value| explain(reqwest::StatusCode::from_u16(status).unwrap(), &body);
|
||||
assert_eq!(said(400, serde_json::json!({ "message": "No." })), "No.");
|
||||
assert!(said(401, serde_json::json!({ "success": 112 })).contains("limited account"));
|
||||
assert!(said(401, serde_json::json!({})).contains("sign-in"));
|
||||
assert!(said(429, serde_json::json!({})).contains("limiting"));
|
||||
assert!(said(503, serde_json::json!({})).contains("trouble"));
|
||||
assert!(said(418, serde_json::json!({})).contains("418"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_picture_is_told_in_the_words_the_client_has_for_each_reason() {
|
||||
assert!(avatar_refusal(Some(112), String::new()).contains("limited account"));
|
||||
assert!(avatar_refusal(Some(25), String::new()).contains("too large"));
|
||||
assert!(avatar_refusal(Some(8), String::new()).contains("cannot use"));
|
||||
assert!(avatar_refusal(Some(15), String::new()).contains("may not"));
|
||||
assert_eq!(avatar_refusal(Some(99), "Steam said this".into()), "Steam said this", "what Steam says of the rest is kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_types_are_the_ones_the_client_takes() {
|
||||
for name in ["a.jpg", "a.JPEG", "a.png", "a.gif", "a.webm", "a.mpg", "a.mp4", "a.mpeg", "a.ogv", "a.webp", "a.avif"] {
|
||||
assert!(type_of(name).is_some(), "{name}");
|
||||
}
|
||||
assert_eq!(type_of("clip.MP4"), Some("video/mp4"));
|
||||
assert_eq!(type_of("photo.jpg"), Some("image/jpeg"));
|
||||
assert_eq!(type_of("paper.pdf"), None);
|
||||
assert_eq!(type_of("noextension"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_steam_will_not_take_is_refused_in_its_own_terms() {
|
||||
assert!(refusal("a.png", 100).is_none());
|
||||
assert!(refusal("a.png", MAX_BYTES).is_none());
|
||||
assert!(refusal("a.png", MAX_BYTES + 1).unwrap().contains("30 MB"));
|
||||
assert!(refusal("a.pdf", 10).unwrap().contains(".pdf"));
|
||||
assert!(refusal("a.png", 0).unwrap().contains("empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steams_answer_to_the_first_step_is_read_as_it_is_sent() {
|
||||
// As Steam sends it: the id a string, and "use https" a number.
|
||||
let said = serde_json::json!({
|
||||
"storage_system": 1, "ugcid": "13438947538884666122", "timestamp": 1791849742,
|
||||
"url_host": "files.example.test", "url_path": "/a/b", "use_https": 1,
|
||||
"request_headers": [{ "name": "x-goog-meta", "value": "v" }], "success": 1
|
||||
});
|
||||
let begun: Begun = serde_json::from_value(said).expect("what Steam sends");
|
||||
assert!(begun.use_https);
|
||||
assert_eq!((begun.url_host.as_str(), begun.url_path.as_str(), begun.request_headers.len()), ("files.example.test", "/a/b", 1));
|
||||
let no: Result<Begun, _> = serde_json::from_value(serde_json::json!({ "url_host": null, "url_path": null, "ugcid": null, "request_headers": [], "success": 10 }));
|
||||
assert!(no.is_err(), "a refusal has no place to put anything");
|
||||
let plain: Begun = serde_json::from_value(serde_json::json!({ "ugcid": 5, "url_host": "h", "url_path": "/p", "use_https": false })).unwrap();
|
||||
assert!(!plain.use_https);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_history_is_read_as_names_and_the_moments_they_were_taken() {
|
||||
let said = r#"[{"newname":"Gabe","timechanged":"12 Oct, 2025 @ 6:03pm"},{"newname":" ","timechanged":"x"},{"newname":"Old Name","timechanged":"3 Mar, 2019 @ 1:00am"},{"newname":"No Time"}]"#;
|
||||
assert_eq!(
|
||||
read_aliases(said),
|
||||
vec![("Gabe".to_string(), "12 Oct, 2025 @ 6:03pm".to_string()), ("Old Name".to_string(), "3 Mar, 2019 @ 1:00am".to_string()), ("No Time".to_string(), String::new())]
|
||||
);
|
||||
assert!(read_aliases("not json").is_empty());
|
||||
assert!(read_aliases("{}").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_login_is_two_cookies() {
|
||||
assert_eq!(cookies("7656", "tok", "abc"), "sessionid=abc; steamLoginSecure=7656%7C%7Ctok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_has_a_size_and_anything_else_has_none() {
|
||||
let png = image::RgbaImage::new(3, 2);
|
||||
let mut bytes = Vec::new();
|
||||
png.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap();
|
||||
assert_eq!(dimensions(&bytes), (3, 2));
|
||||
assert_eq!(dimensions(b"not a picture"), (0, 0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
//! How a packet is laid out on Steam's WebSocket, and nothing about what is in
|
||||
//! one.
|
||||
//!
|
||||
//! One WebSocket binary message is one packet:
|
||||
//!
|
||||
//! ```text
|
||||
//! u32 LE message type, with the top bit set when the body is protobuf
|
||||
//! u32 LE length of the header
|
||||
//! header a `Header` protobuf (jobs, the account, the session, a result)
|
||||
//! body the message's own protobuf
|
||||
//! ```
|
||||
//!
|
||||
//! A handful of older messages use a fixed binary header instead of a
|
||||
//! protobuf one; they are read so that one arriving does not end the
|
||||
//! connection, but nothing here sends one.
|
||||
//!
|
||||
//! One kind of packet holds others: a `Multi`, whose body is a run of
|
||||
//! `u32 LE length, packet`, gzip-compressed when it says how long it is once
|
||||
//! unzipped. A server batches what it has to say after a sign-in this way, so
|
||||
//! the whole friends list and every name in it commonly arrive inside one.
|
||||
|
||||
use super::proto::{Header, Multi};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use prost::Message;
|
||||
use std::io::Read;
|
||||
|
||||
/// Set on the message type of a packet whose body (and header) is protobuf.
|
||||
pub const PROTO_MASK: u32 = 0x8000_0000;
|
||||
|
||||
/// No job: what a packet that neither starts nor answers a request carries.
|
||||
pub const NO_JOB: u64 = u64::MAX;
|
||||
|
||||
/// The message types this client sends or looks for. Numbers from the
|
||||
/// official client's own table (`enums_clientserver.proto`).
|
||||
pub mod emsg {
|
||||
pub const MULTI: u32 = 1;
|
||||
#[cfg(test)]
|
||||
pub const SERVICE_METHOD_RESPONSE: u32 = 147;
|
||||
/// What the server sends the client unprompted: a notification such as
|
||||
/// `FriendMessagesClient.IncomingMessage#1`. 152 is the same shape and is
|
||||
/// accepted wherever this is, since both are seen.
|
||||
pub const SERVICE_METHOD: u32 = 146;
|
||||
pub const SERVICE_METHOD_CALL_FROM_CLIENT: u32 = 151;
|
||||
pub const SERVICE_METHOD_SEND_TO_CLIENT: u32 = 152;
|
||||
pub const CLIENT_HEART_BEAT: u32 = 703;
|
||||
pub const CLIENT_LOG_OFF: u32 = 706;
|
||||
pub const CLIENT_REMOVE_FRIEND: u32 = 714;
|
||||
pub const CLIENT_CHANGE_STATUS: u32 = 716;
|
||||
pub const CLIENT_LOG_ON_RESPONSE: u32 = 751;
|
||||
pub const CLIENT_LOGGED_OFF: u32 = 757;
|
||||
pub const CLIENT_PERSONA_STATE: u32 = 766;
|
||||
pub const CLIENT_FRIENDS_LIST: u32 = 767;
|
||||
pub const CLIENT_ACCOUNT_INFO: u32 = 768;
|
||||
pub const CLIENT_FRIEND_PROFILE_INFO: u32 = 5535;
|
||||
pub const CLIENT_FS_GET_FRIENDS_STEAM_LEVELS: u32 = 7528;
|
||||
pub const CLIENT_GET_EMOTICON_LIST: u32 = 9330;
|
||||
pub const CLIENT_EMOTICON_LIST: u32 = 9331;
|
||||
pub const CLIENT_ADD_FRIEND: u32 = 791;
|
||||
/// Only the tests build one: the connection matches it to its question by job id, not by kind.
|
||||
#[cfg(test)]
|
||||
pub const CLIENT_ADD_FRIEND_RESPONSE: u32 = 792;
|
||||
pub const CLIENT_REQUEST_FRIEND_DATA: u32 = 815;
|
||||
pub const CLIENT_LOGON: u32 = 5514;
|
||||
pub const CLIENT_FRIENDS_GROUPS_LIST: u32 = 5553;
|
||||
pub const CLIENT_CREATE_FRIENDS_GROUP: u32 = 5560;
|
||||
pub const CLIENT_DELETE_FRIENDS_GROUP: u32 = 5562;
|
||||
pub const CLIENT_MANAGE_FRIENDS_GROUP: u32 = 5564;
|
||||
pub const CLIENT_ADD_FRIEND_TO_GROUP: u32 = 5566;
|
||||
pub const CLIENT_REMOVE_FRIEND_FROM_GROUP: u32 = 5568;
|
||||
pub const SERVICE_METHOD_CALL_FROM_CLIENT_NON_AUTHED: u32 = 9804;
|
||||
pub const CLIENT_HELLO: u32 = 9805;
|
||||
}
|
||||
|
||||
/// The most a compressed batch may grow to. A real one is a few hundred
|
||||
/// kilobytes; this is a stop against a body that claims to be small and is not.
|
||||
const MAX_UNZIPPED: usize = 64 * 1024 * 1024;
|
||||
|
||||
/// One packet, with its header read and its body left as bytes.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Packet {
|
||||
/// The message type, without the protobuf bit.
|
||||
pub emsg: u32,
|
||||
/// Whether the body is protobuf; false for the old fixed-header packets.
|
||||
pub protobuf: bool,
|
||||
pub header: Header,
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Packet {
|
||||
pub fn new(emsg: u32, header: Header, body: Vec<u8>) -> Self {
|
||||
Self { emsg, protobuf: true, header, body }
|
||||
}
|
||||
|
||||
/// The request this answers, or `NO_JOB`.
|
||||
pub fn job_target(&self) -> u64 {
|
||||
self.header.jobid_target.unwrap_or(NO_JOB)
|
||||
}
|
||||
|
||||
/// How it went. Absent reads as the schema's default, a failure: success
|
||||
/// is always written down.
|
||||
pub fn eresult(&self) -> i32 {
|
||||
self.header.eresult.unwrap_or(super::eresult::FAIL)
|
||||
}
|
||||
|
||||
/// The bytes to put on the wire.
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
debug_assert!(self.protobuf, "only protobuf packets are written");
|
||||
let header = self.header.encode_to_vec();
|
||||
let mut out = Vec::with_capacity(8 + header.len() + self.body.len());
|
||||
out.extend_from_slice(&(self.emsg | PROTO_MASK).to_le_bytes());
|
||||
out.extend_from_slice(&(header.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&header);
|
||||
out.extend_from_slice(&self.body);
|
||||
out
|
||||
}
|
||||
|
||||
/// Reads one packet from a WebSocket message.
|
||||
pub fn decode(bytes: &[u8]) -> Result<Packet> {
|
||||
let kind = u32_at(bytes, 0).context("a packet shorter than its message type")?;
|
||||
let protobuf = kind & PROTO_MASK != 0;
|
||||
let emsg = kind & !PROTO_MASK;
|
||||
if protobuf {
|
||||
let header_len = u32_at(bytes, 4).context("a packet with no header length")? as usize;
|
||||
let end = 8usize.checked_add(header_len).filter(|end| *end <= bytes.len()).ok_or_else(|| anyhow!("a header longer than its packet"))?;
|
||||
let header = Header::decode(&bytes[8..end]).context("reading a packet header")?;
|
||||
return Ok(Packet { emsg, protobuf, header, body: bytes[end..].to_vec() });
|
||||
}
|
||||
// The old layout: a one-byte header size, a two-byte version, the two
|
||||
// job ids, a canary, the account and the session - 32 bytes in all
|
||||
// after the type. It is read for the jobs and the session, because
|
||||
// that is what the rest of this needs from it.
|
||||
const LEGACY: usize = 4 + 32;
|
||||
if bytes.len() < LEGACY {
|
||||
bail!("an old-style packet of {} bytes", bytes.len());
|
||||
}
|
||||
let target = u64_at(bytes, 7).context("a legacy job id")?;
|
||||
let source = u64_at(bytes, 15).context("a legacy job id")?;
|
||||
let steamid = u64_at(bytes, 24).context("a legacy steam id")?;
|
||||
let session = i32::from_le_bytes(bytes[32..36].try_into().expect("four bytes"));
|
||||
let header = Header {
|
||||
steamid: Some(steamid),
|
||||
client_sessionid: Some(session),
|
||||
jobid_source: Some(source),
|
||||
jobid_target: Some(target),
|
||||
..Header::default()
|
||||
};
|
||||
Ok(Packet { emsg, protobuf, header, body: bytes[LEGACY..].to_vec() })
|
||||
}
|
||||
}
|
||||
|
||||
fn u32_at(bytes: &[u8], at: usize) -> Option<u32> {
|
||||
Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
fn u64_at(bytes: &[u8], at: usize) -> Option<u64> {
|
||||
Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
/// The packets inside a `Multi`.
|
||||
///
|
||||
/// Nested batches are unpacked too, in order, so a caller sees a flat run.
|
||||
pub fn unpack_multi(body: &[u8]) -> Result<Vec<Packet>> {
|
||||
let multi = Multi::decode(body).context("reading a batch of packets")?;
|
||||
let inner = multi.message_body.unwrap_or_default();
|
||||
let bytes = match multi.size_unzipped.filter(|n| *n > 0) {
|
||||
None => inner,
|
||||
Some(expected) => {
|
||||
let expected = expected as usize;
|
||||
if expected > MAX_UNZIPPED {
|
||||
bail!("a batch that claims to unzip to {expected} bytes");
|
||||
}
|
||||
let mut out = Vec::with_capacity(expected);
|
||||
// Reading one byte past the claim is how an overlong stream is
|
||||
// told from an exact one without trusting the claim.
|
||||
flate2::read::GzDecoder::new(inner.as_slice())
|
||||
.take(expected as u64 + 1)
|
||||
.read_to_end(&mut out)
|
||||
.context("unzipping a batch of packets")?;
|
||||
if out.len() != expected {
|
||||
bail!("a batch that said {expected} bytes and unzipped to {}", out.len());
|
||||
}
|
||||
out
|
||||
}
|
||||
};
|
||||
|
||||
let mut packets = Vec::new();
|
||||
let mut at = 0;
|
||||
while at < bytes.len() {
|
||||
let len = u32_at(&bytes, at).context("a batch cut off in a length")? as usize;
|
||||
at += 4;
|
||||
let end = at.checked_add(len).filter(|end| *end <= bytes.len()).ok_or_else(|| anyhow!("a packet longer than its batch"))?;
|
||||
let packet = Packet::decode(&bytes[at..end])?;
|
||||
if packet.emsg == emsg::MULTI {
|
||||
packets.extend(unpack_multi(&packet.body)?);
|
||||
} else {
|
||||
packets.push(packet);
|
||||
}
|
||||
at = end;
|
||||
}
|
||||
Ok(packets)
|
||||
}
|
||||
|
||||
/// Packs packets into a gzip-compressed `Multi` body, as a server would. Only
|
||||
/// the test server and the tests have a use for it.
|
||||
#[cfg(test)]
|
||||
pub fn pack_multi(packets: &[Packet], compress: bool) -> Vec<u8> {
|
||||
use std::io::Write;
|
||||
let mut run = Vec::new();
|
||||
for p in packets {
|
||||
let encoded = p.encode();
|
||||
run.extend_from_slice(&(encoded.len() as u32).to_le_bytes());
|
||||
run.extend_from_slice(&encoded);
|
||||
}
|
||||
let multi = if compress {
|
||||
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
gz.write_all(&run).unwrap();
|
||||
Multi { size_unzipped: Some(run.len() as u32), message_body: Some(gz.finish().unwrap()) }
|
||||
} else {
|
||||
Multi { size_unzipped: None, message_body: Some(run) }
|
||||
};
|
||||
multi.encode_to_vec()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn packet(emsg: u32, body: &[u8]) -> Packet {
|
||||
Packet::new(
|
||||
emsg,
|
||||
Header { steamid: Some(76561197960287930), client_sessionid: Some(7), jobid_source: Some(42), target_job_name: Some("A.B#1".into()), ..Header::default() },
|
||||
body.to_vec(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_packet_is_its_type_a_header_length_a_header_and_a_body() {
|
||||
let p = packet(emsg::CLIENT_HEART_BEAT, &[0x08, 0x01]);
|
||||
let bytes = p.encode();
|
||||
// The protobuf bit rides on the type.
|
||||
assert_eq!(&bytes[0..4], &(703u32 | PROTO_MASK).to_le_bytes());
|
||||
let header_len = u32::from_le_bytes(bytes[4..8].try_into().unwrap()) as usize;
|
||||
assert_eq!(bytes.len(), 8 + header_len + 2);
|
||||
assert_eq!(&bytes[bytes.len() - 2..], &[0x08, 0x01]);
|
||||
assert_eq!(Packet::decode(&bytes).unwrap(), p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_answer_names_the_request_it_answers() {
|
||||
let mut p = packet(emsg::SERVICE_METHOD_RESPONSE, b"");
|
||||
assert_eq!(p.job_target(), NO_JOB);
|
||||
p.header.jobid_target = Some(9);
|
||||
assert_eq!(Packet::decode(&p.encode()).unwrap().job_target(), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_result_nobody_wrote_is_a_failure_and_a_written_one_is_believed() {
|
||||
let mut p = packet(emsg::SERVICE_METHOD_RESPONSE, b"");
|
||||
assert_eq!(p.eresult(), 2);
|
||||
p.header.eresult = Some(1);
|
||||
assert_eq!(Packet::decode(&p.encode()).unwrap().eresult(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packets_that_are_cut_short_are_refused_rather_than_guessed_at() {
|
||||
assert!(Packet::decode(&[]).is_err());
|
||||
assert!(Packet::decode(&[1, 2, 3]).is_err());
|
||||
// A protobuf packet whose header claims more than there is.
|
||||
let mut bytes = (703u32 | PROTO_MASK).to_le_bytes().to_vec();
|
||||
bytes.extend_from_slice(&1000u32.to_le_bytes());
|
||||
bytes.extend_from_slice(&[0; 10]);
|
||||
assert!(Packet::decode(&bytes).is_err());
|
||||
// One that claims the largest header there is must not overflow.
|
||||
let mut bytes = (703u32 | PROTO_MASK).to_le_bytes().to_vec();
|
||||
bytes.extend_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert!(Packet::decode(&bytes).is_err());
|
||||
// And a header that is not a header.
|
||||
let mut bytes = (703u32 | PROTO_MASK).to_le_bytes().to_vec();
|
||||
bytes.extend_from_slice(&3u32.to_le_bytes());
|
||||
bytes.extend_from_slice(&[0xff, 0xff, 0xff]);
|
||||
assert!(Packet::decode(&bytes).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_style_packet_is_read_for_its_jobs_and_session() {
|
||||
let mut bytes = 5500u32.to_le_bytes().to_vec(); // no protobuf bit
|
||||
bytes.push(36); // header size
|
||||
bytes.extend_from_slice(&2u16.to_le_bytes()); // version
|
||||
bytes.extend_from_slice(&11u64.to_le_bytes()); // target job
|
||||
bytes.extend_from_slice(&22u64.to_le_bytes()); // source job
|
||||
bytes.push(239); // canary
|
||||
bytes.extend_from_slice(&76561197960287930u64.to_le_bytes());
|
||||
bytes.extend_from_slice(&5i32.to_le_bytes());
|
||||
bytes.extend_from_slice(b"body");
|
||||
let p = Packet::decode(&bytes).unwrap();
|
||||
assert!(!p.protobuf);
|
||||
assert_eq!(p.emsg, 5500);
|
||||
assert_eq!(p.job_target(), 11);
|
||||
assert_eq!(p.header.jobid_source, Some(22));
|
||||
assert_eq!(p.header.steamid, Some(76561197960287930));
|
||||
assert_eq!(p.header.client_sessionid, Some(5));
|
||||
assert_eq!(p.body, b"body");
|
||||
assert!(Packet::decode(&bytes[..20]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_batch_unpacks_in_order_compressed_or_not() {
|
||||
let inside = [packet(emsg::CLIENT_FRIENDS_LIST, b"one"), packet(emsg::CLIENT_PERSONA_STATE, b"two"), packet(emsg::CLIENT_ACCOUNT_INFO, b"three")];
|
||||
for compress in [true, false] {
|
||||
let got = unpack_multi(&pack_multi(&inside, compress)).unwrap();
|
||||
assert_eq!(got, inside, "compressed: {compress}");
|
||||
}
|
||||
// Nothing in it is nothing out. (A zero length means "not compressed",
|
||||
// as it does to every other client, so an empty batch is sent plain.)
|
||||
assert!(unpack_multi(&pack_multi(&[], false)).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_batch_inside_a_batch_comes_out_flat() {
|
||||
let inner = packet(emsg::CLIENT_PERSONA_STATE, b"deep");
|
||||
let wrapper = packet(emsg::MULTI, &pack_multi(std::slice::from_ref(&inner), true));
|
||||
let got = unpack_multi(&pack_multi(&[packet(emsg::CLIENT_FRIENDS_LIST, b"first"), wrapper], false)).unwrap();
|
||||
assert_eq!(got.iter().map(|p| p.body.clone()).collect::<Vec<_>>(), vec![b"first".to_vec(), b"deep".to_vec()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_batch_that_lies_about_itself_is_refused() {
|
||||
let good = pack_multi(&[packet(emsg::CLIENT_FRIENDS_LIST, b"x")], true);
|
||||
// Says it unzips to a different length than it does.
|
||||
let mut multi = Multi::decode(good.as_slice()).unwrap();
|
||||
multi.size_unzipped = Some(multi.size_unzipped.unwrap() + 5);
|
||||
assert!(unpack_multi(&multi.encode_to_vec()).is_err());
|
||||
multi.size_unzipped = Some(3);
|
||||
assert!(unpack_multi(&multi.encode_to_vec()).is_err(), "unzips to more than it said");
|
||||
// Says it is huge.
|
||||
multi.size_unzipped = Some(u32::MAX);
|
||||
assert!(unpack_multi(&multi.encode_to_vec()).is_err());
|
||||
// Is not gzip at all.
|
||||
let junk = Multi { size_unzipped: Some(10), message_body: Some(vec![1, 2, 3, 4]) };
|
||||
assert!(unpack_multi(&junk.encode_to_vec()).is_err());
|
||||
// Packs a packet that runs past the end.
|
||||
let mut run = 100u32.to_le_bytes().to_vec();
|
||||
run.extend_from_slice(&[1, 2, 3]);
|
||||
let short = Multi { size_unzipped: None, message_body: Some(run) };
|
||||
assert!(unpack_multi(&short.encode_to_vec()).is_err());
|
||||
// And a length cut off in the middle.
|
||||
let cut = Multi { size_unzipped: None, message_body: Some(vec![1, 2]) };
|
||||
assert!(unpack_multi(&cut.encode_to_vec()).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! What an administrator may do to a member: remove, ban, silence.
|
||||
//!
|
||||
//! The menu beside the member list asks what this account may do here, and offers only that. Telegram has the last
|
||||
//! word: an administrator who lacks the particular right is refused ("Only an administrator may do that here").
|
||||
|
||||
use super::send::{explain, peer_for};
|
||||
use crate::model::Buffer;
|
||||
use crate::state::AppState;
|
||||
use grammers_session::types::{PeerId, PeerKind};
|
||||
use grammers_session::Session;
|
||||
use serde_json::{json, Value};
|
||||
use std::time::Duration;
|
||||
|
||||
/// How long a mute lasts, as everywhere else in moho.
|
||||
const MUTE: Duration = Duration::from_secs(10 * 60);
|
||||
|
||||
/// What this account may do to others in a conversation, in the shape the member menu reads.
|
||||
pub async fn permissions(state: &AppState, buffer: &Buffer) -> Result<Value, String> {
|
||||
let none = json!({ "canRedactOthers": false, "canKick": false, "canBan": false, "canMute": false });
|
||||
if buffer.kind == "dm" {
|
||||
return Ok(none);
|
||||
}
|
||||
let (handle, peer) = peer_for(state, buffer).await?;
|
||||
let me = handle.client.get_me().await.map_err(|e| explain(&e))?;
|
||||
let Ok(Some(me)) = me.to_ref().await else { return Ok(none) };
|
||||
let Ok(found) = handle.client.get_permissions(peer, me).await else { return Ok(none) };
|
||||
let may = found.is_creator() || found.is_admin();
|
||||
// A small group can only have a member removed; bans and silences are for supergroups and channels.
|
||||
let supergroup = peer.id.kind() == PeerKind::Channel;
|
||||
Ok(json!({ "canRedactOthers": may, "canKick": may, "canBan": may && supergroup, "canMute": may && supergroup }))
|
||||
}
|
||||
|
||||
/// Removes, bans or silences one member.
|
||||
pub async fn moderate(state: &AppState, buffer: &Buffer, user_id: &str, action: &str) -> Result<(), String> {
|
||||
let id: i64 = user_id.parse().map_err(|_| "that member cannot be told apart from the others".to_string())?;
|
||||
let (handle, peer) = peer_for(state, buffer).await?;
|
||||
let user = handle.session.peer_ref(PeerId::user_unchecked(id)).await.ok().flatten().ok_or_else(|| "Telegram has not told this account about that member yet".to_string())?;
|
||||
let supergroup = peer.id.kind() == PeerKind::Channel;
|
||||
match action {
|
||||
"kick" => handle.client.kick_participant(peer, user).await.map_err(|e| explain(&e)),
|
||||
"ban" if supergroup => handle.client.set_banned_rights(peer, user).view_messages(false).await.map_err(|e| explain(&e)),
|
||||
"mute" if supergroup => handle.client.set_banned_rights(peer, user).send_messages(false).duration(MUTE).await.map_err(|e| explain(&e)),
|
||||
"ban" | "mute" => Err("Telegram only lets a supergroup or a channel ban or silence somebody".to_string()),
|
||||
_ => Err("Telegram has no such action".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! What a bot offers: the commands in its menu.
|
||||
//!
|
||||
//! 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.
|
||||
|
||||
use super::send::{explain, peer_for};
|
||||
use crate::model::Buffer;
|
||||
use crate::state::AppState;
|
||||
use grammers_client::tl;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How long a bot's list is kept before it is asked for again.
|
||||
const KEEP: Duration = Duration::from_secs(15 * 60);
|
||||
|
||||
/// One command in a bot's menu.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Command {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// When each conversation's list was read, and what it said.
|
||||
type Kept = Mutex<HashMap<String, (Instant, Vec<Command>)>>;
|
||||
|
||||
fn kept() -> &'static Kept {
|
||||
static K: std::sync::OnceLock<Kept> = std::sync::OnceLock::new();
|
||||
K.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// The commands a bot's profile lists.
|
||||
fn from_info(info: &tl::enums::BotInfo) -> Vec<Command> {
|
||||
let tl::enums::BotInfo::Info(info) = info;
|
||||
info.commands
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|c| {
|
||||
let tl::enums::BotCommand::Command(c) = c;
|
||||
Command { name: c.command.trim_start_matches('/').to_string(), description: c.description.clone() }
|
||||
})
|
||||
.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.
|
||||
pub async fn commands(state: &AppState, buffer: &Buffer) -> Result<Vec<Command>, String> {
|
||||
if buffer.kind != "dm" || buffer.name_icon.as_deref() != Some("smart_toy") {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if let Some((when, list)) = kept().lock().unwrap_or_else(|e| e.into_inner()).get(&buffer.id) {
|
||||
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()));
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_bots_menu_is_read_from_its_profile() {
|
||||
let info = tl::enums::BotInfo::Info(tl::types::BotInfo {
|
||||
has_preview_medias: false,
|
||||
user_id: Some(1),
|
||||
description: None,
|
||||
description_photo: None,
|
||||
description_document: None,
|
||||
commands: Some(vec![
|
||||
tl::enums::BotCommand::Command(tl::types::BotCommand { ephemeral: false, command: "help".into(), description: "Show help for bot commands".into() }),
|
||||
tl::enums::BotCommand::Command(tl::types::BotCommand { ephemeral: false, command: "/weblink".into(), description: "Get available link to ZLibrary".into() }),
|
||||
tl::enums::BotCommand::Command(tl::types::BotCommand { ephemeral: false, command: "".into(), description: "nothing".into() }),
|
||||
]),
|
||||
menu_button: None,
|
||||
privacy_policy_url: None,
|
||||
app_settings: None,
|
||||
verifier_settings: None,
|
||||
});
|
||||
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() },
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
//! An account's connection: open the session it saved, stay connected, and reconnect when Telegram or the network
|
||||
//! lets go - without ever signing in afresh to get over a hiccup (see mod.rs on why).
|
||||
//!
|
||||
//! Modelled on Steam's loop (`steam/connect.rs run_with_retry`): a wait that grows while a connection falls over at
|
||||
//! once and starts again after one that held, a long wait when Telegram says to slow down, and a stop - not a retry
|
||||
//! - when Telegram has ended the session, because the same key can never work again.
|
||||
|
||||
use super::link::{self, Link};
|
||||
use super::messages::{self, Own};
|
||||
use super::peers;
|
||||
use super::Handle;
|
||||
use super::session::{self, FileSession};
|
||||
use crate::accounts::TelegramAccountConfig;
|
||||
use crate::runtime::ConnState;
|
||||
use crate::state::AppState;
|
||||
use anyhow::{Context, Result};
|
||||
use grammers_client::InvocationError;
|
||||
use grammers_mtsender::UpdatesConfiguration;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
const RECONNECT_INITIAL_DELAY: Duration = Duration::from_secs(10);
|
||||
const RECONNECT_MAX_DELAY: Duration = Duration::from_secs(300);
|
||||
|
||||
/// How often a changed session is written.
|
||||
const SAVE_EVERY: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Ends the account's session at Telegram, so removing it here does not leave a session in the account's list of
|
||||
/// devices, and takes the saved session away. Best effort: the saved one is gone either way.
|
||||
pub async fn sign_out(state: &AppState, config: &TelegramAccountConfig) {
|
||||
let path = session::path_for(config.user_id);
|
||||
if let Ok(session) = FileSession::open(path.clone()) {
|
||||
let session = Arc::new(session);
|
||||
if session.has_key() {
|
||||
// The account was removed first, so its own key is the only route it has left: not Tor unless it was.
|
||||
match link::open(state, &config.account_id(), config.api_id, session.clone()).await {
|
||||
Ok(link) => {
|
||||
if let Err(e) = link.client.sign_out().await {
|
||||
tracing::info!("telegram[{}]: signing out at Telegram: {e}", config.account_id());
|
||||
}
|
||||
link.close().await;
|
||||
}
|
||||
Err(e) => tracing::info!("telegram[{}]: could not reach Telegram to sign out: {e:#}", config.account_id()),
|
||||
}
|
||||
}
|
||||
session.delete();
|
||||
} else {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// How a connection ended.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Ending {
|
||||
/// Telegram ended the session (or it is gone): sign in again.
|
||||
SignInAgain(String),
|
||||
/// Telegram asked for a pause, or the network is down: try again after this.
|
||||
Later(Duration, String),
|
||||
/// The connection ended; try again after the usual wait.
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// What an error from Telegram means for the connection.
|
||||
pub fn classify(error: &InvocationError) -> Ending {
|
||||
match error {
|
||||
InvocationError::Rpc(rpc) => {
|
||||
let name = rpc.name.as_str();
|
||||
if rpc.code == 401 || matches!(name, "AUTH_KEY_UNREGISTERED" | "SESSION_REVOKED" | "SESSION_EXPIRED" | "USER_DEACTIVATED" | "USER_DEACTIVATED_BAN" | "AUTH_KEY_DUPLICATED") {
|
||||
Ending::SignInAgain(super::login::explain(name))
|
||||
} else if name == "FLOOD_WAIT" || name == "FLOOD_PREMIUM_WAIT" {
|
||||
// The number of seconds Telegram asked for, and a little more.
|
||||
Ending::Later(Duration::from_secs(u64::from(rpc.value.unwrap_or(60)) + 5), format!("Telegram asked for a pause of {} seconds", rpc.value.unwrap_or(60)))
|
||||
} else {
|
||||
Ending::Closed
|
||||
}
|
||||
}
|
||||
_ => Ending::Closed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts (or restarts) an account's connection.
|
||||
pub fn spawn(state: AppState, config: TelegramAccountConfig) {
|
||||
let account_id = config.account_id();
|
||||
crate::net::route::router().mark(&account_id, config.use_tor);
|
||||
state.runtime.reset_connection(&account_id);
|
||||
let handle = tokio::spawn({
|
||||
let account_id = account_id.clone();
|
||||
let state = state.clone();
|
||||
async move {
|
||||
run_with_retry(&state, &account_id).await;
|
||||
state.runtime.remove_task_handle(&account_id);
|
||||
}
|
||||
});
|
||||
state.runtime.insert_task_handle(&account_id, handle.abort_handle());
|
||||
}
|
||||
|
||||
async fn run_with_retry(state: &AppState, account_id: &str) {
|
||||
let mut delay = RECONNECT_INITIAL_DELAY;
|
||||
state.runtime.set_conn_state(state, account_id, ConnState::Connecting, None);
|
||||
loop {
|
||||
// The account as it is now: removed while this slept, it is not reconnected.
|
||||
let Some(config) = state.accounts.get_telegram(account_id) else { return };
|
||||
let (detail, wait) = match run(state, &config, account_id).await {
|
||||
Ok(Ending::SignInAgain(why)) => {
|
||||
tracing::info!("telegram[{account_id}]: stopped: {why}");
|
||||
state.runtime.set_conn_state(state, account_id, ConnState::AuthFailed, Some(&format!("{why}. Sign in to Telegram again from Accounts.")));
|
||||
return;
|
||||
}
|
||||
Ok(Ending::Later(wait, why)) => (why, Some(wait)),
|
||||
Ok(Ending::Closed) => ("connection ended".to_string(), None),
|
||||
Err(e) => {
|
||||
tracing::warn!("telegram[{account_id}]: {e:#}");
|
||||
(format!("{e:#}"), None)
|
||||
}
|
||||
};
|
||||
state.runtime.set_conn_state(state, account_id, ConnState::Connecting, None);
|
||||
let wait = wait.unwrap_or_else(|| state.runtime.next_retry_delay(account_id, &mut delay, RECONNECT_INITIAL_DELAY, RECONNECT_MAX_DELAY));
|
||||
tracing::info!("telegram[{account_id}]: not connected ({detail}); trying again in {}s", wait.as_secs());
|
||||
state.runtime.report_retry(state, account_id, &detail, wait);
|
||||
tokio::time::sleep(wait).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// One go at being connected.
|
||||
async fn run(state: &AppState, config: &TelegramAccountConfig, account_id: &str) -> Result<Ending> {
|
||||
let session = Arc::new(FileSession::open(session::path_for(config.user_id))?);
|
||||
if !session.has_key() {
|
||||
return Ok(Ending::SignInAgain("this account has no saved session".to_string()));
|
||||
}
|
||||
let mut link = link::open(state, account_id, config.api_id, session).await?;
|
||||
let ending = stay(state, config, account_id, &mut link).await;
|
||||
state.runtime.clear_telegram_handle(account_id);
|
||||
link.close().await;
|
||||
ending
|
||||
}
|
||||
|
||||
async fn stay(state: &AppState, config: &TelegramAccountConfig, account_id: &str, link: &mut Link) -> Result<Ending> {
|
||||
match link.client.is_authorized().await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => return Ok(Ending::SignInAgain("Telegram no longer accepts this session".to_string())),
|
||||
Err(e) => return Ok(classify(&e)).and_then(|ending| if ending == Ending::Closed { Err(anyhow::Error::from(e)).context("asking Telegram whether the session is good") } else { Ok(ending) }),
|
||||
}
|
||||
// The name and number the account has now, which are Telegram's to change.
|
||||
let mut own = Own { name: if config.name.is_empty() { config.phone.clone() } else { config.name.clone() }, user_id: config.user_id };
|
||||
if let Ok(me) = link.client.get_me().await {
|
||||
let (name, username) = (me.full_name(), me.username().map(str::to_string));
|
||||
if !name.is_empty() {
|
||||
own.name = name.clone();
|
||||
}
|
||||
let _ = state.accounts.mutate_telegram(account_id, |a| {
|
||||
a.name = name;
|
||||
a.username = username;
|
||||
});
|
||||
}
|
||||
state.runtime.set_own_identity(account_id, &own.name);
|
||||
let handle = Handle { client: link.client.clone(), session: link.session.clone(), own: own.clone(), names: Default::default() };
|
||||
state.runtime.set_telegram_handle(account_id, handle.clone());
|
||||
state.runtime.set_conn_state(state, account_id, ConnState::Connected, None);
|
||||
tracing::info!("telegram[{account_id}]: connected as {} ({})", config.user_id, config.username.as_deref().unwrap_or("no username"));
|
||||
// The conversations, in the background: the connection is up whether or not this succeeds.
|
||||
tokio::spawn({
|
||||
let (state, account_id, handle) = (state.clone(), account_id.to_string(), handle.clone());
|
||||
async move {
|
||||
if let Err(e) = super::dialogs::sync(&state, &account_id, &handle).await {
|
||||
tracing::warn!("telegram[{account_id}]: reading the conversations: {e:#}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let updates = link.updates.take().context("the updates were already taken")?;
|
||||
let mut stream = link.client.stream_updates(updates, UpdatesConfiguration { catch_up: true }).await.map_err(|e| anyhow::anyhow!("starting to read updates: {e}"))?;
|
||||
let mut save = tokio::time::interval(SAVE_EVERY);
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = save.tick() => {
|
||||
if let Err(e) = link.session.save_if_dirty() {
|
||||
tracing::warn!("telegram[{account_id}]: saving the session: {e:#}");
|
||||
}
|
||||
}
|
||||
update = stream.next() => match update {
|
||||
Ok(update) => handle_update(state, account_id, &handle, update).await,
|
||||
Err(e) => {
|
||||
let _ = stream.sync_update_state().await;
|
||||
return match classify(&e) {
|
||||
Ending::Closed => Err(anyhow::Error::from(e)).context("reading updates"),
|
||||
other => Ok(other),
|
||||
};
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What Telegram sent unasked: a message, an edit, something deleted.
|
||||
async fn handle_update(state: &AppState, account_id: &str, handle: &Handle, update: grammers_client::update::Update) {
|
||||
use grammers_client::update::Update;
|
||||
match update {
|
||||
Update::NewMessage(message) => {
|
||||
let recorded = match message.peer() {
|
||||
Some(_) => messages::record_named(state, account_id, &handle.own, &message, Some(&handle.names)),
|
||||
// A short update from a person names no one: asked of Telegram, who is the other side.
|
||||
None => {
|
||||
let (state, account_id, handle) = (state.clone(), account_id.to_string(), handle.clone());
|
||||
tokio::spawn(async move { record_unnamed(&state, &account_id, &handle, &message).await });
|
||||
return;
|
||||
}
|
||||
};
|
||||
tracing::debug!("telegram[{account_id}]: new message in {:?}: recorded {recorded}", message.peer_id());
|
||||
}
|
||||
Update::Raw(raw) => super::updates::raw(state, account_id, handle, &raw.raw),
|
||||
Update::MessageEdited(message) => {
|
||||
let peer = match message.peer() {
|
||||
Some(peer) => Some(peer.clone()),
|
||||
None => resolve(handle, &message).await,
|
||||
};
|
||||
if let Some(peer) = peer {
|
||||
let inner: &grammers_client::message::Message = &message;
|
||||
let buffer = super::topics::buffer_of_message(state, account_id, &peer, &inner.raw);
|
||||
let body = messages::body_of(&message);
|
||||
let attachments = buffer.remote_id.as_deref().map(|remote| super::media::attachments_of(handle.own.user_id, remote, &message)).unwrap_or_default();
|
||||
state.runtime.update_message(state, &buffer.id, &message.id().to_string(), &body, &[], &attachments);
|
||||
// A bot changes or removes its buttons by editing the message they are under.
|
||||
super::keyboards::note(state, &buffer.id, &message.id().to_string(), &inner.raw, true);
|
||||
}
|
||||
}
|
||||
Update::MessageDeleted(deleted) => {
|
||||
for id in deleted.messages() {
|
||||
forget_message(state, account_id, deleted.channel_id(), *id);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The conversation a message from a short update belongs to, from what the session knows of the person.
|
||||
async fn resolve(handle: &Handle, message: &grammers_client::message::Message) -> Option<grammers_client::peer::Peer> {
|
||||
let peer_ref = message.peer_ref().await.ok().flatten()?;
|
||||
handle.client.resolve_peer(peer_ref).await.ok()
|
||||
}
|
||||
|
||||
async fn record_unnamed(state: &AppState, account_id: &str, handle: &Handle, message: &grammers_client::message::Message) {
|
||||
match resolve(handle, message).await {
|
||||
Some(peer) => {
|
||||
messages::record_in(state, account_id, &handle.own, message, &peer, None, Some(&handle.names));
|
||||
}
|
||||
None => tracing::warn!("telegram[{account_id}]: a message in {:?} from somebody this account has no record of was not shown", message.peer_id()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes a message Telegram says is gone. A channel's deletion names the channel; any other names only the number,
|
||||
/// which is unique across the account's people and small groups, so the conversations are looked through.
|
||||
fn forget_message(state: &AppState, account_id: &str, channel: Option<i64>, id: i32) {
|
||||
let msg_id = id.to_string();
|
||||
let candidates: Vec<crate::model::Buffer> = state
|
||||
.runtime
|
||||
.list_buffers()
|
||||
.into_iter()
|
||||
.filter(|b| b.account_id == account_id)
|
||||
.filter(|b| match (channel, b.remote_id.as_deref()) {
|
||||
// A group with topics keeps each topic's conversation under the group's name and a colon.
|
||||
(Some(c), Some(remote)) => {
|
||||
let channel = peers::remote_id(grammers_session::types::PeerId::channel_unchecked(c));
|
||||
remote == channel || remote.strip_prefix(channel.as_str()).is_some_and(|rest| rest.starts_with(':'))
|
||||
}
|
||||
(Some(_), None) => false,
|
||||
(None, Some(remote)) => !remote.starts_with("-100"),
|
||||
(None, None) => false,
|
||||
})
|
||||
.collect();
|
||||
for buffer in candidates {
|
||||
if state.store.get_message(&buffer.id, &msg_id).ok().flatten().is_some() {
|
||||
state.runtime.delete_message(state, &buffer.id, &msg_id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use grammers_mtsender::RpcError;
|
||||
|
||||
fn rpc(code: i32, name: &str, value: Option<u32>) -> InvocationError {
|
||||
InvocationError::Rpc(RpcError { code, name: name.to_string(), value, caused_by: None })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_telegram_ended_stops_the_connection_and_a_flood_wait_waits_as_long_as_asked() {
|
||||
assert!(matches!(classify(&rpc(401, "AUTH_KEY_UNREGISTERED", None)), Ending::SignInAgain(_)));
|
||||
assert!(matches!(classify(&rpc(401, "SESSION_REVOKED", None)), Ending::SignInAgain(_)));
|
||||
assert!(matches!(classify(&rpc(420, "FLOOD_WAIT", Some(120))), Ending::Later(d, _) if d == Duration::from_secs(125)));
|
||||
assert_eq!(classify(&rpc(500, "INTERDC_CALL_ERROR", None)), Ending::Closed);
|
||||
assert_eq!(classify(&InvocationError::Io(std::io::Error::other("reset"))), Ending::Closed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! The conversations an account has: asked for when the connection comes up, and each one's last message with it.
|
||||
//!
|
||||
//! Telegram's dialog list answers with every conversation and, in the same response, its last message, so one
|
||||
//! request gives the list its names, kinds and recent activity without opening any of them. History proper is read
|
||||
//! when a conversation is opened (see `history`).
|
||||
|
||||
use super::media;
|
||||
use super::messages;
|
||||
use super::Handle;
|
||||
use super::peers;
|
||||
use crate::state::AppState;
|
||||
use anyhow::Result;
|
||||
use grammers_client::tl;
|
||||
|
||||
/// How many conversations are read on connect. Telegram pages them a hundred at a time.
|
||||
const LIMIT: usize = 200;
|
||||
|
||||
/// 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 };
|
||||
// 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);
|
||||
if let Err(e) = super::topics::sync(state, account_id, handle, dialog.peer()).await {
|
||||
tracing::warn!("telegram[{account_id}]: reading a group's topics: {e:#}");
|
||||
}
|
||||
if let Some(last) = &dialog.last_message {
|
||||
messages::record_named(state, account_id, &handle.own, last, Some(&handle.names));
|
||||
}
|
||||
count += 1;
|
||||
continue;
|
||||
}
|
||||
let buffer = peers::buffer_for(state, account_id, dialog.peer());
|
||||
// Filed again each time: a bot read before bots had a heading of their own was filed with the people.
|
||||
// A group is its own entry on the rail and has no heading in the account's list.
|
||||
if peers::is_group(dialog.peer()) {
|
||||
peers::file_in_rail(state, account_id, &buffer, dialog.peer(), count as i64);
|
||||
} else {
|
||||
state.runtime.set_buffer_category(state, &buffer.id, Some(peers::category_of(dialog.peer())), buffer.position);
|
||||
}
|
||||
state.runtime.set_buffer_name_icon(state, &buffer.id, peers::name_icon_of(dialog.peer()));
|
||||
if let grammers_client::peer::Peer::User(user) = dialog.peer() {
|
||||
handle.names.lock().unwrap_or_else(|e| e.into_inner()).insert(user.id().bare_id_unchecked(), peers::title_of(dialog.peer()));
|
||||
}
|
||||
if let Some(last) = &dialog.last_message {
|
||||
messages::record_named(state, account_id, &handle.own, last, Some(&handle.names));
|
||||
}
|
||||
// Silenced on the account, perhaps on the phone.
|
||||
if let tl::enums::Dialog::Dialog(d) = &dialog.raw {
|
||||
state.runtime.set_silenced(state, &buffer.id, super::notify::silent(&d.notify_settings, chrono::Utc::now().timestamp()));
|
||||
}
|
||||
// Nothing unread there, as Telegram counts it: the window need not call it unread either.
|
||||
if matches!(&dialog.raw, tl::enums::Dialog::Dialog(d) if d.unread_count == 0) {
|
||||
state.events.emit("bufferRead", serde_json::json!({ "accountId": account_id, "bufferId": buffer.id }));
|
||||
}
|
||||
if let Some(avatar) = media::avatar_url(handle.own.user_id, dialog.peer()) {
|
||||
state.runtime.set_buffer_avatar(state, &buffer.id, &avatar);
|
||||
}
|
||||
// Whether the account may write there, read off the dialog's own copy of the chat (which carries its rights):
|
||||
// where it may not, the window shows why in place of the message box.
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
state.runtime.set_buffer_read_only(state, &buffer.id, peers::read_only_for(dialog.peer(), now));
|
||||
count += 1;
|
||||
}
|
||||
tracing::info!("telegram[{account_id}]: {count} conversations");
|
||||
Ok(count)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//! A conversation's history, read when it is opened and again when it is scrolled back.
|
||||
//!
|
||||
//! Telegram keeps every message, so opening a conversation asks for its recent end, and scrolling to the top asks for
|
||||
//! the part before. What is stored is returned by `getBacklog` either way, so a failure to reach Telegram leaves what
|
||||
//! there is rather than an error.
|
||||
|
||||
use super::peers;
|
||||
use super::topics;
|
||||
use crate::model::Buffer;
|
||||
use crate::state::AppState;
|
||||
use anyhow::{anyhow, Result};
|
||||
use grammers_session::Session;
|
||||
|
||||
/// How many messages a page asks for.
|
||||
const PAGE: usize = 50;
|
||||
|
||||
/// A page of one topic's messages, as the messages themselves. Telegram lists a topic's messages as raw ones, so the
|
||||
/// ids are taken from that answer and the messages asked for by id: the library hands those over whole.
|
||||
async fn topic_page(
|
||||
handle: &super::Handle,
|
||||
peer: grammers_session::types::PeerRef,
|
||||
topic: i32,
|
||||
offset_id: i32,
|
||||
offset_date: i32,
|
||||
add_offset: i32,
|
||||
limit: i32,
|
||||
) -> Result<Vec<grammers_client::message::Message>> {
|
||||
use grammers_client::tl;
|
||||
let answer = handle
|
||||
.client
|
||||
.invoke(&tl::functions::messages::GetReplies { peer: peer.into(), msg_id: topic, offset_id, offset_date, add_offset, limit, max_id: 0, min_id: 0, hash: 0 })
|
||||
.await
|
||||
.map_err(|e| anyhow!("{}", super::send::explain(&e)))?;
|
||||
let listed = match answer {
|
||||
tl::enums::messages::Messages::Messages(m) => m.messages,
|
||||
tl::enums::messages::Messages::Slice(m) => m.messages,
|
||||
tl::enums::messages::Messages::ChannelMessages(m) => m.messages,
|
||||
tl::enums::messages::Messages::NotModified(_) => Vec::new(),
|
||||
};
|
||||
let mut ids: Vec<i32> = listed
|
||||
.iter()
|
||||
.filter_map(|m| match m {
|
||||
tl::enums::Message::Message(m) => Some(m.id),
|
||||
tl::enums::Message::Service(m) => Some(m.id),
|
||||
tl::enums::Message::Empty(_) => None,
|
||||
})
|
||||
.collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
if ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let found = handle.client.get_messages_by_id(peer, &ids).await.map_err(|e| anyhow!("{}", super::send::explain(&e)))?;
|
||||
Ok(found.into_iter().flatten().collect())
|
||||
}
|
||||
|
||||
async fn read(state: &AppState, buffer: &Buffer, before: Option<i64>) -> Result<usize> {
|
||||
let handle = state.runtime.telegram_handle(&buffer.account_id).ok_or_else(|| anyhow!("this Telegram account is not connected"))?;
|
||||
let peer = buffer.remote_id.as_deref().and_then(peers::peer_id_of).ok_or_else(|| anyhow!("this is not a Telegram conversation"))?;
|
||||
let peer = handle.session.peer_ref(peer).await.ok().flatten().ok_or_else(|| anyhow!("Telegram has not told this account about that conversation yet"))?;
|
||||
if let Some(topic) = topics::topic_of_buffer(buffer) {
|
||||
let date = before.filter(|t| *t > 0).map(|t| t.clamp(0, i64::from(i32::MAX)) as i32).unwrap_or(0);
|
||||
let got = topic_page(&handle, peer, topic, 0, date, 0, PAGE as i32).await?;
|
||||
let count = got.len();
|
||||
for message in &got {
|
||||
super::messages::record(state, &buffer.account_id, &handle.own, message);
|
||||
}
|
||||
return Ok(count);
|
||||
}
|
||||
let mut iter = handle.client.iter_messages(peer).limit(PAGE);
|
||||
if let Some(ts) = before.filter(|t| *t > 0) {
|
||||
iter = iter.offset_date(ts.clamp(0, i64::from(i32::MAX)) as i32);
|
||||
}
|
||||
let mut got = Vec::new();
|
||||
while let Some(message) = iter.next().await.map_err(|e| anyhow!("{e}"))? {
|
||||
got.push(message);
|
||||
}
|
||||
// Oldest first, so a reply finds the message it answers already stored.
|
||||
got.reverse();
|
||||
let count = got.len();
|
||||
for message in &got {
|
||||
super::messages::record(state, &buffer.account_id, &handle.own, message);
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Opening a conversation: its recent end.
|
||||
pub async fn open(state: &AppState, buffer: &Buffer) -> Result<usize> {
|
||||
read(state, buffer, None).await
|
||||
}
|
||||
|
||||
/// Scrolling back: what came before `before` (a Unix time).
|
||||
pub async fn older(state: &AppState, buffer: &Buffer, before: i64) -> Result<usize> {
|
||||
read(state, buffer, Some(before)).await
|
||||
}
|
||||
|
||||
/// Reads the messages around one, so a jump to an old message (a pin, a reply, a search result) arrives with what was
|
||||
/// said before and after it. Returns the message's time.
|
||||
pub async fn context(state: &AppState, buffer: &Buffer, msg_id: &str) -> Result<i64> {
|
||||
let id: i32 = msg_id.parse().map_err(|_| anyhow!("that is not a Telegram message"))?;
|
||||
let handle = state.runtime.telegram_handle(&buffer.account_id).ok_or_else(|| anyhow!("this Telegram account is not connected"))?;
|
||||
let peer = buffer.remote_id.as_deref().and_then(peers::peer_id_of).ok_or_else(|| anyhow!("this is not a Telegram conversation"))?;
|
||||
let peer = handle.session.peer_ref(peer).await.ok().flatten().ok_or_else(|| anyhow!("Telegram has not told this account about that conversation yet"))?;
|
||||
if let Some(topic) = topics::topic_of_buffer(buffer) {
|
||||
let mut around = topic_page(&handle, peer, topic, id.saturating_add(1), 0, 0, 26).await?;
|
||||
around.extend(topic_page(&handle, peer, topic, id, 0, -25, 25).await?);
|
||||
around.sort_by_key(|m| m.id());
|
||||
around.dedup_by_key(|m| m.id());
|
||||
for message in &around {
|
||||
super::messages::record(state, &buffer.account_id, &handle.own, message);
|
||||
}
|
||||
return around.iter().find(|m| m.id() == id).map(|m| m.date().as_second()).ok_or_else(|| anyhow!("that message is no longer there"));
|
||||
}
|
||||
let mut around = Vec::new();
|
||||
// Itself and what came before it, newest first...
|
||||
let mut older = handle.client.iter_messages(peer).offset_id(id.saturating_add(1)).limit(26);
|
||||
while let Some(message) = older.next().await.map_err(|e| anyhow!("{e}"))? {
|
||||
around.push(message);
|
||||
}
|
||||
// ...and what came after, oldest first.
|
||||
let mut newer = handle.client.iter_messages(peer).reverse(true).offset_id(id).limit(25);
|
||||
while let Some(message) = newer.next().await.map_err(|e| anyhow!("{e}"))? {
|
||||
around.push(message);
|
||||
}
|
||||
around.sort_by_key(|m| m.id());
|
||||
for message in &around {
|
||||
super::messages::record(state, &buffer.account_id, &handle.own, message);
|
||||
}
|
||||
around.iter().find(|m| m.id() == id).map(|m| m.date().as_second()).ok_or_else(|| anyhow!("that message is no longer there"))
|
||||
}
|
||||
|
||||
/// Reads on from a message towards the present, for a reader who arrived in the middle of a conversation.
|
||||
pub async fn newer(state: &AppState, buffer: &Buffer, msg_id: &str) -> Result<usize> {
|
||||
let id: i32 = msg_id.parse().map_err(|_| anyhow!("that is not a Telegram message"))?;
|
||||
let handle = state.runtime.telegram_handle(&buffer.account_id).ok_or_else(|| anyhow!("this Telegram account is not connected"))?;
|
||||
let peer = buffer.remote_id.as_deref().and_then(peers::peer_id_of).ok_or_else(|| anyhow!("this is not a Telegram conversation"))?;
|
||||
let peer = handle.session.peer_ref(peer).await.ok().flatten().ok_or_else(|| anyhow!("Telegram has not told this account about that conversation yet"))?;
|
||||
if let Some(topic) = topics::topic_of_buffer(buffer) {
|
||||
let got = topic_page(&handle, peer, topic, id, 0, -(PAGE as i32), PAGE as i32).await?;
|
||||
for message in got.iter().filter(|m| m.id() > id) {
|
||||
super::messages::record(state, &buffer.account_id, &handle.own, message);
|
||||
}
|
||||
return Ok(got.iter().filter(|m| m.id() > id).count());
|
||||
}
|
||||
let mut iter = handle.client.iter_messages(peer).reverse(true).offset_id(id).limit(PAGE);
|
||||
let mut count = 0;
|
||||
while let Some(message) = iter.next().await.map_err(|e| anyhow!("{e}"))? {
|
||||
super::messages::record(state, &buffer.account_id, &handle.own, &message);
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
//! Instant View: the article a link's preview carries, drawn as a page of its own.
|
||||
//!
|
||||
//! A message that links to a page Telegram has made an Instant View of carries the page's blocks (headings,
|
||||
//! paragraphs with their styling, pictures, lists, quotes, tables) as a tree. The window opens it in a window of its
|
||||
//! own, so this turns the tree into one self-contained HTML document: everything in it is escaped here, pictures are
|
||||
//! fetched into the cache and named by their local file, and links are limited to the kinds a page may reasonably have.
|
||||
//! What it cannot draw (embedded players, maps, other channels' posts) is left out, with the page's own link on the
|
||||
//! card to open it in a browser instead.
|
||||
|
||||
use super::media::{download_thumb, download_to, media_name, pick, Variant, MEDIA};
|
||||
use super::send::{explain, peer_for};
|
||||
use crate::model::Buffer;
|
||||
use crate::state::AppState;
|
||||
use grammers_client::media::{Document, Media, Photo};
|
||||
use grammers_client::tl;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use tl::enums::{PageBlock as B, RichText as R};
|
||||
|
||||
/// A finished article: its title, for the window, and the document.
|
||||
pub struct Article {
|
||||
pub title: String,
|
||||
pub url: String,
|
||||
pub html: String,
|
||||
}
|
||||
|
||||
/// What a page needs fetched: a picture, or a video's poster.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||
enum Want {
|
||||
Photo(i64),
|
||||
Poster(i64),
|
||||
}
|
||||
|
||||
struct Cx {
|
||||
account: i64,
|
||||
remote: String,
|
||||
message: i32,
|
||||
photos: HashMap<i64, tl::enums::Photo>,
|
||||
documents: HashMap<i64, tl::enums::Document>,
|
||||
wanted: Vec<Want>,
|
||||
}
|
||||
|
||||
fn escape(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
for c in text.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// An address a page's link may point to: web pages, mail, phones and Telegram's own, or an anchor in the page.
|
||||
fn safe_url(url: &str) -> String {
|
||||
let lower = url.trim().to_ascii_lowercase();
|
||||
let ok = ["http://", "https://", "mailto:", "tel:", "tg://", "#"].iter().any(|p| lower.starts_with(p));
|
||||
if ok { escape(url.trim()) } else { "#".to_string() }
|
||||
}
|
||||
|
||||
fn percent(path: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for b in path.bytes() {
|
||||
if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) {
|
||||
out.push(b as char);
|
||||
} else {
|
||||
out.push_str(&format!("%{b:02X}"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Cx {
|
||||
fn path(&self, want: Want) -> PathBuf {
|
||||
let (kind, id) = match want {
|
||||
Want::Photo(id) => ("iv", id),
|
||||
Want::Poster(id) => ("ivp", id),
|
||||
};
|
||||
let name = media_name(self.account, &self.remote, self.message, Variant::Full, "jpg").replace("_full.jpg", &format!("_{kind}{id}.jpg"));
|
||||
crate::media_cache::dir(MEDIA).join(name)
|
||||
}
|
||||
|
||||
/// The address of a picture the page uses, once it is fetched; `None` when the page does not carry it.
|
||||
fn picture(&mut self, want: Want) -> Option<String> {
|
||||
let known = match want {
|
||||
Want::Photo(id) => self.photos.contains_key(&id),
|
||||
Want::Poster(id) => self.documents.contains_key(&id),
|
||||
};
|
||||
if !known {
|
||||
tracing::info!("telegram: an article uses {want:?}, which its page does not carry ({} pictures, {} files)", self.photos.len(), self.documents.len());
|
||||
return None;
|
||||
}
|
||||
if !self.wanted.contains(&want) {
|
||||
self.wanted.push(want);
|
||||
}
|
||||
Some(format!("moho-media://file/?p={}", percent(&self.path(want).display().to_string())))
|
||||
}
|
||||
}
|
||||
|
||||
fn rich(t: &R, cx: &mut Cx) -> String {
|
||||
let wrap = |tag: &str, inner: &R, cx: &mut Cx| format!("<{tag}>{}</{tag}>", rich(inner, cx));
|
||||
match t {
|
||||
R::TextEmpty => String::new(),
|
||||
R::TextPlain(p) => escape(&p.text).replace('\n', "<br>"),
|
||||
R::TextBold(b) => wrap("b", &b.text, cx),
|
||||
R::TextItalic(i) => wrap("i", &i.text, cx),
|
||||
R::TextUnderline(u) => wrap("u", &u.text, cx),
|
||||
R::TextStrike(s) => wrap("s", &s.text, cx),
|
||||
R::TextFixed(f) => wrap("code", &f.text, cx),
|
||||
R::TextSubscript(s) => wrap("sub", &s.text, cx),
|
||||
R::TextSuperscript(s) => wrap("sup", &s.text, cx),
|
||||
R::TextMarked(m) => wrap("mark", &m.text, cx),
|
||||
R::TextUrl(u) => format!("<a href=\"{}\">{}</a>", safe_url(&u.url), rich(&u.text, cx)),
|
||||
R::TextEmail(e) => format!("<a href=\"mailto:{}\">{}</a>", safe_url(&e.email).trim_start_matches("mailto:"), rich(&e.text, cx)),
|
||||
R::TextPhone(p) => format!("<a href=\"tel:{}\">{}</a>", escape(&p.phone), rich(&p.text, cx)),
|
||||
R::TextConcat(c) => c.texts.iter().map(|t| rich(t, cx)).collect(),
|
||||
R::TextAnchor(a) => format!("<span id=\"{}\">{}</span>", escape(&a.name), rich(&a.text, cx)),
|
||||
R::TextSpoiler(s) => rich(&s.text, cx),
|
||||
R::TextMention(m) => rich(&m.text, cx),
|
||||
R::TextHashtag(m) => rich(&m.text, cx),
|
||||
R::TextBotCommand(m) => rich(&m.text, cx),
|
||||
R::TextCashtag(m) => rich(&m.text, cx),
|
||||
R::TextBankCard(m) => rich(&m.text, cx),
|
||||
R::TextAutoUrl(m) => rich(&m.text, cx),
|
||||
R::TextAutoEmail(m) => rich(&m.text, cx),
|
||||
R::TextAutoPhone(m) => rich(&m.text, cx),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn plain(t: &R) -> String {
|
||||
let mut cx = Cx { account: 0, remote: String::new(), message: 0, photos: HashMap::new(), documents: HashMap::new(), wanted: Vec::new() };
|
||||
let html = rich(t, &mut cx);
|
||||
// Only for a title: the tags taken out again.
|
||||
let mut out = String::new();
|
||||
let mut inside = false;
|
||||
for c in html.chars() {
|
||||
match c {
|
||||
'<' => inside = true,
|
||||
'>' => inside = false,
|
||||
c if !inside => out.push(c),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.replace("&", "&").replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").trim().to_string()
|
||||
}
|
||||
|
||||
fn is_empty(t: &R) -> bool {
|
||||
matches!(t, R::TextEmpty) || plain(t).is_empty()
|
||||
}
|
||||
|
||||
fn caption(c: &tl::enums::PageCaption, cx: &mut Cx) -> String {
|
||||
let tl::enums::PageCaption::Caption(c) = c;
|
||||
let (text, credit) = (rich(&c.text, cx), rich(&c.credit, cx));
|
||||
if text.is_empty() && credit.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
format!("<figcaption>{text}{}</figcaption>", if credit.is_empty() { String::new() } else { format!("<cite>{credit}</cite>") })
|
||||
}
|
||||
|
||||
fn blocks(list: &[B], cx: &mut Cx) -> String {
|
||||
list.iter().map(|b| block(b, cx)).collect()
|
||||
}
|
||||
|
||||
fn heading(tag: &str, t: &R, cx: &mut Cx) -> String {
|
||||
format!("<{tag}>{}</{tag}>", rich(t, cx))
|
||||
}
|
||||
|
||||
fn block(b: &B, cx: &mut Cx) -> String {
|
||||
match b {
|
||||
B::Title(t) => heading("h1", &t.text, cx),
|
||||
B::Subtitle(t) => format!("<p class=\"subtitle\">{}</p>", rich(&t.text, cx)),
|
||||
B::Kicker(t) => format!("<p class=\"kicker\">{}</p>", rich(&t.text, cx)),
|
||||
B::AuthorDate(a) => {
|
||||
let who = rich(&a.author, cx);
|
||||
let when = chrono::DateTime::from_timestamp(i64::from(a.published_date), 0).filter(|_| a.published_date > 0).map(|d| d.format("%B %-d, %Y").to_string());
|
||||
let parts: Vec<String> = [Some(who).filter(|w| !w.is_empty()), when].into_iter().flatten().collect();
|
||||
if parts.is_empty() { String::new() } else { format!("<p class=\"byline\">{}</p>", parts.join(" · ")) }
|
||||
}
|
||||
B::Header(h) => heading("h2", &h.text, cx),
|
||||
B::Heading1(h) => heading("h2", &h.text, cx),
|
||||
B::Heading2(h) => heading("h3", &h.text, cx),
|
||||
B::Heading3(h) => heading("h4", &h.text, cx),
|
||||
B::Heading4(h) => heading("h5", &h.text, cx),
|
||||
B::Heading5(h) => heading("h6", &h.text, cx),
|
||||
B::Heading6(h) => heading("h6", &h.text, cx),
|
||||
B::Subheader(t) => heading("h3", &t.text, cx),
|
||||
B::Paragraph(p) => {
|
||||
let inner = rich(&p.text, cx);
|
||||
if inner.is_empty() { String::new() } else { format!("<p>{inner}</p>") }
|
||||
}
|
||||
B::Preformatted(p) => format!("<pre><code>{}</code></pre>", rich(&p.text, cx)),
|
||||
B::Footer(f) => format!("<p class=\"footer\">{}</p>", rich(&f.text, cx)),
|
||||
B::Divider => "<hr>".to_string(),
|
||||
B::Anchor(a) => format!("<a id=\"{}\"></a>", escape(&a.name)),
|
||||
B::List(l) => {
|
||||
let items: String = l
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| match i {
|
||||
tl::enums::PageListItem::Text(t) => format!("<li>{}</li>", rich(&t.text, cx)),
|
||||
tl::enums::PageListItem::Blocks(b) => format!("<li>{}</li>", blocks(&b.blocks, cx)),
|
||||
})
|
||||
.collect();
|
||||
format!("<ul>{items}</ul>")
|
||||
}
|
||||
B::OrderedList(l) => {
|
||||
let items: String = l
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| match i {
|
||||
tl::enums::PageListOrderedItem::Text(t) => format!("<li>{}</li>", rich(&t.text, cx)),
|
||||
tl::enums::PageListOrderedItem::Blocks(b) => format!("<li>{}</li>", blocks(&b.blocks, cx)),
|
||||
})
|
||||
.collect();
|
||||
let start = l.start.map(|s| format!(" start=\"{s}\"")).unwrap_or_default();
|
||||
format!("<ol{start}{}>{items}</ol>", if l.reversed { " reversed" } else { "" })
|
||||
}
|
||||
B::Blockquote(q) => {
|
||||
let who = rich(&q.caption, cx);
|
||||
format!("<blockquote><p>{}</p>{}</blockquote>", rich(&q.text, cx), if who.is_empty() { String::new() } else { format!("<cite>{who}</cite>") })
|
||||
}
|
||||
B::BlockquoteBlocks(q) => format!("<blockquote>{}</blockquote>", blocks(&q.blocks, cx)),
|
||||
B::Pullquote(q) => {
|
||||
let who = rich(&q.caption, cx);
|
||||
format!("<blockquote class=\"pull\"><p>{}</p>{}</blockquote>", rich(&q.text, cx), if who.is_empty() { String::new() } else { format!("<cite>{who}</cite>") })
|
||||
}
|
||||
B::Photo(p) => {
|
||||
// A picture the page does not carry still has its words.
|
||||
let img = match cx.picture(Want::Photo(p.photo_id)) {
|
||||
Some(src) => {
|
||||
let img = format!("<img src=\"{src}\" alt=\"\">");
|
||||
match &p.url {
|
||||
Some(url) => format!("<a href=\"{}\">{img}</a>", safe_url(url)),
|
||||
None => img,
|
||||
}
|
||||
}
|
||||
None => String::new(),
|
||||
};
|
||||
format!("<figure>{img}{}</figure>", caption(&p.caption, cx))
|
||||
}
|
||||
B::Video(v) => {
|
||||
let poster = cx.picture(Want::Poster(v.video_id)).map(|src| format!("<img src=\"{src}\" alt=\"\">")).unwrap_or_default();
|
||||
format!("<figure class=\"video\">{poster}<span class=\"note\">Video: open the link above to watch it</span>{}</figure>", caption(&v.caption, cx))
|
||||
}
|
||||
B::Cover(c) => format!("<div class=\"cover\">{}</div>", block(&c.cover, cx)),
|
||||
B::Collage(c) => format!("<figure class=\"collage\"><div>{}</div>{}</figure>", blocks(&c.items, cx), caption(&c.caption, cx)),
|
||||
B::Slideshow(c) => format!("<figure class=\"collage\"><div>{}</div>{}</figure>", blocks(&c.items, cx), caption(&c.caption, cx)),
|
||||
B::Embed(e) => {
|
||||
let poster = e.poster_photo_id.and_then(|id| cx.picture(Want::Photo(id))).map(|src| format!("<img src=\"{src}\" alt=\"\">")).unwrap_or_default();
|
||||
let link = e.url.as_deref().map(|u| format!("<a class=\"note\" href=\"{}\">Open the embedded content</a>", safe_url(u))).unwrap_or_default();
|
||||
format!("<figure>{poster}{link}{}</figure>", caption(&e.caption, cx))
|
||||
}
|
||||
B::Table(t) => {
|
||||
let rows: String = t
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let tl::enums::PageTableRow::Row(row) = row;
|
||||
let cells: String = row
|
||||
.cells
|
||||
.iter()
|
||||
.map(|cell| {
|
||||
let tl::enums::PageTableCell::Cell(c) = cell;
|
||||
let tag = if c.header { "th" } else { "td" };
|
||||
let span = format!("{}{}", c.colspan.map(|n| format!(" colspan=\"{n}\"")).unwrap_or_default(), c.rowspan.map(|n| format!(" rowspan=\"{n}\"")).unwrap_or_default());
|
||||
let align = if c.align_center { " class=\"c\"" } else if c.align_right { " class=\"r\"" } else { "" };
|
||||
format!("<{tag}{span}{align}>{}</{tag}>", c.text.as_ref().map(|t| rich(t, cx)).unwrap_or_default())
|
||||
})
|
||||
.collect();
|
||||
format!("<tr>{cells}</tr>")
|
||||
})
|
||||
.collect();
|
||||
let title = rich(&t.title, cx);
|
||||
format!("<div class=\"table\"><table>{}{rows}</table></div>", if title.is_empty() { String::new() } else { format!("<caption>{title}</caption>") })
|
||||
}
|
||||
B::Details(d) => format!("<details{}><summary>{}</summary>{}</details>", if d.open { " open" } else { "" }, rich(&d.title, cx), blocks(&d.blocks, cx)),
|
||||
B::Math(m) => format!("<pre><code>{}</code></pre>", escape(&m.source)),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
const STYLE: &str = "
|
||||
:root{color-scheme:light dark;--bg:#fff;--fg:#1c2733;--muted:#6b7a89;--line:#d8dee4;--link:#2b7bd3;--soft:#f2f5f8}
|
||||
@media (prefers-color-scheme:dark){:root{--bg:#0e1621;--fg:#e4e9ee;--muted:#8a99a8;--line:#26323f;--link:#6ab3f3;--soft:#17212b}}
|
||||
html,body{margin:0;background:var(--bg);color:var(--fg)}
|
||||
body{font:17px/1.65 system-ui,-apple-system,'Segoe UI','Noto Sans',sans-serif}
|
||||
main{max-width:740px;margin:0 auto;padding:0 20px 80px}
|
||||
a{color:var(--link);text-decoration:none}a:hover{text-decoration:underline}
|
||||
h1{font-size:2rem;line-height:1.2;margin:.8em 0 .4em}h2{font-size:1.5rem;margin:1.4em 0 .4em}h3{font-size:1.25rem;margin:1.3em 0 .3em}h4,h5,h6{margin:1.2em 0 .3em}
|
||||
p{margin:.9em 0}.kicker{color:var(--muted);text-transform:uppercase;letter-spacing:.06em;font-size:.8rem;margin:1.6em 0 0}
|
||||
.subtitle{color:var(--muted);font-size:1.15rem}.byline,.footer{color:var(--muted);font-size:.9rem}
|
||||
figure{margin:1.4em -20px}figure img,.cover img{display:block;width:100%;height:auto}figcaption{padding:.5em 20px 0;color:var(--muted);font-size:.9rem}figcaption cite{display:block;font-style:normal;opacity:.8}
|
||||
.cover{margin:0 -20px}.cover figure{margin:0}.cover figcaption{padding:.7em 20px;text-transform:uppercase;letter-spacing:.04em;font-size:.8rem}.cover figcaption cite{margin:.6em -20px -.7em;padding:.6em 20px;background:var(--soft);color:var(--fg);font-size:1rem;font-weight:600;text-transform:none;letter-spacing:0;opacity:1}.collage div{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:4px}.collage figure{margin:0}
|
||||
.note{display:block;padding:.6em 20px;color:var(--muted);font-size:.9rem}
|
||||
blockquote{margin:1.2em 0;padding:.1em 1em;border-left:3px solid var(--link);color:var(--fg)}blockquote.pull{font-size:1.25rem;border-left:0;text-align:center;font-style:italic}cite{display:block;color:var(--muted);font-style:normal;font-size:.9rem}
|
||||
pre{background:var(--soft);padding:12px 14px;overflow:auto;border-radius:6px;font-size:.9rem}code{font-family:ui-monospace,Menlo,Consolas,monospace}mark{background:#ffe08a55;color:inherit}
|
||||
hr{border:0;border-top:1px solid var(--line);margin:2em 0}ul,ol{padding-left:1.4em}li{margin:.3em 0}
|
||||
.table{overflow:auto;margin:1.2em 0}table{border-collapse:collapse;width:100%}td,th{border:1px solid var(--line);padding:6px 10px;vertical-align:top}th{background:var(--soft)}.c{text-align:center}.r{text-align:right}caption{caption-side:top;text-align:left;font-weight:600;padding-bottom:.4em}
|
||||
details{margin:1em 0;border:1px solid var(--line);border-radius:6px;padding:.4em .9em}summary{cursor:pointer;font-weight:600}
|
||||
";
|
||||
|
||||
/// The article a message's link preview carries, with its pictures fetched.
|
||||
pub async fn article(state: &AppState, buffer: &Buffer, msg_id: &str) -> Result<Article, String> {
|
||||
let id: i32 = msg_id.parse().map_err(|_| "that message has no article".to_string())?;
|
||||
let (handle, peer) = peer_for(state, buffer).await?;
|
||||
let found = handle.client.get_messages_by_id(peer, &[id]).await.map_err(|e| explain(&e))?;
|
||||
let Some(Some(message)) = found.into_iter().next() else { return Err("that message is no longer there".to_string()) };
|
||||
let Some(Media::WebPage(media)) = message.media() else { return Err("that message links to no page".to_string()) };
|
||||
let tl::enums::WebPage::Page(page) = &media.raw.webpage else { return Err("Telegram has no preview of that page".to_string()) };
|
||||
let url = page.url.clone();
|
||||
let title = page.title.clone().unwrap_or_default();
|
||||
let (preview_photo, preview_document) = (page.photo.clone(), page.document.clone());
|
||||
let mut cached = page.cached_page.clone();
|
||||
// A message may carry only the start of the page: the whole of it is asked for.
|
||||
if matches!(&cached, Some(tl::enums::Page::Page(p)) if p.part) {
|
||||
if let Ok(tl::enums::messages::WebPage::Page(full)) = handle.client.invoke(&tl::functions::messages::GetWebPage { url: url.clone(), hash: 0 }).await {
|
||||
if let tl::enums::WebPage::Page(whole) = full.webpage {
|
||||
cached = whole.cached_page.or(cached);
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(tl::enums::Page::Page(doc)) = cached else { return Err("Telegram has no Instant View of that page".to_string()) };
|
||||
let remote = buffer.remote_id.clone().unwrap_or_default();
|
||||
let mut cx = Cx {
|
||||
account: handle.own.user_id,
|
||||
remote,
|
||||
message: id,
|
||||
// The page's own picture (the one its preview shows) is often the cover and is not listed again with the rest.
|
||||
photos: doc.photos.iter().chain(preview_photo.iter()).filter_map(|p| match p { tl::enums::Photo::Photo(x) => Some((x.id, p.clone())), _ => None }).collect(),
|
||||
documents: doc.documents.iter().chain(preview_document.iter()).filter_map(|d| match d { tl::enums::Document::Document(x) => Some((x.id, d.clone())), _ => None }).collect(),
|
||||
wanted: Vec::new(),
|
||||
};
|
||||
let body = blocks(&doc.blocks, &mut cx);
|
||||
// The page's own title, from its first heading when the preview gave none.
|
||||
let title = if title.is_empty() { doc.blocks.iter().find_map(|b| if let B::Title(t) = b { Some(plain(&t.text)).filter(|t| !t.is_empty()) } else { None }).unwrap_or_default() } else { title };
|
||||
let has_title = doc.blocks.iter().any(|b| matches!(b, B::Title(t) if !is_empty(&t.text)));
|
||||
let heading = if has_title || title.is_empty() { String::new() } else { format!("<h1>{}</h1>", escape(&title)) };
|
||||
|
||||
// The pictures, fetched together; one that will not come leaves a gap where it was.
|
||||
let mut jobs = tokio::task::JoinSet::new();
|
||||
for want in cx.wanted.clone() {
|
||||
let path = cx.path(want);
|
||||
if path.exists() {
|
||||
continue;
|
||||
}
|
||||
let client = handle.client.clone();
|
||||
let (photo, document) = match want {
|
||||
Want::Photo(id) => (cx.photos.get(&id).cloned(), None),
|
||||
Want::Poster(id) => (None, cx.documents.get(&id).cloned()),
|
||||
};
|
||||
jobs.spawn(async move {
|
||||
let done = if let Some(photo) = photo {
|
||||
let photo = Photo::from_raw(photo);
|
||||
match pick(&photo.thumbs(), 1280) {
|
||||
Some(size) => download_to(&client, size, &path, false).await,
|
||||
None => download_to(&client, &photo, &path, false).await,
|
||||
}
|
||||
} else if let Some(document) = document {
|
||||
let document = Document::from_raw_media(tl::types::MessageMediaDocument {
|
||||
nopremium: false,
|
||||
spoiler: false,
|
||||
video: true,
|
||||
round: false,
|
||||
voice: false,
|
||||
document: Some(document),
|
||||
alt_documents: None,
|
||||
video_cover: None,
|
||||
video_timestamp: None,
|
||||
ttl_seconds: None,
|
||||
});
|
||||
download_thumb(&client, &document, &path).await
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
if let Err(e) = done {
|
||||
tracing::debug!("telegram: a picture of an article: {e:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
while jobs.join_next().await.is_some() {}
|
||||
|
||||
let rtl = if doc.rtl { " dir=\"rtl\"" } else { "" };
|
||||
let html = format!(
|
||||
"<!doctype html><html lang=\"en\"{rtl}><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
|
||||
<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; img-src moho-media: data:; style-src 'unsafe-inline'\">\
|
||||
<title>{}</title><style>{STYLE}</style></head><body><main>{heading}{body}<p class=\"footer\"><a href=\"{}\">Open the original page</a></p></main></body></html>",
|
||||
escape(&title),
|
||||
safe_url(&url)
|
||||
);
|
||||
Ok(Article { title, url, html })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cx() -> Cx {
|
||||
Cx { account: 1, remote: "-100123".into(), message: 7, photos: HashMap::new(), documents: HashMap::new(), wanted: Vec::new() }
|
||||
}
|
||||
|
||||
fn plain_text(t: &str) -> R {
|
||||
R::TextPlain(tl::types::TextPlain { text: t.into() })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_is_escaped_and_styled() {
|
||||
let mut c = cx();
|
||||
let t = R::TextConcat(tl::types::TextConcat {
|
||||
texts: vec![plain_text("a <b> & "), R::TextBold(Box::new(tl::types::TextBold { text: plain_text("bold") }))],
|
||||
});
|
||||
assert_eq!(rich(&t, &mut c), "a <b> & <b>bold</b>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_may_only_go_where_a_page_would() {
|
||||
assert_eq!(safe_url("https://example.com/a?b=1&c=2"), "https://example.com/a?b=1&c=2");
|
||||
assert_eq!(safe_url("javascript:alert(1)"), "#");
|
||||
assert_eq!(safe_url("data:text/html,x"), "#");
|
||||
assert_eq!(safe_url("#section"), "#section");
|
||||
let mut c = cx();
|
||||
let link = R::TextUrl(Box::new(tl::types::TextUrl { text: plain_text("x"), url: "javascript:alert(1)".into(), webpage_id: 0 }));
|
||||
assert_eq!(rich(&link, &mut c), "<a href=\"#\">x</a>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_the_page_lacks_keeps_its_words_and_one_it_has_is_named_by_its_file() {
|
||||
let mut c = cx();
|
||||
let photo = B::Photo(tl::types::PageBlockPhoto {
|
||||
spoiler: false,
|
||||
photo_id: 9,
|
||||
caption: tl::enums::PageCaption::Caption(tl::types::PageCaption { text: plain_text("a caption"), credit: R::TextEmpty }),
|
||||
url: None,
|
||||
webpage_id: None,
|
||||
});
|
||||
let without = block(&photo, &mut c);
|
||||
assert!(!without.contains("<img") && without.contains("a caption"), "{without}");
|
||||
c.photos.insert(9, tl::enums::Photo::Empty(tl::types::PhotoEmpty { id: 9 }));
|
||||
let html = block(&photo, &mut c);
|
||||
assert!(html.contains("moho-media://file/?p=") && html.contains("_iv9.jpg") && html.contains("a caption"), "{html}");
|
||||
assert_eq!(c.wanted, vec![Want::Photo(9)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//! A bot's inline keyboard: the buttons under one of its messages.
|
||||
//!
|
||||
//! Telegram sends them as rows of buttons with the message. Most are "callback" buttons, which tell the bot something
|
||||
//! when pressed and are answered with a short notice (or nothing, the bot editing its message instead); the rest are
|
||||
//! links. They are drawn with the same shape Discord's buttons use, and an edit to the message that changes or removes
|
||||
//! them says so.
|
||||
|
||||
use super::send::{explain, peer_for};
|
||||
use crate::model::{Buffer, Component};
|
||||
use crate::state::AppState;
|
||||
use grammers_client::tl;
|
||||
use serde_json::json;
|
||||
use tl::enums::InlineButtonType as T;
|
||||
|
||||
fn hex(data: &[u8]) -> String {
|
||||
data.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
fn unhex(text: &str) -> Option<Vec<u8>> {
|
||||
if !text.len().is_multiple_of(2) {
|
||||
return None;
|
||||
}
|
||||
(0..text.len()).step_by(2).map(|i| u8::from_str_radix(text.get(i..i + 2)?, 16).ok()).collect()
|
||||
}
|
||||
|
||||
/// Only an address a window may open in a browser.
|
||||
fn web(url: &str) -> Option<String> {
|
||||
let lower = url.trim().to_ascii_lowercase();
|
||||
(lower.starts_with("https://") || lower.starts_with("http://")).then(|| url.trim().to_string())
|
||||
}
|
||||
|
||||
/// The buttons of an inline keyboard, in rows, as this client draws buttons. Nothing for any other kind of markup.
|
||||
pub fn components_of(markup: &tl::enums::ReplyMarkup) -> Vec<Component> {
|
||||
let tl::enums::ReplyMarkup::ReplyInlineMarkup(inline) = markup else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
for (row_index, row) in inline.rows.iter().enumerate() {
|
||||
let tl::enums::KeyboardInlineButtonRow::Row(row) = row;
|
||||
for button in &row.buttons {
|
||||
let tl::enums::KeyboardInlineButton::Button(button) = button;
|
||||
let style = button.style.as_ref().map(|s| {
|
||||
let tl::enums::KeyboardButtonStyle::Style(s) = s;
|
||||
if s.bg_danger {
|
||||
"danger"
|
||||
} else if s.bg_success {
|
||||
"success"
|
||||
} else if s.bg_primary {
|
||||
"primary"
|
||||
} else {
|
||||
"secondary"
|
||||
}
|
||||
});
|
||||
let make = |custom_id: Option<String>, style: Option<&str>, url: Option<String>, disabled: bool| Component {
|
||||
kind: "button".to_string(),
|
||||
custom_id,
|
||||
label: Some(button.text.clone()).filter(|t| !t.is_empty()),
|
||||
style: style.map(str::to_string),
|
||||
url,
|
||||
disabled,
|
||||
emoji: None,
|
||||
options: Vec::new(),
|
||||
placeholder: None,
|
||||
row: row_index as i64,
|
||||
};
|
||||
match &button.r#type {
|
||||
T::Callback(c) => out.push(make(Some(hex(&c.data)), style.or(Some("secondary")), None, c.requires_password)),
|
||||
T::Url(u) => out.extend(web(&u.url).map(|url| make(None, Some("link"), Some(url), false))),
|
||||
T::UrlAuth(u) => out.extend(web(&u.url).map(|url| make(None, Some("link"), Some(url), false))),
|
||||
T::WebView(w) => out.extend(web(&w.url).map(|url| make(None, Some("link"), Some(url), false))),
|
||||
T::Disabled => out.push(make(None, Some("secondary"), None, true)),
|
||||
// Switching to inline mode, games, payments, profile links and copy buttons are not done here.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Keeps the buttons on a message and says so. With none, an edit that took them away takes them off the row too.
|
||||
pub fn note(state: &AppState, buffer_id: &str, msg_id: &str, raw: &tl::enums::Message, edited: bool) {
|
||||
let tl::enums::Message::Message(m) = raw else { return };
|
||||
let components = m.reply_markup.as_ref().map(components_of).unwrap_or_default();
|
||||
// Said when a message has markup that gave no buttons, so what the bot sent is on record rather than guessed at.
|
||||
if let Some(markup) = m.reply_markup.as_ref().filter(|_| components.is_empty()) {
|
||||
let text = format!("{markup:?}");
|
||||
tracing::info!("telegram: message {msg_id} carries markup with no buttons this client draws: {}", text.chars().take(400).collect::<String>());
|
||||
}
|
||||
if components.is_empty() && !edited {
|
||||
return;
|
||||
}
|
||||
let kept = if components.is_empty() { state.store.clear_components(buffer_id, msg_id) } else { state.store.set_components(buffer_id, msg_id, &components) };
|
||||
match kept {
|
||||
Ok(()) => state.events.emit("messageComponents", json!({ "bufferId": buffer_id, "messageId": msg_id, "components": components })),
|
||||
Err(e) => tracing::debug!("telegram: keeping the buttons on {msg_id}: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// What the bot said back to a press: a notice to show (as a pop-up alert when it asked for one), and an address to open.
|
||||
pub async fn press(state: &AppState, buffer: &Buffer, msg_id: &str, custom_id: &str) -> Result<serde_json::Value, String> {
|
||||
let id: i32 = msg_id.parse().map_err(|_| "that message has no buttons".to_string())?;
|
||||
let data = unhex(custom_id).ok_or_else(|| "that button is not one this client can press".to_string())?;
|
||||
let (handle, peer) = peer_for(state, buffer).await?;
|
||||
let answer = handle
|
||||
.client
|
||||
.invoke(&tl::functions::messages::GetBotCallbackAnswer { game: false, peer: peer.into(), msg_id: id, data: Some(data), password: None })
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
grammers_client::InvocationError::Rpc(r) if r.name == "BOT_RESPONSE_TIMEOUT" => "The bot did not answer".to_string(),
|
||||
grammers_client::InvocationError::Rpc(r) if r.name == "DATA_INVALID" || r.name == "BUTTON_DATA_INVALID" => "That button is no longer valid".to_string(),
|
||||
_ => explain(&e),
|
||||
})?;
|
||||
let tl::enums::messages::BotCallbackAnswer::Answer(a) = answer;
|
||||
Ok(json!({ "message": a.message, "alert": a.alert, "url": a.url.as_deref().and_then(web) }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn button(text: &str, kind: T) -> tl::enums::KeyboardInlineButton {
|
||||
tl::enums::KeyboardInlineButton::Button(tl::types::KeyboardInlineButton { style: None, text: text.into(), r#type: kind })
|
||||
}
|
||||
|
||||
fn keyboard(rows: Vec<Vec<tl::enums::KeyboardInlineButton>>) -> tl::enums::ReplyMarkup {
|
||||
tl::enums::ReplyMarkup::ReplyInlineMarkup(tl::types::ReplyInlineMarkup {
|
||||
force_reply: false,
|
||||
rows: rows.into_iter().map(|buttons| tl::enums::KeyboardInlineButtonRow::Row(tl::types::KeyboardInlineButtonRow { buttons })).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_survives_the_trip_through_a_button() {
|
||||
assert_eq!(unhex(&hex(&[0, 1, 0xab, 0xff])), Some(vec![0, 1, 0xab, 0xff]));
|
||||
assert_eq!(unhex("abc"), None);
|
||||
assert_eq!(unhex("zz"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_keep_their_places_and_links_are_links() {
|
||||
let markup = keyboard(vec![
|
||||
vec![button("Next", T::Callback(tl::types::InlineButtonTypeCallback { requires_password: false, data: b"next:2".to_vec() })), button("Close", T::Callback(tl::types::InlineButtonTypeCallback { requires_password: false, data: b"x".to_vec() }))],
|
||||
vec![button("Open site", T::Url(tl::types::InlineButtonTypeUrl { url: "https://example.com/a".into() }))],
|
||||
]);
|
||||
let got = components_of(&markup);
|
||||
assert_eq!(got.iter().map(|c| (c.label.as_deref().unwrap(), c.row)).collect::<Vec<_>>(), vec![("Next", 0), ("Close", 0), ("Open site", 1)]);
|
||||
assert_eq!(got[0].custom_id.as_deref(), Some(hex(b"next:2").as_str()));
|
||||
assert_eq!(got[2].url.as_deref(), Some("https://example.com/a"));
|
||||
assert_eq!(got[2].style.as_deref(), Some("link"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_cannot_be_opened_or_pressed_is_left_out() {
|
||||
let markup = keyboard(vec![vec![
|
||||
button("App", T::Url(tl::types::InlineButtonTypeUrl { url: "tg://resolve?domain=x".into() })),
|
||||
button("Pay", T::Buy),
|
||||
button("Locked", T::Callback(tl::types::InlineButtonTypeCallback { requires_password: true, data: b"x".to_vec() })),
|
||||
]]);
|
||||
let got = components_of(&markup);
|
||||
assert_eq!(got.len(), 1, "only the callback is kept, and it cannot be pressed");
|
||||
assert!(got[0].disabled);
|
||||
assert!(components_of(&tl::enums::ReplyMarkup::ReplyKeyboardHide(tl::types::ReplyKeyboardHide { selective: false })).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! One connection to Telegram: the library's sender pool, its client and the stream of what Telegram sends.
|
||||
//!
|
||||
//! Everything that talks to Telegram - a sign-in, a connected account - opens one of these and closes it when it
|
||||
//! is done. The pool connects to each datacenter on demand, and a closed one drops its connections gracefully.
|
||||
|
||||
use super::session::FileSession;
|
||||
use crate::state::AppState;
|
||||
use anyhow::{Context, Result};
|
||||
use grammers_client::Client;
|
||||
use grammers_mtsender::{SenderPool, SenderPoolFatHandle};
|
||||
use grammers_session::updates::UpdatesLike;
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct Link {
|
||||
pub client: Client,
|
||||
pub session: Arc<FileSession>,
|
||||
/// What Telegram sends unasked, to be handed to `Client::stream_updates` once (taken by whoever reads it).
|
||||
pub updates: Option<Receiver<UpdatesLike>>,
|
||||
handle: SenderPoolFatHandle,
|
||||
runner: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
/// Opens a connection under `key` (the account's id, or a sign-in's pending key), by the account's route if it has
|
||||
/// one: Tor or the proxy, as a SOCKS5 address the library connects through.
|
||||
pub async fn open(state: &AppState, key: &str, api_id: i32, session: Arc<FileSession>) -> Result<Link> {
|
||||
let router = crate::net::route::router();
|
||||
let proxy = if router.routed(key) {
|
||||
let (host, port) = router.ready(|msg| state.runtime.report_progress(state, key, msg)).await.context("starting Tor or reaching the proxy")?;
|
||||
Some(format!("socks5://{host}:{port}"))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let SenderPool { runner, handle, updates } = SenderPool::with_configuration(session.clone(), api_id, super::connection_params(proxy.as_deref()));
|
||||
let client = Client::new(handle.clone());
|
||||
let runner = tokio::spawn(runner.run());
|
||||
Ok(Link { client, session, updates: Some(updates), handle, runner })
|
||||
}
|
||||
|
||||
impl Link {
|
||||
/// Closes the connections, and writes the session if anything changed.
|
||||
pub async fn close(self) {
|
||||
self.handle.quit();
|
||||
let _ = self.runner.await;
|
||||
if let Err(e) = self.session.save_if_dirty() {
|
||||
tracing::warn!("telegram: saving the session: {e:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
//! Adding an account: the sign-in a person takes part in.
|
||||
//!
|
||||
//! The person types an `api_id`, an `api_hash` and a phone number. Telegram sends a code to the account's other
|
||||
//! sessions (or by SMS), the person types it, and if the account has a cloud password that is asked for next.
|
||||
//! Neither the code nor the password is kept: what the account keeps is the session Telegram issued.
|
||||
//!
|
||||
//! Like Steam's, it is a conversation that can take minutes, so it runs as its own task and talks to the window
|
||||
//! through events:
|
||||
//!
|
||||
//! - `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
|
||||
//! - `telegramLoginResult` - done, with the account, or the reason it failed
|
||||
//!
|
||||
//! and the window answers with `submitTelegramCode` and `submitTelegramPassword`.
|
||||
|
||||
use super::link::{self, Link};
|
||||
use super::session::{self, FileSession};
|
||||
use crate::accounts::TelegramAccountConfig;
|
||||
use crate::state::AppState;
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use grammers_client::SignInError;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// How long a sign-in may wait for a person. Telegram's own code is good for a few minutes, and a form left open
|
||||
/// overnight should not leave a task.
|
||||
const PATIENCE: Duration = Duration::from_secs(5 * 60);
|
||||
|
||||
/// What the window can hand to a sign-in that is waiting.
|
||||
enum Answer {
|
||||
Code(String),
|
||||
Password(String),
|
||||
}
|
||||
|
||||
/// The sign-ins waiting for an answer, by id, and the way to give each one.
|
||||
fn waiting() -> &'static Mutex<HashMap<String, mpsc::UnboundedSender<Answer>>> {
|
||||
static WAITING: std::sync::OnceLock<Mutex<HashMap<String, mpsc::UnboundedSender<Answer>>>> = std::sync::OnceLock::new();
|
||||
WAITING.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
fn give(login_id: &str, answer: Answer) -> Result<()> {
|
||||
let sender = waiting().lock().unwrap().get(login_id).cloned().ok_or_else(|| anyhow!("that sign-in is not waiting for anything any more"))?;
|
||||
sender.send(answer).map_err(|_| anyhow!("that sign-in has ended"))
|
||||
}
|
||||
|
||||
/// Gives a sign-in the code Telegram sent.
|
||||
pub fn submit_code(login_id: &str, code: &str) -> Result<()> {
|
||||
// Telegram's own client shows the code as "12345"; a pasted one may carry spaces or a dash.
|
||||
let code: String = code.chars().filter(|c| c.is_ascii_digit()).collect();
|
||||
if code.is_empty() {
|
||||
bail!("type the code first");
|
||||
}
|
||||
give(login_id, Answer::Code(code))
|
||||
}
|
||||
|
||||
/// Gives a sign-in the account's cloud password.
|
||||
pub fn submit_password(login_id: &str, password: &str) -> Result<()> {
|
||||
if password.is_empty() {
|
||||
bail!("type the password first");
|
||||
}
|
||||
give(login_id, Answer::Password(password.to_string()))
|
||||
}
|
||||
|
||||
/// Stops a sign-in that is waiting, because the form was closed.
|
||||
pub fn cancel(login_id: &str) {
|
||||
waiting().lock().unwrap().remove(login_id);
|
||||
}
|
||||
|
||||
/// A phone number as Telegram wants it: digits with a `+` in front.
|
||||
pub fn normalize_phone(typed: &str) -> Option<String> {
|
||||
let digits: String = typed.chars().filter(|c| c.is_ascii_digit()).collect();
|
||||
// The shortest numbers in use are about seven digits and the longest fifteen.
|
||||
(7..=15).contains(&digits.len()).then(|| format!("+{digits}"))
|
||||
}
|
||||
|
||||
/// Starts a sign-in. Answers at once; the rest is events.
|
||||
pub fn start(state: AppState, login_id: String, api_id: i32, api_hash: String, phone: String) {
|
||||
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;
|
||||
waiting().lock().unwrap().remove(&login_id);
|
||||
match outcome {
|
||||
Ok(Some(saved)) => {
|
||||
let account = crate::accounts::telegram_account_to_json(&saved, "connecting");
|
||||
super::connect::spawn(state.clone(), saved);
|
||||
state.events.emit("telegramLoginResult", json!({ "loginId": login_id, "success": true, "account": account }));
|
||||
}
|
||||
// The form was closed; nobody is waiting for an answer.
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
let error = format!("{e:#}");
|
||||
tracing::info!("telegram: sign-in {login_id}: {error}");
|
||||
state.events.emit("telegramLoginResult", json!({ "loginId": login_id, "success": false, "error": error }));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn status(state: &AppState, login_id: &str, text: &str) {
|
||||
state.events.emit("telegramLoginStatus", json!({ "loginId": login_id, "text": text }));
|
||||
}
|
||||
|
||||
fn needs(state: &AppState, login_id: &str, step: &str, hint: Option<&str>, error: Option<&str>) {
|
||||
state.events.emit("telegramLoginNeeds", json!({ "loginId": login_id, "step": step, "hint": hint, "error": error }));
|
||||
}
|
||||
|
||||
/// What was typed, or `None` when the form was closed or nobody answered in time.
|
||||
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>> {
|
||||
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"))?;
|
||||
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 user = match outcome {
|
||||
Ok(Some(user)) => user,
|
||||
other => {
|
||||
link.close().await;
|
||||
session.delete();
|
||||
return other.map(|_| None);
|
||||
}
|
||||
};
|
||||
let config = TelegramAccountConfig {
|
||||
api_id,
|
||||
api_hash: api_hash.trim().to_string(),
|
||||
phone,
|
||||
user_id: user.id().bare_id_unchecked(),
|
||||
username: user.username().map(str::to_string),
|
||||
name: user.full_name(),
|
||||
use_tor: crate::net::route::router().wanted(&crate::net::route::pending_key("telegram")),
|
||||
..Default::default()
|
||||
};
|
||||
// The session is this account's now: under its own name, and written before the connection that made it closes.
|
||||
session.move_to(session::path_for(config.user_id)).context("saving the session")?;
|
||||
link.close().await;
|
||||
let saved = state.accounts.add_telegram(config)?;
|
||||
Ok(Some(saved))
|
||||
}
|
||||
|
||||
/// The conversation with Telegram, up to being signed in.
|
||||
async fn signed_in(state: &AppState, login_id: &str, link: &Link, api_hash: &str, phone: &str, rx: &mut mpsc::UnboundedReceiver<Answer>) -> Result<Option<grammers_client::peer::User>> {
|
||||
let client = &link.client;
|
||||
if client.is_authorized().await.context("asking Telegram whether this session is signed in")? {
|
||||
bail!("this session is already signed in");
|
||||
}
|
||||
status(state, login_id, "Asking Telegram to send a code");
|
||||
let token = client.request_login_code(phone, api_hash).await.map_err(|e| anyhow!("{}", explain(&e.to_string())))?;
|
||||
needs(state, login_id, "code", None, None);
|
||||
loop {
|
||||
let Some(given) = answer(rx).await else { return Ok(None) };
|
||||
match given {
|
||||
Answer::Code(code) => match client.sign_in(&token, &code).await {
|
||||
Ok(user) => return Ok(Some(user)),
|
||||
Err(SignInError::PasswordRequired(password)) => {
|
||||
let hint = password.hint().map(str::to_string);
|
||||
needs(state, login_id, "password", hint.as_deref(), None);
|
||||
return ask_password(state, login_id, client, password, rx).await;
|
||||
}
|
||||
Err(SignInError::InvalidCode) => needs(state, login_id, "code", None, Some("That code is not right. Check it and try again")),
|
||||
Err(SignInError::SignUpRequired) => bail!("this number has no Telegram account. Make one with Telegram's own app first"),
|
||||
Err(e) => bail!("{}", explain(&e.to_string())),
|
||||
},
|
||||
Answer::Password(_) => needs(state, login_id, "code", None, Some("Telegram has not asked for a password")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) };
|
||||
let Answer::Password(password) = given else { continue };
|
||||
match client.check_password(token, password.as_bytes()).await {
|
||||
Ok(user) => return Ok(Some(user)),
|
||||
Err(SignInError::InvalidPassword(again)) => {
|
||||
needs(state, login_id, "password", again.hint(), Some("That password is not right"));
|
||||
token = again;
|
||||
}
|
||||
Err(e) => bail!("{}", explain(&e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Telegram's own wording for what went wrong, said so a person can act on it.
|
||||
pub fn explain(raw: &str) -> String {
|
||||
let upper = raw.to_uppercase();
|
||||
for (code, words) in [
|
||||
("API_ID_INVALID", "Telegram does not accept that api_id and api_hash pair. Check them at my.telegram.org/apps"),
|
||||
("PHONE_NUMBER_INVALID", "Telegram says that is not a valid phone number"),
|
||||
("PHONE_NUMBER_BANNED", "Telegram has banned that phone number"),
|
||||
("PHONE_CODE_EXPIRED", "That code has expired. Start again to get a new one"),
|
||||
("PHONE_CODE_INVALID", "That code is not right"),
|
||||
("PASSWORD_HASH_INVALID", "That password is not right"),
|
||||
("FLOOD_WAIT", "Telegram says too many attempts were made. Wait before trying again"),
|
||||
("SESSION_PASSWORD_NEEDED", "This account has a cloud password"),
|
||||
("AUTH_KEY_UNREGISTERED", "Telegram has ended this session"),
|
||||
("USER_DEACTIVATED", "Telegram says this account has been deleted"),
|
||||
] {
|
||||
if upper.contains(code) {
|
||||
return words.to_string();
|
||||
}
|
||||
}
|
||||
raw.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_phone_number_is_made_the_way_telegram_wants_it() {
|
||||
assert_eq!(normalize_phone("+1 (555) 123-4567").as_deref(), Some("+15551234567"));
|
||||
assert_eq!(normalize_phone("44 20 7946 0958").as_deref(), Some("+442079460958"));
|
||||
assert_eq!(normalize_phone("12345"), None);
|
||||
assert_eq!(normalize_phone("not a number"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answers_for_a_sign_in_that_is_not_waiting_are_refused_in_words() {
|
||||
assert!(submit_code("telegram-none", "12345").unwrap_err().to_string().contains("not waiting"));
|
||||
assert!(submit_code("telegram-none", "abc").unwrap_err().to_string().contains("type the code"));
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
waiting().lock().unwrap().insert("telegram-t".into(), tx);
|
||||
submit_code("telegram-t", "1 2-3 4 5").unwrap();
|
||||
submit_password("telegram-t", "hunter2").unwrap();
|
||||
assert!(matches!(rx.try_recv(), Ok(Answer::Code(c)) if c == "12345"), "spaces and dashes in a pasted code are dropped");
|
||||
assert!(matches!(rx.try_recv(), Ok(Answer::Password(p)) if p == "hunter2"));
|
||||
cancel("telegram-t");
|
||||
assert!(submit_code("telegram-t", "12345").is_err());
|
||||
}
|
||||
|
||||
#[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"));
|
||||
assert!(explain("PHONE_CODE_EXPIRED").contains("expired"));
|
||||
assert_eq!(explain("something unforeseen"), "something unforeseen");
|
||||
}
|
||||
|
||||
/// Against Telegram's own test servers, which have no real accounts: a number `99966XYYYY` signs in with the code
|
||||
/// `XXXXX` (its digit X, five times). Needs the person's `api_id` and `api_hash` (the test servers take a real pair):
|
||||
///
|
||||
/// `TELEGRAM_LIVE_API_ID=... TELEGRAM_LIVE_API_HASH=... TELEGRAM_LIVE_PHONE=+99966XYYYY cargo test --release live_ -- --ignored --nocapture`
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn live_a_test_number_signs_in_and_out_on_telegrams_test_servers() {
|
||||
use grammers_session::types::DcOption;
|
||||
use grammers_session::Session;
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let api_id: i32 = std::env::var("TELEGRAM_LIVE_API_ID").unwrap().parse().unwrap();
|
||||
let api_hash = std::env::var("TELEGRAM_LIVE_API_HASH").unwrap();
|
||||
let phone = normalize_phone(&std::env::var("TELEGRAM_LIVE_PHONE").unwrap()).unwrap();
|
||||
assert!(phone.starts_with("+99966"), "only a test number, which has no real account behind it");
|
||||
let code: String = std::iter::repeat_n(phone.chars().nth(6).unwrap(), 5).collect();
|
||||
|
||||
let state = crate::backend::kick::testkit::simulated_daemon("telegram-live");
|
||||
let path = std::env::temp_dir().join(format!("nobilis-telegram-live-{}.session", std::process::id()));
|
||||
let session = Arc::new(FileSession::fresh(path.clone()));
|
||||
// Telegram's test datacenter 2 stands where the real one does.
|
||||
let mut dc: DcOption = session.dc_option(2).unwrap().unwrap();
|
||||
dc.ipv4 = "149.154.167.40:443".parse().unwrap();
|
||||
dc.ipv6 = "[::ffff:149.154.167.40]:443".parse().unwrap();
|
||||
session.set_dc_option(&dc).await.unwrap();
|
||||
session.set_home_dc_id(2).await.unwrap();
|
||||
|
||||
let link = link::open(&state, "telegram:live-test", api_id, session.clone()).await.unwrap();
|
||||
assert!(!link.client.is_authorized().await.unwrap());
|
||||
let token = link.client.request_login_code(&phone, &api_hash).await.unwrap();
|
||||
let user = match link.client.sign_in(&token, &code).await {
|
||||
Ok(user) => user,
|
||||
Err(e) => panic!("{}", explain(&e.to_string())),
|
||||
};
|
||||
println!("signed in as {} ({})", user.full_name(), user.id().bare_id_unchecked());
|
||||
assert!(link.client.is_authorized().await.unwrap());
|
||||
assert!(session.has_key());
|
||||
session.save().unwrap();
|
||||
let reopened = FileSession::open(path.clone()).unwrap();
|
||||
assert!(reopened.has_key(), "the saved session still has its key");
|
||||
link.client.sign_out().await.unwrap();
|
||||
link.close().await;
|
||||
std::fs::remove_file(path).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//! What the message box holds, as Telegram reads it: plain text and a list of entities.
|
||||
//!
|
||||
//! The window writes formatting as Markdown, the way Discord reads it (`**bold**`, `*italic*`, `__underline__`,
|
||||
//! `~~strike~~`, `||spoiler||`, `` `code` ``, fenced code, `> quote`, `[text](url)`), and Telegram wants the text with
|
||||
//! the marks taken out and a list saying where each kind starts and how long it runs, counted in UTF-16 code units.
|
||||
//! A mark with no partner is the character it looks like.
|
||||
|
||||
use grammers_client::tl;
|
||||
|
||||
#[derive(Debug, PartialEq, Clone, Copy)]
|
||||
enum Kind {
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
Strike,
|
||||
Spoiler,
|
||||
Code,
|
||||
Pre,
|
||||
Quote,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Span {
|
||||
kind: Kind,
|
||||
offset: usize,
|
||||
length: usize,
|
||||
/// For a link, where it goes.
|
||||
url: Option<String>,
|
||||
/// For a fenced block, its language.
|
||||
language: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Out {
|
||||
text: String,
|
||||
units: usize,
|
||||
spans: Vec<Span>,
|
||||
}
|
||||
|
||||
impl Out {
|
||||
fn push(&mut self, c: char) {
|
||||
self.text.push(c);
|
||||
self.units += c.len_utf16();
|
||||
}
|
||||
fn push_str(&mut self, s: &str) {
|
||||
for c in s.chars() {
|
||||
self.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The text with its marks taken out, and the entities that say what they marked.
|
||||
pub fn parse(source: &str) -> (String, Vec<tl::enums::MessageEntity>) {
|
||||
let mut out = Out::default();
|
||||
let lines: Vec<&str> = source.split('\n').collect();
|
||||
let mut i = 0;
|
||||
let mut first = true;
|
||||
while i < lines.len() {
|
||||
if !first {
|
||||
out.push('\n');
|
||||
}
|
||||
first = false;
|
||||
let line = lines[i];
|
||||
if let Some(fence) = line.strip_prefix("```") {
|
||||
// A fenced block runs to the next fence, or to the end.
|
||||
if let Some(end) = lines[i + 1..].iter().position(|l| l.trim_end() == "```") {
|
||||
let language = fence.trim().to_string();
|
||||
let body = lines[i + 1..i + 1 + end].join("\n");
|
||||
let offset = out.units;
|
||||
out.push_str(&body);
|
||||
out.spans.push(Span { kind: Kind::Pre, offset, length: out.units - offset, url: None, language: (!language.is_empty()).then_some(language) });
|
||||
i += end + 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if line.starts_with("> ") || line == ">" {
|
||||
// Consecutive quoted lines are one quote.
|
||||
let start = out.units;
|
||||
let mut quoted = vec![line.strip_prefix("> ").unwrap_or("")];
|
||||
while i + 1 < lines.len() && (lines[i + 1].starts_with("> ") || lines[i + 1] == ">") {
|
||||
i += 1;
|
||||
quoted.push(lines[i].strip_prefix("> ").unwrap_or(""));
|
||||
}
|
||||
for (n, q) in quoted.iter().enumerate() {
|
||||
if n > 0 {
|
||||
out.push('\n');
|
||||
}
|
||||
inline(&q.chars().collect::<Vec<_>>(), &mut out);
|
||||
}
|
||||
out.spans.push(Span { kind: Kind::Quote, offset: start, length: out.units - start, url: None, language: None });
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
inline(&line.chars().collect::<Vec<_>>(), &mut out);
|
||||
i += 1;
|
||||
}
|
||||
let entities = out.spans.iter().filter(|s| s.length > 0).map(entity).collect();
|
||||
(out.text, entities)
|
||||
}
|
||||
|
||||
fn entity(s: &Span) -> tl::enums::MessageEntity {
|
||||
use tl::enums::MessageEntity as E;
|
||||
use tl::types as t;
|
||||
let (offset, length) = (s.offset as i32, s.length as i32);
|
||||
if let Some(url) = &s.url {
|
||||
return E::TextUrl(t::MessageEntityTextUrl { offset, length, url: url.clone() });
|
||||
}
|
||||
match s.kind {
|
||||
Kind::Bold => E::Bold(t::MessageEntityBold { offset, length }),
|
||||
Kind::Italic => E::Italic(t::MessageEntityItalic { offset, length }),
|
||||
Kind::Underline => E::Underline(t::MessageEntityUnderline { offset, length }),
|
||||
Kind::Strike => E::Strike(t::MessageEntityStrike { offset, length }),
|
||||
Kind::Spoiler => E::Spoiler(t::MessageEntitySpoiler { offset, length }),
|
||||
Kind::Code => E::Code(t::MessageEntityCode { offset, length }),
|
||||
Kind::Pre => E::Pre(t::MessageEntityPre { offset, length, language: s.language.clone().unwrap_or_default() }),
|
||||
Kind::Quote => E::Blockquote(t::MessageEntityBlockquote { collapsed: false, offset, length }),
|
||||
}
|
||||
}
|
||||
|
||||
fn find(chars: &[char], from: usize, marker: &[char]) -> Option<usize> {
|
||||
(from..chars.len().saturating_sub(marker.len() - 1)).find(|&j| chars[j..].starts_with(marker))
|
||||
}
|
||||
|
||||
/// A single `*` or `_` only opens at the start of a word and closes at the end of one, so `snake_case_name` and `2*3*4`
|
||||
/// stay as written.
|
||||
fn word_edge(chars: &[char], at: usize, opening: bool) -> bool {
|
||||
let alnum = |c: char| c.is_alphanumeric();
|
||||
if opening {
|
||||
(at == 0 || !alnum(chars[at - 1])) && chars.get(at + 1).is_some_and(|c| !c.is_whitespace())
|
||||
} else {
|
||||
at > 0 && !chars[at - 1].is_whitespace() && chars.get(at + 1).is_none_or(|c| !alnum(*c))
|
||||
}
|
||||
}
|
||||
|
||||
fn inline(chars: &[char], out: &mut Out) {
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
let c = chars[i];
|
||||
if c == '\\' && i + 1 < chars.len() && "\\*_~|`[]()>".contains(chars[i + 1]) {
|
||||
out.push(chars[i + 1]);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
// Code takes its contents as they are.
|
||||
if c == '`' {
|
||||
if let Some(end) = find(chars, i + 1, &['`']).filter(|&e| e > i + 1) {
|
||||
let offset = out.units;
|
||||
out.push_str(&chars[i + 1..end].iter().collect::<String>());
|
||||
out.spans.push(Span { kind: Kind::Code, offset, length: out.units - offset, url: None, language: None });
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// [text](url)
|
||||
if c == '[' {
|
||||
if let Some(close) = find(chars, i + 1, &[']']) {
|
||||
if chars.get(close + 1) == Some(&'(') {
|
||||
if let Some(end) = find(chars, close + 2, &[')']) {
|
||||
let url: String = chars[close + 2..end].iter().collect();
|
||||
if ["http://", "https://", "tg://", "mailto:"].iter().any(|p| url.starts_with(p)) && close > i + 1 {
|
||||
let offset = out.units;
|
||||
inline(&chars[i + 1..close], out);
|
||||
out.spans.push(Span { kind: Kind::Bold, offset, length: out.units - offset, url: Some(url), language: None });
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let two = |m: &str| m.chars().collect::<Vec<_>>();
|
||||
let pairs: [(&str, Kind); 4] = [("**", Kind::Bold), ("__", Kind::Underline), ("~~", Kind::Strike), ("||", Kind::Spoiler)];
|
||||
let mut done = false;
|
||||
for (marker, kind) in pairs {
|
||||
let m = two(marker);
|
||||
if chars[i..].starts_with(&m) {
|
||||
if let Some(end) = find(chars, i + 2, &m).filter(|&e| e > i + 2) {
|
||||
let offset = out.units;
|
||||
inline(&chars[i + 2..end], out);
|
||||
out.spans.push(Span { kind, offset, length: out.units - offset, url: None, language: None });
|
||||
i = end + 2;
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if done {
|
||||
continue;
|
||||
}
|
||||
if (c == '*' || c == '_') && word_edge(chars, i, true) {
|
||||
// The closing one: the next of the same mark that ends a word.
|
||||
let mut j = i + 1;
|
||||
let mut found = None;
|
||||
while let Some(e) = find(chars, j, &[c]) {
|
||||
if e > i + 1 && word_edge(chars, e, false) {
|
||||
found = Some(e);
|
||||
break;
|
||||
}
|
||||
j = e + 1;
|
||||
}
|
||||
if let Some(end) = found {
|
||||
let offset = out.units;
|
||||
inline(&chars[i + 1..end], out);
|
||||
out.spans.push(Span { kind: Kind::Italic, offset, length: out.units - offset, url: None, language: None });
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tl::enums::MessageEntity as E;
|
||||
|
||||
fn kinds(entities: &[E]) -> Vec<(&'static str, i32, i32)> {
|
||||
entities
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
E::Bold(x) => ("bold", x.offset, x.length),
|
||||
E::Italic(x) => ("italic", x.offset, x.length),
|
||||
E::Underline(x) => ("underline", x.offset, x.length),
|
||||
E::Strike(x) => ("strike", x.offset, x.length),
|
||||
E::Spoiler(x) => ("spoiler", x.offset, x.length),
|
||||
E::Code(x) => ("code", x.offset, x.length),
|
||||
E::Pre(x) => ("pre", x.offset, x.length),
|
||||
E::Blockquote(x) => ("quote", x.offset, x.length),
|
||||
E::TextUrl(x) => ("link", x.offset, x.length),
|
||||
_ => ("other", 0, 0),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marks_are_taken_out_and_the_entities_say_where_they_were() {
|
||||
let (text, entities) = parse("a **bold** and *italic* and ~~gone~~ and ||secret|| and __under__");
|
||||
assert_eq!(text, "a bold and italic and gone and secret and under");
|
||||
let got = kinds(&entities);
|
||||
assert!(got.contains(&("bold", 2, 4)) && got.contains(&("italic", 11, 6)) && got.contains(&("strike", 22, 4)) && got.contains(&("spoiler", 31, 6)) && got.contains(&("underline", 42, 5)), "{got:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marks_nest_and_a_mark_with_no_partner_is_just_the_character() {
|
||||
let (text, entities) = parse("**a *b* c**");
|
||||
assert_eq!(text, "a b c");
|
||||
let got = kinds(&entities);
|
||||
assert!(got.contains(&("bold", 0, 5)) && got.contains(&("italic", 2, 1)), "{got:?}");
|
||||
let (text, entities) = parse("2 * 3 and snake_case_name and **unclosed");
|
||||
assert_eq!(text, "2 * 3 and snake_case_name and **unclosed");
|
||||
assert!(entities.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_is_taken_as_written_and_a_fence_is_a_block_with_its_language() {
|
||||
let (text, entities) = parse("see `**not bold**` ok");
|
||||
assert_eq!(text, "see **not bold** ok");
|
||||
assert_eq!(kinds(&entities), vec![("code", 4, 12)]);
|
||||
let (text, entities) = parse("```rust\nlet x = 1;\n```\nafter");
|
||||
assert_eq!(text, "let x = 1;\nafter");
|
||||
assert!(matches!(&entities[0], E::Pre(p) if p.language == "rust" && p.offset == 0 && p.length == 10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_and_a_quote_and_an_escape() {
|
||||
let (text, entities) = parse("[here](https://example.com/a?b=1) \\*not\\*");
|
||||
assert_eq!(text, "here *not*");
|
||||
assert!(matches!(&entities[0], E::TextUrl(u) if u.url == "https://example.com/a?b=1" && u.offset == 0 && u.length == 4));
|
||||
let (text, entities) = parse("> one\n> two\nthree");
|
||||
assert_eq!(text, "one\ntwo\nthree");
|
||||
assert_eq!(kinds(&entities), vec![("quote", 0, 7)]);
|
||||
// A link to somewhere that is not a web address is left as it was typed.
|
||||
assert_eq!(parse("[x](javascript:alert(1))").0, "[x](javascript:alert(1))");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offsets_count_utf16_units_so_an_emoji_before_a_mark_moves_it_by_two() {
|
||||
let (text, entities) = parse("😀 **hi**");
|
||||
assert_eq!(text, "😀 hi");
|
||||
assert_eq!(kinds(&entities), vec![("bold", 3, 2)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
//! Pictures, video, files, voice and stickers: what a message carries, and fetching it.
|
||||
//!
|
||||
//! Nothing is fetched until it is looked at. A message is recorded with attachments that name files in the daemon's
|
||||
//! caches (`telegram-media`, `telegram-avatars`) - files that need not exist yet - and when the window asks for one
|
||||
//! that is not there, the "restore a cached picture" route it already has for every other service asks the daemon,
|
||||
//! which finds the file's message again (a file reference expires, so the one remembered would not do), downloads it
|
||||
//! and answers. So opening a channel of fifty posts fetches the pictures on screen, not fifty videos.
|
||||
//!
|
||||
//! A cached file is named for what it is: `tg_<account>_<conversation>_<message>_<thumb|full>.<ext>` for a message's
|
||||
//! media, `tg_<account>_<person>_<photo>.jpg` for a profile picture.
|
||||
|
||||
use super::peers;
|
||||
use super::Handle;
|
||||
use crate::model::{Attachment, Embed};
|
||||
use crate::state::AppState;
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use grammers_client::media::{Document, Downloadable, Media, PhotoSize};
|
||||
use grammers_client::message::Message;
|
||||
use grammers_client::tl;
|
||||
use grammers_session::types::PeerId;
|
||||
use grammers_session::Session;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const MEDIA: &str = "telegram-media";
|
||||
pub const AVATARS: &str = "telegram-avatars";
|
||||
|
||||
/// How many downloads run at once: enough for a screenful of pictures, few enough not to look like a scraper.
|
||||
const AT_ONCE: usize = 4;
|
||||
|
||||
/// How many whole files (videos, documents) are fetched at once. They take minutes, and pictures must not wait behind
|
||||
/// them: those have places of their own.
|
||||
const BIG_AT_ONCE: usize = 2;
|
||||
|
||||
fn permits(big: bool) -> &'static tokio::sync::Semaphore {
|
||||
static P: std::sync::OnceLock<tokio::sync::Semaphore> = std::sync::OnceLock::new();
|
||||
static BIG: std::sync::OnceLock<tokio::sync::Semaphore> = std::sync::OnceLock::new();
|
||||
if big {
|
||||
BIG.get_or_init(|| tokio::sync::Semaphore::new(BIG_AT_ONCE))
|
||||
} else {
|
||||
P.get_or_init(|| tokio::sync::Semaphore::new(AT_ONCE))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Variant {
|
||||
/// A small picture to draw in the list.
|
||||
Thumb,
|
||||
/// The picture at the size the viewer shows, or the file itself.
|
||||
Full,
|
||||
}
|
||||
|
||||
impl Variant {
|
||||
fn word(self) -> &'static str {
|
||||
match self {
|
||||
Variant::Thumb => "thumb",
|
||||
Variant::Full => "full",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A conversation's id as a piece of a file name: a group's `-` would be taken for a separator.
|
||||
fn slug(remote: &str) -> String {
|
||||
remote.replace('-', "n")
|
||||
}
|
||||
|
||||
fn unslug(text: &str) -> String {
|
||||
match text.strip_prefix('n') {
|
||||
Some(rest) => format!("-{rest}"),
|
||||
None => text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn media_name(account: i64, remote: &str, message: i32, variant: Variant, ext: &str) -> String {
|
||||
format!("tg_{account}_{}_{message}_{}.{ext}", slug(remote), variant.word())
|
||||
}
|
||||
|
||||
pub fn avatar_name(account: i64, remote: &str, photo: i64) -> String {
|
||||
format!("tg_{account}_{}_{photo}.jpg", slug(remote))
|
||||
}
|
||||
|
||||
/// What a cached media file's name says: the account, the conversation, the message and which version of it.
|
||||
pub fn parse_media_name(name: &str) -> Option<(i64, String, i32, Variant)> {
|
||||
let stem = name.rsplit_once('.')?.0;
|
||||
let mut parts = stem.strip_prefix("tg_")?.split('_');
|
||||
let account = parts.next()?.parse().ok()?;
|
||||
let remote = unslug(parts.next()?);
|
||||
let message = parts.next()?.parse().ok()?;
|
||||
let variant = match parts.next()? {
|
||||
"thumb" => Variant::Thumb,
|
||||
"full" => Variant::Full,
|
||||
_ => return None,
|
||||
};
|
||||
parts.next().is_none().then_some((account, remote, message, variant))
|
||||
}
|
||||
|
||||
/// What a cached avatar's name says: the account, the person or chat, and which picture of theirs.
|
||||
pub fn parse_avatar_name(name: &str) -> Option<(i64, String, i64)> {
|
||||
let stem = name.strip_suffix(".jpg")?;
|
||||
let mut parts = stem.strip_prefix("tg_")?.split('_');
|
||||
let account = parts.next()?.parse().ok()?;
|
||||
let remote = unslug(parts.next()?);
|
||||
let photo = parts.next()?.parse().ok()?;
|
||||
parts.next().is_none().then_some((account, remote, photo))
|
||||
}
|
||||
|
||||
fn local(dir: &str, name: &str) -> String {
|
||||
format!("file://{}", crate::media_cache::dir(dir).join(name).display())
|
||||
}
|
||||
|
||||
/// The file's extension, from its name or else its type.
|
||||
pub fn extension_of(name: Option<&str>, mime: Option<&str>) -> String {
|
||||
if let Some(ext) = name.and_then(|n| n.rsplit_once('.')).map(|(_, e)| e.to_ascii_lowercase()).filter(|e| !e.is_empty() && e.len() <= 8 && e.chars().all(|c| c.is_ascii_alphanumeric())) {
|
||||
return ext;
|
||||
}
|
||||
match mime.unwrap_or("") {
|
||||
"image/jpeg" => "jpg",
|
||||
"image/png" => "png",
|
||||
"image/webp" => "webp",
|
||||
"image/gif" => "gif",
|
||||
"video/mp4" => "mp4",
|
||||
"video/webm" => "webm",
|
||||
"video/quicktime" => "mov",
|
||||
"audio/ogg" | "audio/opus" => "ogg",
|
||||
"audio/mpeg" => "mp3",
|
||||
"audio/mp4" | "audio/m4a" => "m4a",
|
||||
"application/pdf" => "pdf",
|
||||
"application/zip" => "zip",
|
||||
"application/x-tgsticker" => "tgs",
|
||||
"text/plain" => "txt",
|
||||
_ => "bin",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// The broad shape of a document, which decides how the window draws it.
|
||||
fn kind_of_mime(mime: &str) -> &'static str {
|
||||
if mime.starts_with("image/") && mime != "image/gif" {
|
||||
"image"
|
||||
} else if mime.starts_with("video/") || mime == "image/gif" {
|
||||
"video"
|
||||
} else if mime.starts_with("audio/") {
|
||||
"audio"
|
||||
} else {
|
||||
"file"
|
||||
}
|
||||
}
|
||||
|
||||
fn dimensions(size: &PhotoSize) -> Option<(i32, i32)> {
|
||||
match size {
|
||||
PhotoSize::Size(s) => Some((s.width, s.height)),
|
||||
PhotoSize::Cached(s) => Some((s.width, s.height)),
|
||||
PhotoSize::Progressive(s) => Some((s.width, s.height)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The version of a photo to draw in the list (about 320 wide) and the one to show in the viewer (about 1280).
|
||||
pub(super) fn pick(sizes: &[PhotoSize], target: i32) -> Option<&PhotoSize> {
|
||||
let sized: Vec<(&PhotoSize, i32)> = sizes.iter().filter_map(|s| dimensions(s).map(|(w, h)| (s, w.max(h)))).collect();
|
||||
choose(&sized, target).copied()
|
||||
}
|
||||
|
||||
/// The smallest that reaches the target, else the largest there is.
|
||||
fn choose<T>(items: &[(T, i32)], target: i32) -> Option<&T> {
|
||||
let mut by_size: Vec<&(T, i32)> = items.iter().collect();
|
||||
by_size.sort_by_key(|(_, longest)| *longest);
|
||||
by_size.iter().find(|(_, longest)| *longest >= target).or(by_size.last()).map(|(item, _)| item)
|
||||
}
|
||||
|
||||
/// The width a link card's picture is fetched at: it is drawn larger than a list thumbnail.
|
||||
const CARD: i32 = 800;
|
||||
|
||||
/// The picture of the web page a message links to, if the page has one.
|
||||
fn page_photo(media: &Media) -> Option<grammers_client::media::Photo> {
|
||||
let Media::WebPage(page) = media else { return None };
|
||||
let tl::enums::WebPage::Page(page) = &page.raw.webpage else { return None };
|
||||
page.photo.clone().map(grammers_client::media::Photo::from_raw)
|
||||
}
|
||||
|
||||
/// The link preview a message carries (what Telegram calls a web page, with or without an Instant View): a card with
|
||||
/// the site, title, description and picture. Its picture is named in the cache and fetched like any preview.
|
||||
pub fn embeds_of(account: i64, remote: &str, message: &Message) -> Vec<Embed> {
|
||||
let Some(media) = message.media() else { return Vec::new() };
|
||||
let Media::WebPage(page) = &media else { return Vec::new() };
|
||||
let tl::enums::WebPage::Page(page) = &page.raw.webpage else { return Vec::new() };
|
||||
let has_picture = page_photo(&media).is_some();
|
||||
if page.title.is_none() && page.description.is_none() && !has_picture {
|
||||
return Vec::new();
|
||||
}
|
||||
let non_empty = |t: &Option<String>| t.clone().filter(|t| !t.trim().is_empty());
|
||||
vec![Embed {
|
||||
title: non_empty(&page.title),
|
||||
description: non_empty(&page.description),
|
||||
url: Some(page.url.clone()),
|
||||
provider: non_empty(&page.site_name),
|
||||
author: non_empty(&page.author),
|
||||
image_url: has_picture.then(|| local(MEDIA, &media_name(account, remote, message.id(), Variant::Thumb, "jpg"))),
|
||||
// A page Telegram has made an article of opens as one: the card says so, as Telegram's own does.
|
||||
kind: page.cached_page.is_some().then(|| "instant".to_string()),
|
||||
cta: page.cached_page.is_some().then(|| crate::model::EmbedLink { label: "Instant View".to_string(), url: None }),
|
||||
..Default::default()
|
||||
}]
|
||||
}
|
||||
|
||||
/// What the message carries, as attachments naming files in the cache. Nothing is downloaded here.
|
||||
pub fn attachments_of(account: i64, remote: &str, message: &Message) -> Vec<Attachment> {
|
||||
let id = message.id();
|
||||
let Some(media) = message.media() else { return Vec::new() };
|
||||
let thumb = |ext: &str| Some(local(MEDIA, &media_name(account, remote, id, Variant::Thumb, ext)));
|
||||
let full = |ext: &str| Some(local(MEDIA, &media_name(account, remote, id, Variant::Full, ext)));
|
||||
match media {
|
||||
Media::Photo(photo) => {
|
||||
let sizes = photo.thumbs();
|
||||
let view = pick(&sizes, 1280).and_then(dimensions);
|
||||
vec![Attachment {
|
||||
kind: "image".into(),
|
||||
mimetype: Some("image/jpeg".into()),
|
||||
filename: Some(if photo.is_spoiler() { "SPOILER_photo.jpg".into() } else { "photo.jpg".into() }),
|
||||
size: photo.size().map(|s| s as u64),
|
||||
width: view.map(|(w, _)| w as u32),
|
||||
height: view.map(|(_, h)| h as u32),
|
||||
path: full("jpg"),
|
||||
thumbnail_path: thumb("jpg"),
|
||||
..Default::default()
|
||||
}]
|
||||
}
|
||||
Media::Sticker(sticker) => {
|
||||
let document = sticker.document.clone();
|
||||
let mime = document.mime_type().unwrap_or("image/webp").to_string();
|
||||
let still = mime == "image/webp";
|
||||
let (w, h) = document.resolution().unwrap_or((512, 512));
|
||||
vec![Attachment {
|
||||
kind: "image".into(),
|
||||
mimetype: Some(if still { mime.clone() } else { "image/webp".into() }),
|
||||
filename: Some(format!("sticker{}", if sticker.emoji().is_empty() { String::new() } else { format!(" {}", sticker.emoji()) })),
|
||||
size: document.size().map(|s| s as u64),
|
||||
width: Some(w as u32),
|
||||
height: Some(h as u32),
|
||||
// An animated or video sticker is drawn as the still picture Telegram sends with it.
|
||||
path: if still { full(&extension_of(document.name(), Some(&mime))) } else { thumb("jpg") },
|
||||
thumbnail_path: thumb("jpg"),
|
||||
..Default::default()
|
||||
}]
|
||||
}
|
||||
Media::Document(document) => {
|
||||
let mime = document.mime_type().unwrap_or("application/octet-stream").to_string();
|
||||
let name = document.name().map(str::to_string);
|
||||
let ext = extension_of(name.as_deref(), Some(&mime));
|
||||
let voice = is_voice(&document);
|
||||
let mut kind = kind_of_mime(&mime);
|
||||
if document.is_animated() && kind == "image" {
|
||||
kind = "video";
|
||||
}
|
||||
let (w, h) = document.resolution().map(|(w, h)| (Some(w as u32), Some(h as u32))).unwrap_or((None, None));
|
||||
vec![Attachment {
|
||||
kind: kind.into(),
|
||||
mimetype: Some(mime),
|
||||
filename: Some(match (&name, document.is_spoiler()) {
|
||||
(Some(n), true) => format!("SPOILER_{n}"),
|
||||
(Some(n), false) => n.clone(),
|
||||
(None, _) => format!("file.{ext}"),
|
||||
}),
|
||||
size: document.size().map(|s| s as u64),
|
||||
width: w,
|
||||
height: h,
|
||||
duration_secs: document.duration(),
|
||||
voice,
|
||||
path: full(&ext),
|
||||
thumbnail_path: (!document.thumbs().is_empty() && matches!(kind, "video" | "file" | "image")).then(|| local(MEDIA, &media_name(account, remote, id, Variant::Thumb, "jpg"))),
|
||||
..Default::default()
|
||||
}]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a recording was spoken into the app rather than attached as a file.
|
||||
fn is_voice(document: &Document) -> bool {
|
||||
match document.raw.document.as_ref() {
|
||||
Some(tl::enums::Document::Document(d)) => d.attributes.iter().any(|a| matches!(a, tl::enums::DocumentAttribute::Audio(audio) if audio.voice)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads to a file, whole and renamed into place so a half-written one is never taken for a picture.
|
||||
pub(super) async fn download_to<D: Downloadable>(client: &grammers_client::Client, what: &D, path: &Path, big: bool) -> Result<()> {
|
||||
let _permit = permits(big).acquire().await?;
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
// Its own name for the half-written file: two fetches of one picture (a preview being fetched as the message was
|
||||
// recorded, and the window asking for it) must not write into the same one.
|
||||
static SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let part = path.with_extension(format!("{}.part", SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed)));
|
||||
if let Err(e) = client.download_media(what, &part).await {
|
||||
let _ = tokio::fs::remove_file(&part).await;
|
||||
return Err(anyhow!("{}", super::send::explain(&e)));
|
||||
}
|
||||
tokio::fs::rename(&part, path).await.with_context(|| format!("writing {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetches a message's media again and writes the version the name asks for.
|
||||
async fn fetch(handle: &Handle, remote: &str, message: i32, variant: Variant, path: &Path) -> Result<()> {
|
||||
let id = peers::peer_id_of(remote).ok_or_else(|| anyhow!("not a Telegram conversation"))?;
|
||||
let peer = handle.session.peer_ref(id).await.ok().flatten().ok_or_else(|| anyhow!("Telegram has not told this account about that conversation yet"))?;
|
||||
let found = handle.client.get_messages_by_id(peer, &[message]).await.map_err(|e| anyhow!("{}", super::send::explain(&e)))?;
|
||||
let Some(Some(message)) = found.into_iter().next() else { bail!("that message is no longer there") };
|
||||
match message.media().ok_or_else(|| anyhow!("that message has no media"))? {
|
||||
Media::Photo(photo) => {
|
||||
let sizes = photo.thumbs();
|
||||
let wanted = match variant {
|
||||
Variant::Thumb => pick(&sizes, 320),
|
||||
Variant::Full => pick(&sizes, 1280),
|
||||
};
|
||||
match wanted {
|
||||
Some(size) => download_to(&handle.client, size, path, false).await,
|
||||
None => download_to(&handle.client, &photo, path, false).await,
|
||||
}
|
||||
}
|
||||
Media::Sticker(sticker) => match variant {
|
||||
Variant::Full => download_to(&handle.client, &sticker.document, path, false).await,
|
||||
Variant::Thumb => download_thumb(&handle.client, &sticker.document, path).await,
|
||||
},
|
||||
Media::Document(document) => match variant {
|
||||
Variant::Full => download_to(&handle.client, &document, path, true).await,
|
||||
Variant::Thumb => download_thumb(&handle.client, &document, path).await,
|
||||
},
|
||||
media @ Media::WebPage(_) => {
|
||||
let photo = page_photo(&media).ok_or_else(|| anyhow!("that link has no picture"))?;
|
||||
match pick(&photo.thumbs(), CARD) {
|
||||
Some(size) => download_to(&handle.client, size, path, false).await,
|
||||
None => download_to(&handle.client, &photo, path, false).await,
|
||||
}
|
||||
}
|
||||
_ => bail!("that kind of media cannot be fetched"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn download_thumb(client: &grammers_client::Client, document: &Document, path: &Path) -> Result<()> {
|
||||
let sizes = document.thumbs();
|
||||
let size = pick(&sizes, 320).or(sizes.first()).ok_or_else(|| anyhow!("Telegram sent no preview of that"))?;
|
||||
// A tiny inline preview carries its own bytes; the rest are downloaded.
|
||||
if let Some(bytes) = Downloadable::to_data(size) {
|
||||
tokio::fs::write(path, bytes).await?;
|
||||
return Ok(());
|
||||
}
|
||||
download_to(client, size, path, false).await
|
||||
}
|
||||
|
||||
/// The previews being fetched now, so a message read twice at once fetches its preview once.
|
||||
fn fetching() -> &'static std::sync::Mutex<std::collections::HashSet<PathBuf>> {
|
||||
static F: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<PathBuf>>> = std::sync::OnceLock::new();
|
||||
F.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// Fetches the small preview of a message's picture or video in the background, as the message is recorded.
|
||||
///
|
||||
/// Previews are a few kilobytes and are what the list draws, so they are not left to be asked for one by one when the
|
||||
/// window finds them missing (which asks Telegram for the message again each time). The message is in hand here, so
|
||||
/// nothing more is asked. Whole files are still fetched only when looked at.
|
||||
pub fn prefetch_preview(state: &AppState, account_id: &str, account: i64, remote: &str, message: &Message) {
|
||||
let Some(media) = message.media() else { return };
|
||||
let path = crate::media_cache::dir(MEDIA).join(media_name(account, remote, message.id(), Variant::Thumb, "jpg"));
|
||||
if path.exists() || !matches!(media, Media::Photo(_) | Media::Document(_) | Media::Sticker(_) | Media::WebPage(_)) {
|
||||
return;
|
||||
}
|
||||
if !fetching().lock().unwrap_or_else(|e| e.into_inner()).insert(path.clone()) {
|
||||
return;
|
||||
}
|
||||
let Some(handle) = state.runtime.telegram_handle(account_id) else {
|
||||
fetching().lock().unwrap_or_else(|e| e.into_inner()).remove(&path);
|
||||
return;
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
let done = match &media {
|
||||
Media::Photo(photo) => match pick(&photo.thumbs(), 320) {
|
||||
Some(size) => download_to(&handle.client, size, &path, false).await,
|
||||
None => download_to(&handle.client, photo, &path, false).await,
|
||||
},
|
||||
Media::Document(document) => download_thumb(&handle.client, document, &path).await,
|
||||
Media::Sticker(sticker) => download_thumb(&handle.client, &sticker.document, &path).await,
|
||||
Media::WebPage(_) => match page_photo(&media) {
|
||||
Some(photo) => match pick(&photo.thumbs(), CARD) {
|
||||
Some(size) => download_to(&handle.client, size, &path, false).await,
|
||||
None => download_to(&handle.client, &photo, &path, false).await,
|
||||
},
|
||||
None => Ok(()),
|
||||
},
|
||||
_ => Ok(()),
|
||||
};
|
||||
if let Err(e) = done {
|
||||
tracing::debug!("telegram: preview {}: {e:#}", path.display());
|
||||
}
|
||||
fetching().lock().unwrap_or_else(|e| e.into_inner()).remove(&path);
|
||||
});
|
||||
}
|
||||
|
||||
/// Fetches a cached Telegram file again, after it expired, was cleared, or was named before it was ever fetched.
|
||||
pub async fn restore_cached(state: &AppState, path: &Path) -> Result<PathBuf> {
|
||||
let name = path.file_name().and_then(|n| n.to_str()).context("not a cached file")?;
|
||||
let parent = path.parent().and_then(|p| p.file_name()).and_then(|n| n.to_str()).unwrap_or("");
|
||||
if parent == AVATARS {
|
||||
let (account, peer, photo) = parse_avatar_name(name).context("not a cached Telegram picture")?;
|
||||
let handle = state.runtime.telegram_handle(&format!("telegram:{account}")).context("that Telegram account is not connected")?;
|
||||
fetch_avatar(&handle, &peer, photo, path).await?;
|
||||
return Ok(path.to_path_buf());
|
||||
}
|
||||
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 {
|
||||
if path.exists() {
|
||||
return Ok(path.to_path_buf());
|
||||
}
|
||||
if !fetching().lock().unwrap_or_else(|e| e.into_inner()).contains(path) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||
}
|
||||
let handle = state.runtime.telegram_handle(&format!("telegram:{account}")).context("that Telegram account is not connected")?;
|
||||
fetch(&handle, &remote, message, variant, path).await?;
|
||||
Ok(path.to_path_buf())
|
||||
}
|
||||
|
||||
/// The file an avatar is cached as, for a person or chat with a profile photo; `None` for one without.
|
||||
pub fn avatar_url(account: i64, peer: &grammers_client::peer::Peer) -> Option<String> {
|
||||
use grammers_client::peer::Peer;
|
||||
let photo_id = match peer {
|
||||
Peer::User(u) => u.photo().map(|p| p.photo_id),
|
||||
Peer::Group(g) => g.photo().map(|p| p.photo_id),
|
||||
Peer::Channel(c) => c.photo().map(|p| p.photo_id),
|
||||
Peer::Community(_) => None,
|
||||
}?;
|
||||
Some(local(AVATARS, &avatar_name(account, &peers::remote_id(peer.id()), photo_id)))
|
||||
}
|
||||
|
||||
async fn fetch_avatar(handle: &Handle, remote: &str, photo_id: i64, path: &Path) -> Result<()> {
|
||||
let id: PeerId = peers::peer_id_of(remote).ok_or_else(|| anyhow!("not a Telegram conversation"))?;
|
||||
let peer = handle.session.peer_ref(id).await.ok().flatten().ok_or_else(|| anyhow!("Telegram has not told this account about that picture's owner yet"))?;
|
||||
let what = grammers_client::media::ChatPhoto { raw: tl::types::InputPeerPhotoFileLocation { big: false, peer: peer.into(), photo_id }.into() };
|
||||
download_to(&handle.client, &what, path, false).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_cached_files_name_says_what_it_is_and_is_read_back() {
|
||||
let name = media_name(1234567890, "-1001234567890", 21910, Variant::Full, "mp4");
|
||||
assert_eq!(name, "tg_1234567890_n1001234567890_21910_full.mp4");
|
||||
assert_eq!(parse_media_name(&name), Some((1234567890, "-1001234567890".to_string(), 21910, Variant::Full)));
|
||||
let person = media_name(1, "6576171495", 7, Variant::Thumb, "jpg");
|
||||
assert_eq!(parse_media_name(&person), Some((1, "6576171495".to_string(), 7, Variant::Thumb)));
|
||||
assert_eq!(parse_media_name("not_ours.jpg"), None);
|
||||
assert_eq!(parse_media_name("tg_1_n5_2_other.jpg"), None);
|
||||
let avatar = avatar_name(1, "-100123", 99);
|
||||
assert_eq!(parse_avatar_name(&avatar), Some((1, "-100123".to_string(), 99)));
|
||||
assert_eq!(parse_avatar_name("tg_1_n5_2_full.jpg"), None, "a media file is not an avatar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_files_extension_comes_from_its_name_else_its_type() {
|
||||
assert_eq!(extension_of(Some("Report.PDF"), None), "pdf");
|
||||
assert_eq!(extension_of(None, Some("video/mp4")), "mp4");
|
||||
assert_eq!(extension_of(Some("noextension"), Some("audio/ogg")), "ogg");
|
||||
assert_eq!(extension_of(Some("weird.name with spaces"), None), "bin");
|
||||
assert_eq!(kind_of_mime("image/webp"), "image");
|
||||
assert_eq!(kind_of_mime("image/gif"), "video");
|
||||
assert_eq!(kind_of_mime("application/pdf"), "file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_size_to_draw_is_the_smallest_that_reaches_the_target_else_the_largest() {
|
||||
let sizes = vec![("s", 90), ("y", 1280), ("m", 320), ("x", 800)];
|
||||
assert_eq!(choose(&sizes, 320), Some(&"m"));
|
||||
assert_eq!(choose(&sizes, 1280), Some(&"y"));
|
||||
assert_eq!(choose(&sizes, 5000), Some(&"y"));
|
||||
assert_eq!(choose(&sizes, 100), Some(&"m"));
|
||||
assert_eq!(choose::<&str>(&[], 320), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Who is in a group: the list beside the conversation.
|
||||
//!
|
||||
//! Telegram will list the members of a group or a supergroup (a channel's subscribers are for its administrators
|
||||
//! only), two hundred at a time. The first page is read when somebody opens the conversation, with the group's own
|
||||
//! count, and the owner and administrators carry a mark in front of their name.
|
||||
|
||||
use super::media;
|
||||
use super::messages;
|
||||
use super::send::{explain, peer_for};
|
||||
use crate::model::Buffer;
|
||||
use crate::state::AppState;
|
||||
use grammers_client::peer::Role;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// How many members are read: Telegram's own page.
|
||||
const LIMIT: usize = 200;
|
||||
|
||||
/// What stands in front of an owner's or an administrator's name.
|
||||
const OWNER: &str = "👑";
|
||||
const ADMIN: &str = "@";
|
||||
|
||||
/// Reads a group's members and says so, as the list and as how many there are in all.
|
||||
pub async fn refresh(state: &AppState, buffer: &Buffer) -> Result<usize, String> {
|
||||
let (handle, peer) = peer_for(state, buffer).await?;
|
||||
// A person's conversation, a channel somebody only follows, or the account's own: nobody to list.
|
||||
if buffer.kind == "dm" || buffer.category.as_deref() == Some(super::peers::CHANNELS) {
|
||||
return Ok(0);
|
||||
}
|
||||
let mut iter = handle.client.iter_participants(peer);
|
||||
let total = iter.total().await.unwrap_or(0);
|
||||
let mut members: Vec<Value> = Vec::new();
|
||||
while members.len() < LIMIT {
|
||||
let Some(found) = iter.next().await.map_err(|e| explain(&e))? else { break };
|
||||
let (prefix, banned) = match &found.role {
|
||||
Role::Creator(_) => (OWNER, false),
|
||||
Role::Admin(_) => (ADMIN, false),
|
||||
Role::Banned(_) | Role::Left(_) => ("", true),
|
||||
_ => ("", false),
|
||||
};
|
||||
if banned {
|
||||
continue;
|
||||
}
|
||||
let peer = grammers_client::peer::Peer::User(found.user.clone());
|
||||
handle.names.lock().unwrap_or_else(|e| e.into_inner()).insert(found.user.id().bare_id_unchecked(), messages::name_of(&peer));
|
||||
members.push(json!({
|
||||
"nick": messages::name_of(&peer),
|
||||
"userId": found.user.id().bare_id_unchecked().to_string(),
|
||||
"prefix": prefix,
|
||||
"bot": found.user.is_bot(),
|
||||
"avatarUrl": media::avatar_url(handle.own.user_id, &peer),
|
||||
}));
|
||||
}
|
||||
let count = members.len();
|
||||
let list = Value::Array(members);
|
||||
state.runtime.set_presence(&buffer.id, list.clone());
|
||||
state.runtime.set_presence_total(&buffer.id, total.max(count));
|
||||
state.events.emit("presenceChange", json!({ "bufferId": buffer.id, "members": list, "total": total.max(count) }));
|
||||
Ok(count)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Joining and leaving: a public chat by its @name or t.me link, a private one by its invite link, and leaving what
|
||||
//! the account is in.
|
||||
//!
|
||||
//! A chat that was joined arrives as a dialog, so the list is read again afterwards and the window opens the new
|
||||
//! conversation as it does for any service.
|
||||
|
||||
use super::dialogs;
|
||||
use super::peers;
|
||||
use super::send::{explain, peer_for};
|
||||
use crate::model::Buffer;
|
||||
use crate::state::AppState;
|
||||
use grammers_client::peer::Peer;
|
||||
use grammers_client::tl;
|
||||
use grammers_session::types::PeerKind;
|
||||
|
||||
/// What a person typed to name somewhere to join.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Target {
|
||||
/// A public name: a person, a group or a channel.
|
||||
Username(String),
|
||||
/// The secret part of a private invite link.
|
||||
Invite(String),
|
||||
}
|
||||
|
||||
/// Reads `@name`, `name`, `t.me/name`, `https://t.me/+hash`, `t.me/joinchat/hash` and `tg://join?invite=hash`.
|
||||
pub fn parse_target(input: &str) -> Option<Target> {
|
||||
let text = input.trim();
|
||||
if let Some(rest) = text.strip_prefix("tg://join?invite=") {
|
||||
return clean_invite(rest).map(Target::Invite);
|
||||
}
|
||||
let rest = text.strip_prefix("https://").or_else(|| text.strip_prefix("http://")).unwrap_or(text);
|
||||
let path = ["t.me/", "telegram.me/", "telegram.dog/"].iter().find_map(|host| rest.strip_prefix(host));
|
||||
let Some(path) = path else {
|
||||
let name = text.strip_prefix('@').unwrap_or(text);
|
||||
return valid_username(name).then(|| Target::Username(name.to_string()));
|
||||
};
|
||||
let path = path.split(['?', '#']).next().unwrap_or("");
|
||||
let mut parts = path.split('/').filter(|p| !p.is_empty());
|
||||
let first = parts.next()?;
|
||||
if first == "joinchat" {
|
||||
return clean_invite(parts.next()?).map(Target::Invite);
|
||||
}
|
||||
if let Some(hash) = first.strip_prefix('+') {
|
||||
return clean_invite(hash).map(Target::Invite);
|
||||
}
|
||||
valid_username(first).then(|| Target::Username(first.to_string()))
|
||||
}
|
||||
|
||||
fn clean_invite(hash: &str) -> Option<String> {
|
||||
let hash = hash.trim();
|
||||
(!hash.is_empty() && hash.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')).then(|| hash.to_string())
|
||||
}
|
||||
|
||||
/// Telegram's rule: 5 to 32 letters, digits and underscores, starting with a letter.
|
||||
fn valid_username(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 == '_')
|
||||
}
|
||||
|
||||
/// Joins what was named, and makes its conversation.
|
||||
pub async fn join(state: &AppState, account_id: &str, input: &str) -> Result<(), String> {
|
||||
let target = parse_target(input).ok_or_else(|| format!("\"{}\" is not a Telegram @name or t.me link", input.trim()))?;
|
||||
let handle = state.runtime.telegram_handle(account_id).ok_or_else(|| "that Telegram account is not connected".to_string())?;
|
||||
match target {
|
||||
Target::Invite(hash) => {
|
||||
handle.client.invoke(&tl::functions::messages::ImportChatInvite { hash }).await.map_err(|e| 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(),
|
||||
_ => explain(&e),
|
||||
})?;
|
||||
}
|
||||
Target::Username(name) => {
|
||||
let peer = handle.client.resolve_username(&name).await.map_err(|e| explain(&e))?.ok_or_else(|| format!("Nobody on Telegram is called @{name}"))?;
|
||||
match &peer {
|
||||
// A person: the conversation with them is all there is to open.
|
||||
Peer::User(_) => {
|
||||
let buffer = peers::buffer_for(state, account_id, &peer);
|
||||
if let Some(avatar) = super::media::avatar_url(handle.own.user_id, &peer) {
|
||||
state.runtime.set_buffer_avatar(state, &buffer.id, &avatar);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
_ => {
|
||||
let peer_ref = peer.to_ref().await.ok().flatten().ok_or_else(|| "Telegram did not say how to reach that chat".to_string())?;
|
||||
handle.client.join_chat(peer_ref).await.map_err(|e| match &e {
|
||||
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(),
|
||||
_ => explain(&e),
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dialogs::sync(state, account_id, &handle).await.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Leaves a conversation: a channel or supergroup is left, a small group is left, a private chat is cleared.
|
||||
pub async fn leave(state: &AppState, buffer: &Buffer) -> Result<(), String> {
|
||||
let (handle, peer) = peer_for(state, buffer).await?;
|
||||
match peer.id.kind() {
|
||||
PeerKind::Channel => {
|
||||
handle.client.invoke(&tl::functions::channels::LeaveChannel { channel: peer.into() }).await.map_err(|e| explain(&e))?;
|
||||
}
|
||||
PeerKind::Chat => {
|
||||
handle.client.invoke(&tl::functions::messages::DeleteChatUser { revoke_history: false, chat_id: peer.into(), user_id: tl::enums::InputUser::UserSelf }).await.map_err(|e| explain(&e))?;
|
||||
}
|
||||
_ => {
|
||||
// History is deleted from this account's side only; the other person keeps theirs.
|
||||
handle.client.invoke(&tl::functions::messages::DeleteHistory { just_clear: false, revoke: false, peer: peer.into(), max_id: 0, min_date: None, max_date: None }).await.map_err(|e| explain(&e))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn names_and_links_are_read() {
|
||||
let user = |n: &str| Some(Target::Username(n.to_string()));
|
||||
let invite = |h: &str| Some(Target::Invite(h.to_string()));
|
||||
assert_eq!(parse_target("@abcd"), None, "too short for Telegram, so not a name");
|
||||
assert_eq!(parse_target("@disclosetv"), user("disclosetv"));
|
||||
assert_eq!(parse_target("disclosetv"), user("disclosetv"));
|
||||
assert_eq!(parse_target("https://t.me/disclosetv"), user("disclosetv"));
|
||||
assert_eq!(parse_target("t.me/disclosetv/1234?single"), user("disclosetv"));
|
||||
assert_eq!(parse_target("https://t.me/+AbC_d-123"), invite("AbC_d-123"));
|
||||
assert_eq!(parse_target("https://t.me/joinchat/AbC_d-123"), invite("AbC_d-123"));
|
||||
assert_eq!(parse_target("tg://join?invite=AbC_d-123"), invite("AbC_d-123"));
|
||||
assert_eq!(parse_target("two words"), None);
|
||||
assert_eq!(parse_target("1startsdigit"), None);
|
||||
assert_eq!(parse_target(""), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
//! A Telegram message as moho's: who said it, what it says, what it answers, what came with it.
|
||||
//!
|
||||
//! Telegram marks up a message with entities - a kind, an offset and a length, counted in UTF-16 code units - which
|
||||
//! become the message's `html` (bold, italic, underline, strike, code, a quote, a link, a spoiler) beside its plain
|
||||
//! `body`. Media is not drawn until Round 4; a message that carries some says what it is in words, after its caption.
|
||||
|
||||
use super::media;
|
||||
use super::peers;
|
||||
use crate::model::ReplyPreview;
|
||||
use crate::state::AppState;
|
||||
use grammers_client::message::Message;
|
||||
use grammers_client::peer::Peer;
|
||||
use grammers_client::tl;
|
||||
|
||||
/// What the connection knows about the account itself: its name here, which a message it sent is attributed to.
|
||||
#[derive(Clone)]
|
||||
pub struct Own {
|
||||
pub name: String,
|
||||
pub user_id: i64,
|
||||
}
|
||||
|
||||
fn escape(text: &str) -> String {
|
||||
text.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
|
||||
/// A span to mark: where it starts and ends in UTF-16 units, and the tags that open and close it.
|
||||
struct Span {
|
||||
from: usize,
|
||||
to: usize,
|
||||
open: String,
|
||||
close: &'static str,
|
||||
}
|
||||
|
||||
fn span_of(entity: &tl::enums::MessageEntity, text16: &[u16]) -> Option<Span> {
|
||||
use tl::enums::MessageEntity as E;
|
||||
let (offset, length, open, close): (i32, i32, String, &'static str) = match entity {
|
||||
E::Bold(e) => (e.offset, e.length, "<b>".into(), "</b>"),
|
||||
E::Italic(e) => (e.offset, e.length, "<i>".into(), "</i>"),
|
||||
E::Underline(e) => (e.offset, e.length, "<u>".into(), "</u>"),
|
||||
E::Strike(e) => (e.offset, e.length, "<s>".into(), "</s>"),
|
||||
E::Code(e) => (e.offset, e.length, "<code>".into(), "</code>"),
|
||||
E::Pre(e) => (e.offset, e.length, "<pre>".into(), "</pre>"),
|
||||
E::Blockquote(e) => (e.offset, e.length, "<blockquote>".into(), "</blockquote>"),
|
||||
E::Spoiler(e) => (e.offset, e.length, "<span class=\"spoiler\">".into(), "</span>"),
|
||||
E::TextUrl(e) => (e.offset, e.length, format!("<a href=\"{}\">", escape(&e.url)), "</a>"),
|
||||
E::Url(e) => {
|
||||
// The link is its own text.
|
||||
let (from, to) = (e.offset.max(0) as usize, ((e.offset + e.length).max(0) as usize).min(text16.len()));
|
||||
let target = String::from_utf16_lossy(text16.get(from..to)?);
|
||||
let target = if target.contains("://") { target } else { format!("https://{target}") };
|
||||
(e.offset, e.length, format!("<a href=\"{}\">", escape(&target)), "</a>")
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let from = offset.max(0) as usize;
|
||||
let to = ((offset + length).max(0) as usize).min(text16.len());
|
||||
(from < to).then_some(Span { from, to, open, close })
|
||||
}
|
||||
|
||||
/// The message's text with its entities as html, or `None` when it has none worth drawing.
|
||||
pub fn entities_html(text: &str, entities: &[tl::enums::MessageEntity]) -> Option<String> {
|
||||
let text16: Vec<u16> = text.encode_utf16().collect();
|
||||
let mut spans: Vec<Span> = entities.iter().filter_map(|e| span_of(e, &text16)).collect();
|
||||
if spans.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Outer spans open first: by where they start, the longer before the shorter.
|
||||
spans.sort_by(|a, b| a.from.cmp(&b.from).then(b.to.cmp(&a.to)));
|
||||
let mut out = String::new();
|
||||
let mut open: Vec<&Span> = Vec::new();
|
||||
let mut next = 0;
|
||||
let mut position = 0;
|
||||
let mut run = String::new();
|
||||
let flush = |run: &mut String, out: &mut String| {
|
||||
if !run.is_empty() {
|
||||
out.push_str(&escape(run).replace('\n', "<br>"));
|
||||
run.clear();
|
||||
}
|
||||
};
|
||||
let mut units = text16.iter().copied().peekable();
|
||||
while position <= text16.len() {
|
||||
// What ends here closes first, innermost first.
|
||||
while let Some(last) = open.last() {
|
||||
if last.to <= position {
|
||||
flush(&mut run, &mut out);
|
||||
out.push_str(last.close);
|
||||
open.pop();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
while next < spans.len() && spans[next].from <= position {
|
||||
flush(&mut run, &mut out);
|
||||
out.push_str(&spans[next].open);
|
||||
open.push(&spans[next]);
|
||||
next += 1;
|
||||
}
|
||||
let Some(unit) = units.next() else { break };
|
||||
// A character outside the basic plane is two units: take both together.
|
||||
let mut pair = vec![unit];
|
||||
if (0xD800..0xDC00).contains(&unit) {
|
||||
if let Some(low) = units.next() {
|
||||
pair.push(low);
|
||||
position += 1;
|
||||
}
|
||||
}
|
||||
run.push_str(&String::from_utf16_lossy(&pair));
|
||||
position += 1;
|
||||
}
|
||||
flush(&mut run, &mut out);
|
||||
while let Some(last) = open.pop() {
|
||||
out.push_str(last.close);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// What a message with media says it carries, in words.
|
||||
pub fn media_label(media: &tl::enums::MessageMedia) -> Option<String> {
|
||||
use tl::enums::MessageMedia as M;
|
||||
Some(match media {
|
||||
M::Empty | M::Unsupported => return None,
|
||||
// A link's card is the link already in the text.
|
||||
M::WebPage(_) => return None,
|
||||
M::Photo(_) => "[Photo]".to_string(),
|
||||
M::Geo(_) | M::GeoLive(_) => "[Location]".to_string(),
|
||||
M::Venue(v) => format!("[Place: {}]", v.title),
|
||||
M::Contact(c) => format!("[Contact: {} {}]", c.first_name, c.last_name).trim_end().replace(" ]", "]"),
|
||||
M::Poll(p) => format!("[Poll: {}]", poll_question(&p.poll)),
|
||||
M::Dice(d) => format!("[{} {}]", d.emoticon, d.value),
|
||||
M::Document(d) => match &d.document {
|
||||
Some(tl::enums::Document::Document(doc)) => document_label(doc),
|
||||
_ => "[File]".to_string(),
|
||||
},
|
||||
_ => "[Attachment]".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn poll_question(poll: &tl::enums::Poll) -> String {
|
||||
match poll {
|
||||
tl::enums::Poll::Poll(p) => match &p.question {
|
||||
tl::enums::TextWithEntities::Entities(t) => t.text.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn document_label(doc: &tl::types::Document) -> String {
|
||||
use tl::enums::DocumentAttribute as A;
|
||||
let name = doc.attributes.iter().find_map(|a| if let A::Filename(f) = a { Some(f.file_name.clone()) } else { None });
|
||||
for attribute in &doc.attributes {
|
||||
match attribute {
|
||||
A::Sticker(s) => return format!("[Sticker {}]", s.alt).replace("[Sticker ]", "[Sticker]"),
|
||||
A::Audio(a) if a.voice => return "[Voice message]".to_string(),
|
||||
A::Audio(a) => return format!("[Audio: {}]", a.title.clone().or(name.clone()).unwrap_or_else(|| "track".to_string())),
|
||||
A::Video(v) if v.round_message => return "[Video message]".to_string(),
|
||||
A::Video(_) => return format!("[Video{}]", name.map(|n| format!(": {n}")).unwrap_or_default()),
|
||||
A::Animated => return "[GIF]".to_string(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
format!("[File{}]", name.map(|n| format!(": {n}")).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// What a service message says (somebody joined, the title changed), or `None` for those with nothing to say.
|
||||
pub fn service_text(who: &str, action: &tl::enums::MessageAction) -> Option<String> {
|
||||
use tl::enums::MessageAction as A;
|
||||
Some(match action {
|
||||
A::ChatCreate(a) => format!("{who} created the group \"{}\"", a.title),
|
||||
A::ChatEditTitle(a) => format!("{who} changed the title to \"{}\"", a.title),
|
||||
A::ChatEditPhoto(_) => format!("{who} changed the group photo"),
|
||||
A::ChatDeletePhoto => format!("{who} removed the group photo"),
|
||||
A::ChatAddUser(a) => format!("{who} added {} {}", a.users.len(), if a.users.len() == 1 { "person" } else { "people" }),
|
||||
A::ChatDeleteUser(_) => format!("{who} removed somebody from the group"),
|
||||
A::ChatJoinedByLink(_) => format!("{who} joined by invite link"),
|
||||
A::ChatJoinedByRequest => format!("{who} joined after being approved"),
|
||||
A::ChannelCreate(a) => format!("The channel \"{}\" was created", a.title),
|
||||
A::PinMessage => format!("{who} pinned a message"),
|
||||
A::HistoryClear => "The history was cleared".to_string(),
|
||||
A::ScreenshotTaken => format!("{who} took a screenshot"),
|
||||
A::ContactSignUp => format!("{who} joined Telegram"),
|
||||
A::SetMessagesTtl(a) => if a.period == 0 { "Messages no longer disappear".to_string() } else { format!("Messages now disappear after {} seconds", a.period) },
|
||||
A::PhoneCall(a) => format!("{who} {} call", if a.video { "had a video" } else { "had a voice" }),
|
||||
A::TopicCreate(a) => format!("{who} created the topic \"{}\"", a.title),
|
||||
A::Empty => return None,
|
||||
_ => format!("{who} did something Telegram describes in a way moho does not show yet"),
|
||||
})
|
||||
}
|
||||
|
||||
/// A person's name, for a line that says who did something.
|
||||
pub fn name_of(peer: &Peer) -> String {
|
||||
peers::title_of(peer)
|
||||
}
|
||||
|
||||
/// Everything about a message that is words: the caption or text and what came with it. Where it was forwarded from is
|
||||
/// said above it (see `forward_preview`), not in it.
|
||||
pub fn body_of(message: &Message) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
let text = message.text();
|
||||
if !text.is_empty() {
|
||||
parts.push(text.to_string());
|
||||
}
|
||||
if let tl::enums::Message::Message(m) = &message.raw {
|
||||
// A picture, a video, a file, a voice message or a sticker is drawn from its attachment: saying it again in
|
||||
// words (with the file's name) is noise. Everything else with no picture of its own keeps its label.
|
||||
let drawn = matches!(m.media, Some(tl::enums::MessageMedia::Photo(_)) | Some(tl::enums::MessageMedia::Document(_)));
|
||||
if let Some(label) = m.media.as_ref().filter(|_| !drawn).and_then(media_label) {
|
||||
parts.push(label);
|
||||
}
|
||||
}
|
||||
parts.join("\n")
|
||||
}
|
||||
|
||||
/// Records a message in its conversation, as it arrived live or as history. `false` when it was already there.
|
||||
pub fn record(state: &AppState, account_id: &str, own: &Own, message: &Message) -> bool {
|
||||
record_named(state, account_id, own, message, None)
|
||||
}
|
||||
|
||||
/// The same, remembering who the sender is for `names` when it is given.
|
||||
pub fn record_named(state: &AppState, account_id: &str, own: &Own, message: &Message, names: Option<&std::sync::Mutex<std::collections::HashMap<i64, String>>>) -> bool {
|
||||
let Some(peer) = message.peer() else { return false };
|
||||
record_in(state, account_id, own, message, peer, message.sender(), names)
|
||||
}
|
||||
|
||||
/// The same for a message whose conversation the caller found. A message a person sent in a private chat arrives as
|
||||
/// a short update that names no one, so the library cannot say who it is from or where it is: the caller asks
|
||||
/// Telegram, and the other person in a private chat is the sender of everything not sent by the account.
|
||||
pub fn record_in(state: &AppState, account_id: &str, own: &Own, message: &Message, peer: &Peer, sender: Option<&Peer>, names: Option<&std::sync::Mutex<std::collections::HashMap<i64, String>>>) -> bool {
|
||||
let buffer = super::topics::buffer_of_message(state, account_id, peer, &message.raw);
|
||||
let outgoing = message.outgoing();
|
||||
let sender = sender.or_else(|| (!outgoing && matches!(peer, Peer::User(_))).then_some(peer));
|
||||
if let (Some(names), Some(Peer::User(user))) = (names, sender) {
|
||||
names.lock().unwrap_or_else(|e| e.into_inner()).insert(user.id().bare_id_unchecked(), name_of(&Peer::User(user.clone())));
|
||||
}
|
||||
let from = if outgoing {
|
||||
own.name.clone()
|
||||
} else {
|
||||
sender.map(name_of).unwrap_or_else(|| peers::title_of(peer))
|
||||
};
|
||||
let sender_id = if outgoing { Some(own.user_id.to_string()) } else { message.sender_id().map(peers::remote_id) };
|
||||
let ts = message.date().as_second();
|
||||
let id = message.id().to_string();
|
||||
|
||||
if let Some(action) = message.action() {
|
||||
let Some(text) = service_text(&from, action) else { return false };
|
||||
return state.runtime.record_message_at(state, account_id, &buffer.name, &buffer.kind, "Telegram", &text, false, "system", None, Some(id), false, None, Vec::new(), Vec::new(), None, Some(ts), None, None);
|
||||
}
|
||||
|
||||
let topic_only = matches!(&message.raw, tl::enums::Message::Message(m) if m.reply_to.as_ref().is_some_and(is_topic_root));
|
||||
let answered = if topic_only { None } else { reply_preview(state, account_id, &buffer, message) };
|
||||
let forward = message.forward_header().map(|h| forward_preview(state, account_id, &h));
|
||||
// One slot above a message: an answer wins, and says it was forwarded in its text.
|
||||
let (reply, forward_line) = match (answered, forward) {
|
||||
(Some(answer), Some(forwarded)) => (Some(answer), Some(format!("↪ Forwarded from {}", forwarded.from))),
|
||||
(Some(answer), None) => (Some(answer), None),
|
||||
(None, forwarded) => (forwarded, None),
|
||||
};
|
||||
let ask_for_reply = reply.as_ref().filter(|r| r.body.is_empty() && !r.forwarded).map(|r| (r.id.clone(), r.chat.clone()));
|
||||
let has_forward_line = forward_line.is_some();
|
||||
let body = match forward_line {
|
||||
Some(line) => format!("{line}\n{}", body_of(message)),
|
||||
None => body_of(message),
|
||||
};
|
||||
let html = message.fmt_entities().and_then(|e| entities_html(message.text(), e)).filter(|h| !h.is_empty());
|
||||
// The first line of a forward is words of our own, so the html (made from the text alone) would lose it: a
|
||||
// forwarded message is shown as plain text.
|
||||
let html = if has_forward_line { None } else { html };
|
||||
let attachments = buffer.remote_id.as_deref().map(|remote| media::attachments_of(own.user_id, remote, message)).unwrap_or_default();
|
||||
// A post in a channel has no sender but the channel, whose picture it is.
|
||||
let avatar = if outgoing { None } else { sender.or(Some(peer)).and_then(|p| media::avatar_url(own.user_id, p)) };
|
||||
if let Some(remote) = buffer.remote_id.as_deref() {
|
||||
media::prefetch_preview(state, account_id, own.user_id, remote, message);
|
||||
}
|
||||
let again = attachments.clone();
|
||||
let reply_target = ask_for_reply.map(|(reply_id, chat)| {
|
||||
// Where the answered message is: the other conversation if it is one, else this.
|
||||
let remote = chat.and_then(|c| state.runtime.get_buffer(&c)).and_then(|b| b.remote_id).or_else(|| buffer.remote_id.clone());
|
||||
(remote, reply_id)
|
||||
});
|
||||
let cards = buffer.remote_id.as_deref().map(|remote| media::embeds_of(own.user_id, remote, message)).unwrap_or_default();
|
||||
let cards_again = cards.clone();
|
||||
let recorded = state.runtime.record_message_at(state, account_id, &buffer.name, &buffer.kind, &from, &body, false, "chat", reply, Some(id.clone()), message.mentioned(), avatar, cards, attachments, sender_id, Some(ts), html, None);
|
||||
if !recorded && !cards_again.is_empty() {
|
||||
if let Ok(Some(stored)) = state.store.get_message(&buffer.id, &id) {
|
||||
if stored.embeds != cards_again && state.store.set_message_embeds(&buffer.id, &id, &cards_again).unwrap_or(false) {
|
||||
state.events.emit("messageUpdated", serde_json::json!({ "bufferId": buffer.id, "id": id, "body": stored.body, "edited": false, "embeds": cards_again, "attachments": stored.attachments }));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Read again, a message kept with a label for its picture in its text (earlier builds wrote "[Photo]") loses it.
|
||||
if !recorded {
|
||||
if let Ok(Some(stored)) = state.store.get_message(&buffer.id, &id) {
|
||||
let clean = body.trim_end();
|
||||
if stored.body != body && stored.body.starts_with(clean) && stored.body[clean.len()..].trim_start().starts_with('[') && !again.is_empty() {
|
||||
state.runtime.update_message_body_only(state, &buffer.id, &id, &body);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Read again, a message already kept without its picture or file gets it: earlier builds kept the text alone.
|
||||
if !recorded && !again.is_empty() {
|
||||
if let Ok(Some(stored)) = state.store.get_message(&buffer.id, &id) {
|
||||
if stored.attachments.is_empty() && state.store.update_message_attachments(&buffer.id, &id, &again).unwrap_or(false) {
|
||||
state.events.emit("messageUpdated", serde_json::json!({ "bufferId": buffer.id, "id": id, "body": stored.body, "edited": false, "embeds": stored.embeds, "attachments": again }));
|
||||
}
|
||||
}
|
||||
}
|
||||
// The buttons a bot put under it.
|
||||
super::keyboards::note(state, &buffer.id, &id, &message.raw, false);
|
||||
// What a reply answered, when it was not in hand: asked of Telegram, and the row is filled in when it comes.
|
||||
if let Some((Some(remote), reply_id)) = reply_target {
|
||||
fill_reply_later(state, account_id, &buffer.id, &id, &remote, &reply_id);
|
||||
}
|
||||
// A poll that is still open is the conversation's card.
|
||||
if let tl::enums::Message::Message(raw) = &message.raw {
|
||||
if let Some(tl::enums::MessageMedia::Poll(p)) = &raw.media {
|
||||
let (tl::enums::Poll::Poll(poll), tl::enums::PollResults::Results(results)) = (&p.poll, &p.results);
|
||||
super::polls::announce(state, &buffer.id, message.id(), poll, results);
|
||||
}
|
||||
}
|
||||
// What people have reacted with, which a message arrives carrying.
|
||||
if let tl::enums::Message::Message(m) = &message.raw {
|
||||
// How many have answered it, or commented under it if it is a channel's post.
|
||||
if let Some(tl::enums::MessageReplies::Replies(r)) = &m.replies {
|
||||
if r.replies > 0 {
|
||||
state.events.emit("threadCount", serde_json::json!({ "bufferId": buffer.id, "id": id, "count": r.replies }));
|
||||
}
|
||||
}
|
||||
if let Some(reactions) = &m.reactions {
|
||||
let list = super::reactions::from_tl(reactions);
|
||||
if !list.is_empty() {
|
||||
super::reactions::apply(state, &buffer.id, &id, &list);
|
||||
}
|
||||
}
|
||||
}
|
||||
recorded
|
||||
}
|
||||
|
||||
/// Where a forwarded message came from, as the line above it: the name of the source and, when it was a post in a
|
||||
/// channel this account has open, where to find it.
|
||||
fn forward_preview(state: &AppState, account_id: &str, header: &tl::enums::MessageFwdHeader) -> ReplyPreview {
|
||||
let tl::enums::MessageFwdHeader::Header(h) = header;
|
||||
let source = h.from_id.clone().map(|p| peers::remote_id(grammers_session::types::PeerId::from(p)));
|
||||
let origin = source.as_ref().and_then(|remote| state.runtime.list_buffers().into_iter().find(|b| b.account_id == account_id && b.remote_id.as_deref() == Some(remote.as_str())));
|
||||
let from = origin.as_ref().map(|b| b.name.clone()).or_else(|| h.from_name.clone()).or_else(|| h.post_author.clone()).unwrap_or_else(|| "somebody".to_string());
|
||||
// Only a post in a channel can be gone to: a person's forward has no message of theirs to point at.
|
||||
let (chat, id) = match (&origin, h.channel_post) {
|
||||
(Some(b), Some(post)) if b.kind == "channel" => (Some(b.id.clone()), post.to_string()),
|
||||
_ => (None, String::new()),
|
||||
};
|
||||
ReplyPreview { id, from, body: String::new(), thread: false, forwarded: true, thread_root: None, chat }
|
||||
}
|
||||
|
||||
/// What a message answers, as the quote above it: the words quoted (or the message answered), who said them, and - when
|
||||
/// it was said in another conversation - where, so the reader can go there.
|
||||
fn reply_preview(state: &AppState, account_id: &str, buffer: &crate::model::Buffer, message: &Message) -> Option<ReplyPreview> {
|
||||
let reply_id = message.reply_to_message_id()?;
|
||||
let header = match &message.raw {
|
||||
tl::enums::Message::Message(m) => match &m.reply_to {
|
||||
Some(tl::enums::MessageReplyHeader::Header(h)) => Some(h.clone()),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
let elsewhere = header
|
||||
.as_ref()
|
||||
.and_then(|h| h.reply_to_peer_id.clone())
|
||||
.map(|p| peers::remote_id(grammers_session::types::PeerId::from(p)))
|
||||
.filter(|remote| buffer.remote_id.as_deref() != Some(remote.as_str()));
|
||||
let other = elsewhere.as_ref().and_then(|remote| state.runtime.list_buffers().into_iter().find(|b| b.account_id == account_id && b.remote_id.as_deref() == Some(remote.as_str())));
|
||||
let source = other.as_ref().map(|b| b.id.clone()).unwrap_or_else(|| buffer.id.clone());
|
||||
// A message in a conversation this account has not got open is not one to look for here.
|
||||
let stored = if elsewhere.is_some() && other.is_none() { None } else { state.store.get_message(&source, &reply_id.to_string()).ok().flatten() };
|
||||
let quote = header.as_ref().and_then(|h| h.quote_text.clone()).filter(|t| !t.trim().is_empty());
|
||||
let named = header.as_ref().and_then(|h| h.reply_from.as_ref()).and_then(|f| {
|
||||
let tl::enums::MessageFwdHeader::Header(f) = f;
|
||||
f.from_name.clone().or_else(|| f.post_author.clone())
|
||||
});
|
||||
let shown_media = header.as_ref().and_then(|h| h.reply_media.as_ref()).and_then(media_label);
|
||||
let from = other.as_ref().map(|b| b.name.clone()).or(named).or_else(|| stored.as_ref().map(|m| m.from.clone())).unwrap_or_default();
|
||||
let body = quote.or_else(|| stored.map(|m| if m.body.trim().is_empty() { attachment_words(&m.attachments) } else { m.body })).filter(|b| !b.is_empty()).or(shown_media).unwrap_or_default();
|
||||
Some(ReplyPreview { id: reply_id.to_string(), from, body, thread: false, forwarded: false, thread_root: None, chat: other.map(|b| b.id) })
|
||||
}
|
||||
|
||||
/// What a message with only a picture or a file says in a quote, from what it carries.
|
||||
fn attachment_words(attachments: &[crate::model::Attachment]) -> String {
|
||||
let Some(first) = attachments.first() else { return String::new() };
|
||||
match (first.kind.as_str(), first.voice) {
|
||||
(_, true) => "Voice message".to_string(),
|
||||
("image", _) => "Photo".to_string(),
|
||||
("video", _) => "Video".to_string(),
|
||||
("audio", _) => "Audio".to_string(),
|
||||
_ => first.filename.clone().unwrap_or_else(|| "File".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks Telegram what a reply answered and fills the quote in.
|
||||
fn fill_reply_later(state: &AppState, account_id: &str, buffer_id: &str, msg_id: &str, remote: &str, reply_id: &str) {
|
||||
let (Some(handle), Some(id), Ok(reply)) = (state.runtime.telegram_handle(account_id), peers::peer_id_of(remote), reply_id.parse::<i32>()) else { return };
|
||||
let (state, buffer_id, msg_id) = (state.clone(), buffer_id.to_string(), msg_id.to_string());
|
||||
tokio::spawn(async move {
|
||||
use grammers_session::Session;
|
||||
let Some(peer) = handle.session.peer_ref(id).await.ok().flatten() else { return };
|
||||
let Ok(found) = handle.client.get_messages_by_id(peer, &[reply]).await else { return };
|
||||
let Some(Some(original)) = found.into_iter().next() else { return };
|
||||
let body = body_of(&original);
|
||||
let from = original.sender().map(name_of).unwrap_or_else(|| original.peer().map(peers::title_of).unwrap_or_default());
|
||||
if !body.is_empty() {
|
||||
state.runtime.fill_reply_preview(&state, &buffer_id, &msg_id, &from, &body);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// In a group with topics every message "replies" to its topic's first message; that is where it is, not who it
|
||||
/// answers. A real reply names a message and the topic it is in as well.
|
||||
fn is_topic_root(header: &tl::enums::MessageReplyHeader) -> bool {
|
||||
matches!(header, tl::enums::MessageReplyHeader::Header(h) if h.forum_topic && h.reply_to_top_id.is_none())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tl::types as t;
|
||||
|
||||
fn bold(offset: i32, length: i32) -> tl::enums::MessageEntity {
|
||||
tl::enums::MessageEntity::Bold(t::MessageEntityBold { offset, length })
|
||||
}
|
||||
fn italic(offset: i32, length: i32) -> tl::enums::MessageEntity {
|
||||
tl::enums::MessageEntity::Italic(t::MessageEntityItalic { offset, length })
|
||||
}
|
||||
|
||||
fn header(forum_topic: bool, msg: i32, top: Option<i32>) -> tl::enums::MessageReplyHeader {
|
||||
tl::enums::MessageReplyHeader::Header(t::MessageReplyHeader {
|
||||
reply_to_scheduled: false,
|
||||
forum_topic,
|
||||
quote: false,
|
||||
reply_to_ephemeral: false,
|
||||
reply_to_msg_id: Some(msg),
|
||||
reply_to_peer_id: None,
|
||||
reply_from: None,
|
||||
reply_media: None,
|
||||
reply_to_top_id: top,
|
||||
quote_text: None,
|
||||
quote_entities: None,
|
||||
quote_offset: None,
|
||||
todo_item_id: None,
|
||||
poll_option: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_message_in_a_topic_is_not_a_reply_to_the_topic() {
|
||||
assert!(is_topic_root(&header(true, 10, None)));
|
||||
assert!(!is_topic_root(&header(true, 55, Some(10))), "answering a message inside the topic");
|
||||
assert!(!is_topic_root(&header(false, 10, None)), "an ordinary reply");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entities_become_html_and_nest() {
|
||||
assert_eq!(entities_html("hello world", &[bold(0, 5)]).unwrap(), "<b>hello</b> world");
|
||||
assert_eq!(entities_html("a bc d", &[bold(2, 2), italic(2, 1)]).unwrap(), "a <b><i>b</i>c</b> d");
|
||||
assert_eq!(entities_html("plain", &[]), None);
|
||||
// The text is escaped, and a line break is one.
|
||||
assert_eq!(entities_html("a < b\nc", &[bold(0, 1)]).unwrap(), "<b>a</b> < b<br>c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offsets_are_in_utf16_units_so_an_emoji_before_the_text_moves_it_by_two() {
|
||||
// "😀 hi": the emoji is two units, the space one, so "hi" starts at 3.
|
||||
assert_eq!(entities_html("😀 hi", &[bold(3, 2)]).unwrap(), "😀 <b>hi</b>");
|
||||
// A span that runs past the end is cut at it rather than panicking.
|
||||
assert_eq!(entities_html("ab", &[bold(1, 50)]).unwrap(), "a<b>b</b>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_is_its_own_text_or_its_url_and_a_bare_domain_gets_a_scheme() {
|
||||
let url = tl::enums::MessageEntity::Url(t::MessageEntityUrl { offset: 4, length: 11 });
|
||||
assert_eq!(entities_html("see example.com now", &[url]).unwrap(), "see <a href=\"https://example.com\">example.com</a> now");
|
||||
let text_url = tl::enums::MessageEntity::TextUrl(t::MessageEntityTextUrl { offset: 0, length: 4, url: "https://a.example/?q=1&r=2".into() });
|
||||
assert_eq!(entities_html("here", &[text_url]).unwrap(), "<a href=\"https://a.example/?q=1&r=2\">here</a>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_messages_say_who_did_what() {
|
||||
let title = tl::enums::MessageAction::ChatEditTitle(t::MessageActionChatEditTitle { title: "Lunch".into() });
|
||||
assert_eq!(service_text("Ann", &title).as_deref(), Some("Ann changed the title to \"Lunch\""));
|
||||
assert_eq!(service_text("Ann", &tl::enums::MessageAction::PinMessage).as_deref(), Some("Ann pinned a message"));
|
||||
assert_eq!(service_text("Ann", &tl::enums::MessageAction::Empty), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Telegram, over MTProto, in pure Rust (the `grammers` crates).
|
||||
//!
|
||||
//! # How this folder is arranged
|
||||
//!
|
||||
//! Built in rounds, as Twitch was (see Gitea issue #26 for the checklist and what has been verified):
|
||||
//!
|
||||
//! - 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
|
||||
//! - `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
|
||||
//! - `history` - a conversation's history, read when it is opened and scrolled back
|
||||
//! - `markup` - what the message box holds, as plain text and entities
|
||||
//! - `updates` - what Telegram sends that is not a message: typing, reads on other apps
|
||||
//! - `send` - saying things: send, answer, edit, delete, typing, read
|
||||
//! - `media` - pictures, video, files and voice: what a message carries, fetched when it is looked at
|
||||
//! - `reactions` - the emoji on a message, and the account's own
|
||||
//! - `pins` - a conversation's pinned messages
|
||||
//! - `members` - who is in a group
|
||||
//! - `profile` - the card for a person
|
||||
//! - `search` - searching on Telegram's side
|
||||
//! - `polls` - a poll as the conversation's card, voted in from it
|
||||
//! - `threads` - replies in a thread, and a channel post's comments
|
||||
//! - `membership` - joining by @name or link, leaving
|
||||
//! - `admin` - what an administrator may do to a member
|
||||
//! - `notify` - muting a chat on the account
|
||||
//! - `instant` - an Instant View article as a page of its own
|
||||
//! - `keyboards` - a bot's inline buttons
|
||||
//! - `bots` - a bot's command menu
|
||||
//! - `topics` - a group with Topics as several conversations
|
||||
//! - `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
|
||||
//!
|
||||
//! # The api_id
|
||||
//!
|
||||
//! Telegram gives one `api_id` and `api_hash` to a phone number's owner, and a client sends them with every
|
||||
//! connection. Moho does not embed one: each account carries its own, typed into the sign-in form, and they are
|
||||
//! kept with the session like a password. Nothing here logs them.
|
||||
//!
|
||||
//! # What moho tells Telegram about itself
|
||||
//!
|
||||
//! Telegram lists every session of an account (Settings, Devices) with the app and device each one reported, and
|
||||
//! treats a stream of unfamiliar sessions as a risk. So the identity is set once, is the same on every connection,
|
||||
//! and says what this is: the device is "moho" on this operating system, the version is moho's own. A session is
|
||||
//! saved and reused; nothing here signs in afresh to get over a hiccup.
|
||||
|
||||
pub mod admin;
|
||||
pub mod bots;
|
||||
pub mod connect;
|
||||
pub mod dialogs;
|
||||
pub mod history;
|
||||
pub mod instant;
|
||||
pub mod keyboards;
|
||||
pub mod link;
|
||||
pub mod login;
|
||||
pub mod markup;
|
||||
pub mod media;
|
||||
pub mod members;
|
||||
pub mod membership;
|
||||
pub mod messages;
|
||||
pub mod notify;
|
||||
pub mod peers;
|
||||
pub mod pins;
|
||||
pub mod polls;
|
||||
pub mod profile;
|
||||
pub mod reactions;
|
||||
pub mod search;
|
||||
pub mod send;
|
||||
pub mod threads;
|
||||
pub mod topics;
|
||||
pub mod updates;
|
||||
pub mod session;
|
||||
|
||||
use grammers_mtsender::ConnectionParams;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// What the rest of the daemon needs to act on a connected account: its client, its session (to turn a conversation
|
||||
/// into something Telegram will accept, an id with the authority to use it) and its own name.
|
||||
#[derive(Clone)]
|
||||
pub struct Handle {
|
||||
pub client: grammers_client::Client,
|
||||
pub session: Arc<session::FileSession>,
|
||||
pub own: messages::Own,
|
||||
/// What people are called, by Telegram's number for them, so a typing notice can say who. Filled as people are
|
||||
/// met in the conversation list and in messages.
|
||||
pub names: Arc<std::sync::Mutex<std::collections::HashMap<i64, String>>>,
|
||||
}
|
||||
|
||||
/// The name Telegram shows for this app's sessions: `moho` and the kind of machine it runs on.
|
||||
pub fn device_model() -> String {
|
||||
format!("moho ({} {})", std::env::consts::OS, std::env::consts::ARCH)
|
||||
}
|
||||
|
||||
/// How every connection of an account introduces itself.
|
||||
///
|
||||
/// `proxy` is the SOCKS5 address of the account's route (Tor or the proxy in the network settings), if it has one.
|
||||
pub fn connection_params(proxy: Option<&str>) -> ConnectionParams {
|
||||
ConnectionParams {
|
||||
device_model: device_model(),
|
||||
// Moho's own version, which is the daemon's.
|
||||
app_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
proxy_url: proxy.map(str::to_string),
|
||||
..ConnectionParams::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_identity_says_what_this_is_and_is_the_same_each_time() {
|
||||
let a = connection_params(None);
|
||||
let b = connection_params(None);
|
||||
assert!(a.device_model.starts_with("moho ("), "{}", a.device_model);
|
||||
assert_eq!((a.device_model.as_str(), a.app_version.as_str()), (b.device_model.as_str(), b.app_version.as_str()));
|
||||
assert_eq!(a.app_version, env!("CARGO_PKG_VERSION"));
|
||||
assert!(!a.system_version.is_empty() && !a.lang_code.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Muting a conversation on the account itself.
|
||||
//!
|
||||
//! Telegram keeps "mute until" per chat with the account, so a chat silenced on the phone is silent here and the
|
||||
//! other way round. It is read off each dialog and from the update Telegram sends when it changes, and written when
|
||||
//! the window asks.
|
||||
|
||||
use super::peers;
|
||||
use super::send::{explain, peer_for};
|
||||
use crate::model::Buffer;
|
||||
use crate::state::AppState;
|
||||
use grammers_client::tl;
|
||||
use grammers_session::types::PeerId;
|
||||
|
||||
/// "Forever", as Telegram's own apps write it.
|
||||
const FOREVER: i32 = i32::MAX;
|
||||
|
||||
/// Whether notification settings say the chat is silent at `now`.
|
||||
pub fn silent(settings: &tl::enums::PeerNotifySettings, now: i64) -> bool {
|
||||
let tl::enums::PeerNotifySettings::Settings(s) = settings;
|
||||
s.mute_until.is_some_and(|until| i64::from(until) > now)
|
||||
}
|
||||
|
||||
/// A chat's settings changed (on another of the account's apps, or here): the buffer follows.
|
||||
pub fn changed(state: &AppState, account_id: &str, update: &tl::types::UpdateNotifySettings) {
|
||||
let tl::enums::NotifyPeer::Peer(p) = &update.peer else { return };
|
||||
let remote = peers::remote_id(PeerId::from(p.peer.clone()));
|
||||
let Some(buffer) = state.runtime.list_buffers().into_iter().find(|b| b.account_id == account_id && b.remote_id.as_deref() == Some(remote.as_str())) else { return };
|
||||
state.runtime.set_silenced(state, &buffer.id, silent(&update.notify_settings, chrono::Utc::now().timestamp()));
|
||||
}
|
||||
|
||||
/// Mutes a chat, or takes the mute off.
|
||||
pub async fn set_muted(state: &AppState, buffer: &Buffer, muted: bool) -> Result<(), String> {
|
||||
let (handle, peer) = peer_for(state, buffer).await?;
|
||||
let settings = tl::types::InputPeerNotifySettings {
|
||||
show_previews: None,
|
||||
silent: None,
|
||||
mute_until: Some(if muted { FOREVER } else { 0 }),
|
||||
sound: None,
|
||||
stories_muted: None,
|
||||
stories_hide_sender: None,
|
||||
stories_sound: None,
|
||||
};
|
||||
let peer = tl::enums::InputNotifyPeer::Peer(tl::types::InputNotifyPeer { peer: peer.into() });
|
||||
handle.client.invoke(&tl::functions::account::UpdateNotifySettings { peer, settings: settings.into() }).await.map_err(|e| explain(&e))?;
|
||||
state.runtime.set_silenced(state, &buffer.id, muted);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn settings(mute_until: Option<i32>) -> tl::enums::PeerNotifySettings {
|
||||
tl::enums::PeerNotifySettings::Settings(tl::types::PeerNotifySettings {
|
||||
show_previews: None,
|
||||
silent: None,
|
||||
mute_until,
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chat_is_silent_until_its_mute_runs_out() {
|
||||
assert!(silent(&settings(Some(FOREVER)), 1_790_000_000));
|
||||
assert!(silent(&settings(Some(1_790_000_100)), 1_790_000_000));
|
||||
assert!(!silent(&settings(Some(1_789_999_000)), 1_790_000_000));
|
||||
assert!(!silent(&settings(Some(0)), 1_790_000_000));
|
||||
assert!(!silent(&settings(None), 1_790_000_000));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
//! A Telegram conversation as a buffer: its name, its kind, its heading in the list.
|
||||
//!
|
||||
//! People, groups, supergroups, channels and bots are all "peers". A person (or a bot) is a direct message; a group or
|
||||
//! a channel is a channel. The list is headed Direct Messages, Groups and Channels, as Twitch's is headed Channels and
|
||||
//! Direct Messages (see `runtime.rs fixed_category`).
|
||||
//!
|
||||
//! The buffer is named for the conversation, and the buffer's name is what its history is filed under, so it must not
|
||||
//! change when somebody renames themselves: a buffer is found by the peer's own id (Telegram's "bot API dialog id",
|
||||
//! kept as the buffer's `remote_id`), and keeps the name it was first given. Two people with the same name are told
|
||||
//! apart by their `@username`, or failing that by the end of their number.
|
||||
|
||||
use crate::model::Buffer;
|
||||
use crate::state::AppState;
|
||||
use grammers_client::peer::Peer;
|
||||
use grammers_client::tl;
|
||||
use grammers_session::types::PeerId;
|
||||
|
||||
pub const DIRECT_MESSAGES: &str = "Direct Messages";
|
||||
pub const BOTS: &str = "Bots";
|
||||
pub const GROUPS: &str = "Groups";
|
||||
pub const CHANNELS: &str = "Channels";
|
||||
|
||||
/// The id Telegram's own tools (and the bot API) call a peer by: a person's number, minus a group's, and a
|
||||
/// supergroup's or channel's with `-100` in front.
|
||||
pub fn remote_id(peer: PeerId) -> String {
|
||||
peer.bot_api_dialog_id().map(|id| id.to_string()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The peer a buffer's `remote_id` names.
|
||||
pub fn peer_id_of(remote_id: &str) -> Option<PeerId> {
|
||||
// A topic's conversation names its group first and the topic after a colon.
|
||||
PeerId::from_bot_api_dialog_id(remote_id.split(':').next()?.parse().ok()?)
|
||||
}
|
||||
|
||||
pub fn kind_of(peer: &Peer) -> &'static str {
|
||||
match peer {
|
||||
Peer::User(_) => "dm",
|
||||
_ => "channel",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn category_of(peer: &Peer) -> &'static str {
|
||||
match peer {
|
||||
// A bot is a conversation with a program, not a person: its own heading (it is still a direct chat).
|
||||
Peer::User(user) if user.is_bot() => BOTS,
|
||||
Peer::User(_) => DIRECT_MESSAGES,
|
||||
Peer::Channel(_) => CHANNELS,
|
||||
_ => GROUPS,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a conversation is a group (small, or a supergroup): each has an entry of its own on the rail, as a server does.
|
||||
pub fn is_group(peer: &Peer) -> bool {
|
||||
matches!(peer, Peer::Group(_))
|
||||
}
|
||||
|
||||
/// The rail entry of a group.
|
||||
pub fn rail_id(account_id: &str, remote: &str) -> String {
|
||||
format!("{account_id}|tggroup:{remote}")
|
||||
}
|
||||
|
||||
/// Gives a group its place on the rail, with its own name and picture. Answers its rail id; nothing for anything that
|
||||
/// is not a group.
|
||||
pub fn rail_for(state: &AppState, account_id: &str, peer: &Peer, position: i64) -> Option<String> {
|
||||
if !is_group(peer) {
|
||||
return None;
|
||||
}
|
||||
let remote = remote_id(peer.id());
|
||||
let rail = rail_id(account_id, &remote);
|
||||
let account: i64 = account_id.strip_prefix("telegram:").and_then(|a| a.parse().ok()).unwrap_or(0);
|
||||
state.runtime.upsert_buffer_group(
|
||||
state,
|
||||
crate::model::BufferGroup {
|
||||
id: rail.clone(),
|
||||
account_id: account_id.to_string(),
|
||||
service: "telegram".to_string(),
|
||||
kind: "guild".to_string(),
|
||||
name: title_of(peer),
|
||||
icon_url: super::media::avatar_url(account, peer),
|
||||
position,
|
||||
pending: false,
|
||||
},
|
||||
);
|
||||
Some(rail)
|
||||
}
|
||||
|
||||
/// Gives a group its place on the rail and files its conversation under it. Nothing for anything that is not a group:
|
||||
/// people, bots and channels stay in the account's own list.
|
||||
pub fn file_in_rail(state: &AppState, account_id: &str, buffer: &Buffer, peer: &Peer, position: i64) {
|
||||
let Some(rail) = rail_for(state, account_id, peer, position) else { return };
|
||||
state.runtime.set_buffer_group(state, &buffer.id, &rail);
|
||||
state.runtime.set_buffer_category(state, &buffer.id, None, 0);
|
||||
}
|
||||
|
||||
/// The glyph in front of a conversation's name: a horn for a broadcast channel, a robot for a bot.
|
||||
pub fn name_icon_of(peer: &Peer) -> Option<&'static str> {
|
||||
match peer {
|
||||
Peer::Channel(channel) if channel.raw.broadcast => Some("campaign"),
|
||||
Peer::User(user) if user.is_bot() => Some("smart_toy"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the conversation is called: the person's name, the group's title, or "Saved Messages" for the account's own.
|
||||
pub fn title_of(peer: &Peer) -> String {
|
||||
match peer {
|
||||
Peer::User(user) if user.is_self() => "Saved Messages".to_string(),
|
||||
Peer::User(user) => {
|
||||
let name = user.full_name();
|
||||
if !name.trim().is_empty() {
|
||||
name
|
||||
} else if let Some(username) = user.username() {
|
||||
username.to_string()
|
||||
} else {
|
||||
"Deleted account".to_string()
|
||||
}
|
||||
}
|
||||
other => other.name().filter(|n| !n.trim().is_empty()).unwrap_or("Unnamed chat").to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A name for a buffer that no other conversation of the account has.
|
||||
pub fn unique_name(taken: &[(String, Option<String>)], remote_id: &str, title: &str, username: Option<&str>) -> String {
|
||||
let used = |name: &str| taken.iter().any(|(n, r)| n == name && r.as_deref() != Some(remote_id));
|
||||
if !used(title) {
|
||||
return title.to_string();
|
||||
}
|
||||
if let Some(username) = username.filter(|u| !u.is_empty()) {
|
||||
let with = format!("{title} (@{username})");
|
||||
if !used(&with) {
|
||||
return with;
|
||||
}
|
||||
}
|
||||
let digits: String = remote_id.chars().filter(|c| c.is_ascii_digit()).collect();
|
||||
let tail = &digits[digits.len().saturating_sub(4)..];
|
||||
format!("{title} ·{tail}")
|
||||
}
|
||||
|
||||
/// The conversation's buffer, made if it is not there yet.
|
||||
pub fn buffer_for(state: &AppState, account_id: &str, peer: &Peer) -> Buffer {
|
||||
let remote = remote_id(peer.id());
|
||||
let mine: Vec<Buffer> = state.runtime.list_buffers().into_iter().filter(|b| b.account_id == account_id).collect();
|
||||
if let Some(found) = mine.iter().find(|b| b.remote_id.as_deref() == Some(remote.as_str())) {
|
||||
return found.clone();
|
||||
}
|
||||
let taken: Vec<(String, Option<String>)> = mine.iter().map(|b| (b.name.clone(), b.remote_id.clone())).collect();
|
||||
let name = unique_name(&taken, &remote, &title_of(peer), peer.username());
|
||||
let buffer = state.runtime.ensure_buffer(state, account_id, &name, kind_of(peer));
|
||||
state.runtime.set_buffer_remote_id(state, &buffer.id, &remote);
|
||||
state.runtime.set_buffer_category(state, &buffer.id, Some(category_of(peer)), 0);
|
||||
state.runtime.set_buffer_name_icon(state, &buffer.id, name_icon_of(peer));
|
||||
file_in_rail(state, account_id, &buffer, peer, 1000);
|
||||
state.runtime.get_buffer(&buffer.id).unwrap_or(buffer)
|
||||
}
|
||||
|
||||
|
||||
/// What the window says in place of the message box where the account may not write: the words every service uses.
|
||||
pub const NO_SEND_PERMISSION: &str = "You do not have permission to send messages in this channel.";
|
||||
|
||||
/// What decides whether an account may write in a group or a channel, read off what Telegram says about it.
|
||||
#[derive(Default, Clone, Debug, PartialEq)]
|
||||
pub struct Rights {
|
||||
/// A broadcast channel, where only those allowed to post may write.
|
||||
pub broadcast: bool,
|
||||
pub creator: bool,
|
||||
/// An administrator (of any kind) of this chat.
|
||||
pub admin: bool,
|
||||
/// An administrator who may post in a broadcast channel.
|
||||
pub may_post: bool,
|
||||
/// The account has left, or was removed.
|
||||
pub gone: bool,
|
||||
/// The chat was upgraded to a supergroup and no longer takes messages.
|
||||
pub deactivated: bool,
|
||||
/// This account in particular may not send.
|
||||
pub banned_here: bool,
|
||||
/// Nobody but administrators may send.
|
||||
pub members_banned: bool,
|
||||
}
|
||||
|
||||
/// Why the account cannot write in a chat, or `None` when it can.
|
||||
pub fn read_only_reason(r: &Rights) -> Option<&'static str> {
|
||||
if r.gone {
|
||||
return Some("You are not a member of this chat.");
|
||||
}
|
||||
if r.deactivated {
|
||||
return Some("This group was upgraded and no longer takes messages.");
|
||||
}
|
||||
let boss = r.creator || r.admin;
|
||||
if r.broadcast {
|
||||
return (!(r.creator || r.may_post)).then_some(NO_SEND_PERMISSION);
|
||||
}
|
||||
if r.banned_here || (r.members_banned && !boss) {
|
||||
return Some(NO_SEND_PERMISSION);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn sending_banned(rights: &Option<tl::enums::ChatBannedRights>, now: i64) -> bool {
|
||||
match rights {
|
||||
Some(tl::enums::ChatBannedRights::Rights(b)) => b.send_messages && (b.until_date == 0 || i64::from(b.until_date) > now),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_admin(rights: &Option<tl::enums::ChatAdminRights>) -> bool {
|
||||
rights.is_some()
|
||||
}
|
||||
|
||||
fn may_post(rights: &Option<tl::enums::ChatAdminRights>) -> bool {
|
||||
matches!(rights, Some(tl::enums::ChatAdminRights::Rights(a)) if a.post_messages)
|
||||
}
|
||||
|
||||
fn rights_of_channel(c: &tl::types::Channel, now: i64) -> Rights {
|
||||
Rights {
|
||||
broadcast: c.broadcast,
|
||||
creator: c.creator,
|
||||
admin: is_admin(&c.admin_rights),
|
||||
may_post: may_post(&c.admin_rights),
|
||||
gone: c.left,
|
||||
deactivated: false,
|
||||
banned_here: sending_banned(&c.banned_rights, now),
|
||||
members_banned: sending_banned(&c.default_banned_rights, now),
|
||||
}
|
||||
}
|
||||
|
||||
/// What the account may do in a conversation. Only meaningful for a peer read from the dialog list: one that arrived
|
||||
/// with a message may be a "min" copy without the account's rights in it.
|
||||
pub fn rights_of(peer: &Peer, now: i64) -> Option<Rights> {
|
||||
match peer {
|
||||
Peer::Channel(channel) => Some(rights_of_channel(&channel.raw, now)),
|
||||
Peer::Group(group) => Some(match &group.raw {
|
||||
tl::enums::Chat::Chat(c) => Rights {
|
||||
creator: c.creator,
|
||||
admin: is_admin(&c.admin_rights),
|
||||
gone: c.left,
|
||||
deactivated: c.deactivated,
|
||||
members_banned: sending_banned(&c.default_banned_rights, now),
|
||||
..Default::default()
|
||||
},
|
||||
tl::enums::Chat::Channel(c) => rights_of_channel(c, now),
|
||||
// A chat that is gone, or that this account cannot see.
|
||||
_ => Rights { gone: true, ..Default::default() },
|
||||
}),
|
||||
Peer::User(user) if user.id().bare_id_unchecked() == 777000 => None,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The reason the window gives for a message box it does not show, for a conversation read from the dialog list.
|
||||
pub fn read_only_for(peer: &Peer, now: i64) -> Option<&'static str> {
|
||||
if let Peer::User(user) = peer {
|
||||
// Telegram's own service account sends its login codes and takes no reply.
|
||||
return (user.id().bare_id_unchecked() == 777000).then_some("Telegram does not take replies here.");
|
||||
}
|
||||
rights_of(peer, now).and_then(|r| read_only_reason(&r))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn two_people_with_one_name_are_told_apart_and_a_rename_never_makes_a_second_buffer() {
|
||||
let taken = vec![("Anna".to_string(), Some("111".to_string()))];
|
||||
assert_eq!(unique_name(&taken, "222", "Anna", None), "Anna ·222");
|
||||
assert_eq!(unique_name(&taken, "222", "Anna", Some("anna_k")), "Anna (@anna_k)");
|
||||
// The same person asking again is not a clash with themselves.
|
||||
assert_eq!(unique_name(&taken, "111", "Anna", None), "Anna");
|
||||
assert_eq!(unique_name(&taken, "333", "Boris", None), "Boris");
|
||||
assert_eq!(unique_name(&taken, "123456789", "Anna", None), "Anna ·6789");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broadcast_channel_takes_messages_only_from_those_allowed_to_post() {
|
||||
let feed = Rights { broadcast: true, ..Default::default() };
|
||||
assert_eq!(read_only_reason(&feed), Some(NO_SEND_PERMISSION));
|
||||
assert_eq!(read_only_reason(&Rights { admin: true, ..feed.clone() }), Some(NO_SEND_PERMISSION), "an administrator who may not post is a reader here");
|
||||
assert_eq!(read_only_reason(&Rights { admin: true, may_post: true, ..feed.clone() }), None);
|
||||
assert_eq!(read_only_reason(&Rights { creator: true, ..feed }), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_takes_messages_unless_this_account_or_every_member_is_banned_from_sending() {
|
||||
assert_eq!(read_only_reason(&Rights::default()), None);
|
||||
assert_eq!(read_only_reason(&Rights { banned_here: true, ..Default::default() }), Some(NO_SEND_PERMISSION));
|
||||
assert_eq!(read_only_reason(&Rights { members_banned: true, ..Default::default() }), Some(NO_SEND_PERMISSION));
|
||||
assert_eq!(read_only_reason(&Rights { members_banned: true, admin: true, ..Default::default() }), None, "administrators may still send where members may not");
|
||||
assert!(read_only_reason(&Rights { gone: true, ..Default::default() }).unwrap().contains("not a member"));
|
||||
assert!(read_only_reason(&Rights { deactivated: true, ..Default::default() }).unwrap().contains("upgraded"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ban_on_sending_that_has_run_out_is_not_one() {
|
||||
use tl::types::ChatBannedRights as B;
|
||||
let banned = |until| Some(tl::enums::ChatBannedRights::Rights(B { send_messages: true, until_date: until, view_messages: false, send_media: false, send_stickers: false, send_gifs: false, send_games: false, send_inline: false, embed_links: false, send_polls: false, change_info: false, invite_users: false, pin_messages: false, manage_topics: false, send_photos: false, send_videos: false, send_roundvideos: false, send_audios: false, send_voices: false, send_docs: false, send_plain: false, edit_rank: false, manage_linked_peers: false, send_reactions: false }));
|
||||
assert!(sending_banned(&banned(0), 1000), "no end is for ever");
|
||||
assert!(sending_banned(&banned(2000), 1000));
|
||||
assert!(!sending_banned(&banned(500), 1000));
|
||||
assert!(!sending_banned(&None, 1000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_id_goes_to_the_buffer_and_back() {
|
||||
let user = PeerId::user_unchecked(1234567890);
|
||||
assert_eq!(remote_id(user), "1234567890");
|
||||
assert_eq!(peer_id_of("1234567890"), Some(user));
|
||||
let channel = PeerId::channel_unchecked(1234567890);
|
||||
assert_eq!(remote_id(channel), "-1001234567890");
|
||||
assert_eq!(peer_id_of("-1001234567890"), Some(channel));
|
||||
assert_eq!(peer_id_of("not a number"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! A conversation's pinned messages: listed, pinned and unpinned.
|
||||
//!
|
||||
//! Telegram keeps the list on its side, so it is asked for when somebody opens the pins and again when an update says
|
||||
//! the pins changed. The answer has the shape the other services' pins have, so the window's banner and list work
|
||||
//! the same.
|
||||
|
||||
use super::messages;
|
||||
use super::send::{explain, peer_for};
|
||||
use crate::model::Buffer;
|
||||
use crate::state::AppState;
|
||||
use grammers_client::tl;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// How many pins are read: a conversation with more has more than anybody is going to scroll.
|
||||
const LIMIT: usize = 50;
|
||||
|
||||
pub async fn list(state: &AppState, buffer: &Buffer) -> Result<Value, String> {
|
||||
let (handle, peer) = peer_for(state, buffer).await?;
|
||||
let mut found = handle.client.search_messages(peer).filter(tl::enums::MessagesFilter::InputMessagesFilterPinned);
|
||||
let mut pinned: Vec<Value> = Vec::new();
|
||||
while pinned.len() < LIMIT {
|
||||
let Some(message) = found.next().await.map_err(|e| explain(&e))? else { break };
|
||||
let from = if message.outgoing() { handle.own.name.clone() } else { message.sender().map(messages::name_of).unwrap_or_else(|| buffer.name.clone()) };
|
||||
let body = messages::body_of(&message);
|
||||
pinned.push(json!({
|
||||
"id": message.id().to_string(),
|
||||
"bufferId": buffer.id,
|
||||
"from": from,
|
||||
"body": if body.trim().is_empty() { "(no text)".to_string() } else { body },
|
||||
"ts": message.date().as_second(),
|
||||
"kind": "chat",
|
||||
}));
|
||||
}
|
||||
let ids: Vec<String> = pinned.iter().filter_map(|m| m["id"].as_str().map(str::to_string)).collect();
|
||||
// Said as well as answered, so the header's count is right in every window watching this conversation.
|
||||
state.events.emit("pinnedMessages", json!({ "bufferId": buffer.id, "pinned": ids }));
|
||||
Ok(json!({ "bufferId": buffer.id, "pinned": pinned }))
|
||||
}
|
||||
|
||||
pub async fn set(state: &AppState, buffer: &Buffer, msg_id: &str, pinned: bool) -> Result<(), String> {
|
||||
let id: i32 = msg_id.parse().map_err(|_| "that message cannot be pinned".to_string())?;
|
||||
let (handle, peer) = peer_for(state, buffer).await?;
|
||||
if pinned {
|
||||
handle.client.pin_message(peer, id).await.map_err(|e| explain(&e))?;
|
||||
} else {
|
||||
handle.client.unpin_message(peer, id).await.map_err(|e| explain(&e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
//! Polls: a question with answers, shown as the card over the conversation and voted in from it.
|
||||
//!
|
||||
//! A poll is part of a message. When one that is still open is read (live or from history) it is announced as the
|
||||
//! conversation's poll card; Telegram sends an update whenever the tally moves, and the card is drawn again. Only the
|
||||
//! newest open poll has the card (a conversation has one slot for it); a closed poll stays in the log as its label.
|
||||
|
||||
use super::send::{explain, peer_for};
|
||||
use crate::model::Buffer;
|
||||
use crate::state::AppState;
|
||||
use grammers_client::tl;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Which message each poll is, by Telegram's id for the poll, for an update that names only the poll.
|
||||
fn registry() -> &'static Mutex<HashMap<i64, (String, i32)>> {
|
||||
static R: std::sync::OnceLock<Mutex<HashMap<i64, (String, i32)>>> = std::sync::OnceLock::new();
|
||||
R.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
fn text_of(t: &tl::enums::TextWithEntities) -> String {
|
||||
let tl::enums::TextWithEntities::Entities(t) = t;
|
||||
t.text.clone()
|
||||
}
|
||||
|
||||
/// An answer's id as text: Telegram names an answer by a few bytes.
|
||||
pub fn option_id(bytes: &[u8]) -> String {
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
/// The card for a poll, from its definition and its tally.
|
||||
pub fn card(buffer_id: &str, msg_id: i32, poll: &tl::types::Poll, results: &tl::types::PollResults) -> Value {
|
||||
let tally: HashMap<Vec<u8>, (i32, bool)> = results
|
||||
.results
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|r| {
|
||||
let tl::enums::PollAnswerVoters::Voters(v) = r;
|
||||
(v.option.clone(), (v.voters.unwrap_or(0), v.chosen))
|
||||
})
|
||||
.collect();
|
||||
let mut voted: Option<String> = None;
|
||||
let options: Vec<Value> = poll
|
||||
.answers
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let tl::enums::PollAnswer::Answer(a) = a else { return json!({}) };
|
||||
let (votes, chosen) = tally.get(&a.option).copied().unwrap_or((0, false));
|
||||
if chosen {
|
||||
voted = Some(option_id(&a.option));
|
||||
}
|
||||
json!({ "id": option_id(&a.option), "label": text_of(&a.text), "votes": votes })
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"bufferId": buffer_id,
|
||||
"kind": "poll",
|
||||
"id": msg_id.to_string(),
|
||||
"title": text_of(&poll.question),
|
||||
"options": options,
|
||||
// A poll has no clock the card could count: it is open until it is closed.
|
||||
"open": !poll.closed,
|
||||
"duration": 0,
|
||||
"remaining": 0,
|
||||
"resultDisplayDuration": 0,
|
||||
"hasVoted": voted.is_some(),
|
||||
"votedOptionId": voted,
|
||||
"noEnd": true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Shows a poll as the conversation's card, and remembers which message it is.
|
||||
pub fn announce(state: &AppState, buffer_id: &str, msg_id: i32, poll: &tl::types::Poll, results: &tl::types::PollResults) {
|
||||
registry().lock().unwrap_or_else(|e| e.into_inner()).insert(poll.id, (buffer_id.to_string(), msg_id));
|
||||
if poll.closed {
|
||||
return;
|
||||
}
|
||||
let card = card(buffer_id, msg_id, poll, results);
|
||||
state.runtime.set_live_card(buffer_id, "poll", card.clone());
|
||||
let ts = chrono::Utc::now().timestamp();
|
||||
if let Err(e) = state.store.record_live_card(buffer_id, "poll", &msg_id.to_string(), card["title"].as_str().unwrap_or(""), &card.to_string(), ts) {
|
||||
tracing::debug!("telegram: keeping the poll: {e:#}");
|
||||
}
|
||||
state.events.emit("pollCard", json!({ "bufferId": buffer_id, "kind": "poll", "poll": card }));
|
||||
}
|
||||
|
||||
/// The tally of a poll moved.
|
||||
pub fn update(state: &AppState, update: &tl::types::UpdateMessagePoll) {
|
||||
let Some((buffer_id, msg_id)) = registry().lock().unwrap_or_else(|e| e.into_inner()).get(&update.poll_id).cloned() else { return };
|
||||
let tl::enums::PollResults::Results(results) = &update.results;
|
||||
// A poll that closed is drawn as it ended; one whose definition came with the update is redrawn from it.
|
||||
match &update.poll {
|
||||
Some(tl::enums::Poll::Poll(poll)) => announce_any(state, &buffer_id, msg_id, poll, results),
|
||||
None => {
|
||||
if let Some(mut live) = state.runtime.live_card(&buffer_id, "poll").filter(|c| c["id"].as_str() == Some(&msg_id.to_string())) {
|
||||
let mut chosen_id: Option<String> = None;
|
||||
if let Some(options) = live["options"].as_array_mut() {
|
||||
for option in options.iter_mut() {
|
||||
let id = option["id"].as_str().unwrap_or("").to_string();
|
||||
let found = results.results.iter().flatten().find_map(|r| {
|
||||
let tl::enums::PollAnswerVoters::Voters(v) = r;
|
||||
(option_id(&v.option) == id).then_some((v.voters.unwrap_or(0), v.chosen))
|
||||
});
|
||||
if let Some((votes, chosen)) = found {
|
||||
option["votes"] = json!(votes);
|
||||
if chosen {
|
||||
chosen_id = Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(id) = chosen_id {
|
||||
live["votedOptionId"] = json!(id);
|
||||
live["hasVoted"] = json!(true);
|
||||
}
|
||||
state.runtime.set_live_card(&buffer_id, "poll", live.clone());
|
||||
state.events.emit("pollCard", json!({ "bufferId": buffer_id, "kind": "poll", "poll": live }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Redraws a poll that may have closed: the card says so rather than vanishing.
|
||||
fn announce_any(state: &AppState, buffer_id: &str, msg_id: i32, poll: &tl::types::Poll, results: &tl::types::PollResults) {
|
||||
let card = card(buffer_id, msg_id, poll, results);
|
||||
state.runtime.set_live_card(buffer_id, "poll", card.clone());
|
||||
state.events.emit("pollCard", json!({ "bufferId": buffer_id, "kind": "poll", "poll": card }));
|
||||
}
|
||||
|
||||
/// Votes for an answer in the poll the conversation's card shows.
|
||||
pub async fn vote(state: &AppState, buffer: &Buffer, answer: &str) -> Result<(), String> {
|
||||
let card = state.runtime.live_card(&buffer.id, "poll").ok_or_else(|| "there is no poll open here".to_string())?;
|
||||
let msg_id: i32 = card["id"].as_str().and_then(|i| i.parse().ok()).ok_or_else(|| "that poll cannot be voted in".to_string())?;
|
||||
let option = hex::decode(answer).map_err(|_| "that is not one of the poll's answers".to_string())?;
|
||||
let (handle, peer) = peer_for(state, buffer).await?;
|
||||
handle.client.invoke(&tl::functions::messages::SendVote { peer: peer.into(), msg_id, options: vec![option] }).await.map_err(|e| explain(&e))?;
|
||||
// Said at once; Telegram's own update with the new tally follows.
|
||||
let mut live = card;
|
||||
live["hasVoted"] = json!(true);
|
||||
live["votedOptionId"] = json!(answer);
|
||||
if let Some(options) = live["options"].as_array_mut() {
|
||||
for o in options.iter_mut() {
|
||||
if o["id"].as_str() == Some(answer) {
|
||||
o["votes"] = json!(o["votes"].as_i64().unwrap_or(0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
state.runtime.set_live_card(&buffer.id, "poll", live.clone());
|
||||
state.events.emit("pollCard", json!({ "bufferId": buffer.id, "kind": "poll", "poll": live }));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Telegram's limits on a poll: a question of 255 characters, answers of 100, two to ten of them.
|
||||
const QUESTION_MAX: usize = 255;
|
||||
const ANSWER_MAX: usize = 100;
|
||||
const ANSWERS: std::ops::RangeInclusive<usize> = 2..=10;
|
||||
|
||||
/// What a poll somebody is starting is, checked and as Telegram's own type. `disclosed` is whether the running
|
||||
/// count shows before it closes.
|
||||
pub fn build(question: &str, answers: &[String], disclosed: bool) -> Result<tl::types::Poll, String> {
|
||||
let question = question.trim();
|
||||
let answers: Vec<&str> = answers.iter().map(|a| a.trim()).filter(|a| !a.is_empty()).collect();
|
||||
if question.is_empty() {
|
||||
return Err("A poll needs a question".to_string());
|
||||
}
|
||||
if question.chars().count() > QUESTION_MAX {
|
||||
return Err(format!("The question can be at most {QUESTION_MAX} characters"));
|
||||
}
|
||||
if !ANSWERS.contains(&answers.len()) {
|
||||
return Err("Telegram polls have between 2 and 10 answers".to_string());
|
||||
}
|
||||
if answers.iter().any(|a| a.chars().count() > ANSWER_MAX) {
|
||||
return Err(format!("An answer can be at most {ANSWER_MAX} characters"));
|
||||
}
|
||||
let plain = |t: &str| tl::enums::TextWithEntities::Entities(tl::types::TextWithEntities { text: t.to_string(), entities: Vec::new() });
|
||||
Ok(tl::types::Poll {
|
||||
id: 0,
|
||||
closed: false,
|
||||
// Anonymous, as Telegram's own apps start one.
|
||||
public_voters: false,
|
||||
multiple_choice: false,
|
||||
quiz: false,
|
||||
open_answers: false,
|
||||
revoting_disabled: false,
|
||||
shuffle_answers: false,
|
||||
hide_results_until_close: !disclosed,
|
||||
creator: false,
|
||||
subscribers_only: false,
|
||||
question: plain(question),
|
||||
answers: answers.iter().enumerate().map(|(i, a)| tl::enums::PollAnswer::Answer(tl::types::PollAnswer { text: plain(a), option: vec![i as u8], media: None, added_by: None, date: None })).collect(),
|
||||
close_period: None,
|
||||
close_date: None,
|
||||
hash: 0,
|
||||
countries_iso2: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Starts a poll in a conversation.
|
||||
pub async fn start(state: &AppState, buffer: &Buffer, question: &str, answers: &[String], disclosed: bool) -> Result<(), String> {
|
||||
let poll = build(question, answers, disclosed)?;
|
||||
let (handle, peer) = peer_for(state, buffer).await?;
|
||||
let media = tl::types::InputMediaPoll { poll: tl::enums::Poll::Poll(poll), correct_answers: None, attached_media: None, solution: None, solution_entities: None, solution_media: None };
|
||||
let input = grammers_client::message::InputMessage::new().media(tl::enums::InputMedia::Poll(Box::new(media)));
|
||||
let sent = handle.client.send_message(peer, input).await.map_err(|e| explain(&e))?;
|
||||
super::messages::record(state, &buffer.account_id, &handle.own, &sent);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn text(s: &str) -> tl::enums::TextWithEntities {
|
||||
tl::enums::TextWithEntities::Entities(tl::types::TextWithEntities { text: s.into(), entities: vec![] })
|
||||
}
|
||||
|
||||
fn answer(label: &str, option: &[u8]) -> tl::enums::PollAnswer {
|
||||
tl::enums::PollAnswer::Answer(tl::types::PollAnswer { text: text(label), option: option.to_vec(), media: None, added_by: None, date: None })
|
||||
}
|
||||
|
||||
fn poll(closed: bool) -> tl::types::Poll {
|
||||
tl::types::Poll {
|
||||
id: 77,
|
||||
closed,
|
||||
public_voters: false,
|
||||
multiple_choice: false,
|
||||
quiz: false,
|
||||
open_answers: false,
|
||||
revoting_disabled: false,
|
||||
shuffle_answers: false,
|
||||
hide_results_until_close: false,
|
||||
creator: false,
|
||||
subscribers_only: false,
|
||||
question: text("Lunch?"),
|
||||
answers: vec![answer("Pizza", b"0"), answer("Soup", b"1")],
|
||||
close_period: None,
|
||||
close_date: None,
|
||||
hash: 0,
|
||||
countries_iso2: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn voters(option: &[u8], n: i32, chosen: bool) -> tl::enums::PollAnswerVoters {
|
||||
tl::enums::PollAnswerVoters::Voters(tl::types::PollAnswerVoters { chosen, correct: false, option: option.to_vec(), voters: Some(n), recent_voters: None })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_poll_that_is_started_is_checked() {
|
||||
let a = |n: &[&str]| n.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
let ok = build(" Lunch? ", &a(&["Pizza", " Soup ", ""]), false).unwrap();
|
||||
assert_eq!(ok.answers.len(), 2, "an empty answer is not one");
|
||||
assert!(ok.hide_results_until_close);
|
||||
assert!(!ok.public_voters);
|
||||
assert!(build("", &a(&["a", "b"]), true).is_err());
|
||||
assert!(build("Q", &a(&["only"]), true).is_err());
|
||||
assert!(build("Q", &a(&["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"]), true).is_err());
|
||||
assert!(build(&"x".repeat(256), &a(&["a", "b"]), true).is_err());
|
||||
assert!(!build("Q", &a(&["a", "b"]), true).unwrap().hide_results_until_close);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_poll_becomes_a_card_with_counts_and_the_accounts_own_choice() {
|
||||
let results = tl::types::PollResults { min: false, has_unread_votes: false, can_view_stats: false, results: Some(vec![voters(b"0", 3, false), voters(b"1", 5, true)]), total_voters: Some(8), recent_voters: None, solution: None, solution_entities: None, solution_media: None };
|
||||
let card = card("telegram:1|Lunch", 42, &poll(false), &results);
|
||||
assert_eq!((card["title"].as_str(), card["id"].as_str(), card["open"].as_bool()), (Some("Lunch?"), Some("42"), Some(true)));
|
||||
assert_eq!(card["options"][0]["votes"], 3);
|
||||
assert_eq!(card["options"][1]["votes"], 5);
|
||||
assert_eq!((card["hasVoted"].as_bool(), card["votedOptionId"].as_str()), (Some(true), Some(option_id(b"1").as_str())));
|
||||
let closed = super::card("b", 1, &poll(true), &results);
|
||||
assert_eq!(closed["open"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_poll_nobody_voted_in_has_zeros_and_no_choice() {
|
||||
let results = tl::types::PollResults { min: false, has_unread_votes: false, can_view_stats: false, results: None, total_voters: None, recent_voters: None, solution: None, solution_entities: None, solution_media: None };
|
||||
let card = card("b", 1, &poll(false), &results);
|
||||
assert_eq!(card["options"][0]["votes"], 0);
|
||||
assert_eq!(card["hasVoted"], false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//! The card for a person: their name, handle, picture, what they wrote about themselves and when they were last seen.
|
||||
|
||||
use super::media;
|
||||
use super::messages;
|
||||
use super::peers;
|
||||
use super::send::{explain, peer_for};
|
||||
use crate::profile;
|
||||
use crate::state::AppState;
|
||||
use grammers_client::peer::{Peer, User};
|
||||
use grammers_client::tl;
|
||||
use grammers_session::Session;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// What they were last seen doing, in the card's words, from Telegram's coarse status.
|
||||
pub fn presence(status: &tl::enums::UserStatus) -> (Option<&'static str>, Option<i64>) {
|
||||
use tl::enums::UserStatus as S;
|
||||
match status {
|
||||
S::Online(_) => (Some("online"), None),
|
||||
S::Offline(o) => (Some("offline"), Some(i64::from(o.was_online))),
|
||||
S::Recently(_) => (Some("offline"), None),
|
||||
S::LastWeek(_) | S::LastMonth(_) => (Some("offline"), None),
|
||||
S::Empty => (None, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// How Telegram words its coarse "last seen" when it will not give a time.
|
||||
fn coarse(status: &tl::enums::UserStatus) -> Option<&'static str> {
|
||||
use tl::enums::UserStatus as S;
|
||||
match status {
|
||||
S::Recently(_) => Some("recently"),
|
||||
S::LastWeek(_) => Some("within a week"),
|
||||
S::LastMonth(_) => Some("within a month"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The card for the person a conversation is with, or a member of a group the window asked about.
|
||||
pub async fn build(state: &AppState, account_id: &str, buffer_id: &str, who: &str, user_id: &str) -> Value {
|
||||
let mut card = profile::pending("telegram", account_id, who);
|
||||
card["pending"] = json!(false);
|
||||
let Some(buffer) = state.runtime.get_buffer(buffer_id) else { return card };
|
||||
// A direct message is with one person; in a group the member asked about is named by number.
|
||||
let target = if buffer.kind == "dm" { buffer.remote_id.clone() } else { Some(user_id.to_string()) };
|
||||
let Some(target) = target.and_then(|t| peers::peer_id_of(&t)) else { return card };
|
||||
let Ok((handle, _)) = peer_for(state, &buffer).await else { return card };
|
||||
let Some(peer) = handle.session.peer_ref(target).await.ok().flatten() else { return card };
|
||||
card["id"] = json!(target.bare_id_unchecked().to_string());
|
||||
let full = handle.client.invoke(&tl::functions::users::GetFullUser { id: peer.into() }).await;
|
||||
let tl::enums::users::UserFull::Full(full) = match full {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
tracing::debug!("telegram profile: {}", explain(&e));
|
||||
return card;
|
||||
}
|
||||
};
|
||||
let tl::enums::UserFull::Full(info) = &full.full_user;
|
||||
if let Some(user) = full.users.iter().find(|u| u.id() == target.bare_id_unchecked()).cloned() {
|
||||
let user = User::from_raw(&handle.client, user);
|
||||
let as_peer = Peer::User(user.clone());
|
||||
card["name"] = json!(messages::name_of(&as_peer));
|
||||
if let Some(username) = user.username() {
|
||||
card["handle"] = json!(format!("@{username}"));
|
||||
}
|
||||
profile::set(&mut card, "avatarUrl", media::avatar_url(handle.own.user_id, &as_peer).map(|u| json!(u)));
|
||||
let (status, seen) = presence(user.status());
|
||||
profile::set(&mut card, "status", status.map(|s| json!(s)));
|
||||
profile::set(&mut card, "lastActiveTs", seen.map(|t| json!(t)));
|
||||
if let Some(words) = coarse(user.status()) {
|
||||
profile::note(&mut card, "Last seen", words);
|
||||
}
|
||||
if user.is_bot() {
|
||||
profile::note(&mut card, "Account", "A bot");
|
||||
}
|
||||
if user.verified() {
|
||||
profile::note(&mut card, "Account", "Verified by Telegram");
|
||||
}
|
||||
if let Some(phone) = user.phone() {
|
||||
profile::note(&mut card, "Phone", format!("+{phone}"));
|
||||
}
|
||||
}
|
||||
if let Some(about) = &info.about {
|
||||
profile::note(&mut card, "About", about.clone());
|
||||
}
|
||||
if let Some(tl::enums::Birthday::Birthday(b)) = &info.birthday {
|
||||
profile::note(&mut card, "Birthday", match b.year {
|
||||
Some(year) => format!("{}.{}.{year}", b.day, b.month),
|
||||
None => format!("{}.{}", b.day, b.month),
|
||||
});
|
||||
}
|
||||
if info.common_chats_count > 0 {
|
||||
profile::note(&mut card, "Groups in common", info.common_chats_count.to_string());
|
||||
}
|
||||
card
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn last_seen_is_a_time_when_telegram_gives_one_and_a_word_when_it_does_not() {
|
||||
let offline = tl::enums::UserStatus::Offline(tl::types::UserStatusOffline { was_online: 1790000000 });
|
||||
assert_eq!(presence(&offline), (Some("offline"), Some(1790000000)));
|
||||
assert_eq!(presence(&tl::enums::UserStatus::Online(tl::types::UserStatusOnline { expires: 1 })), (Some("online"), None));
|
||||
let recently = tl::enums::UserStatus::Recently(tl::types::UserStatusRecently { by_me: false });
|
||||
assert_eq!(presence(&recently), (Some("offline"), None));
|
||||
assert_eq!(coarse(&recently), Some("recently"));
|
||||
assert_eq!(coarse(&offline), None);
|
||||
assert_eq!(presence(&tl::enums::UserStatus::Empty), (None, None));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Reactions: the emoji people put on a message, and the account's own.
|
||||
//!
|
||||
//! Telegram sends a message with its reactions as a list of (reaction, count, whether the account chose it), and an
|
||||
//! update when they change. A reaction is an ordinary emoji, a custom emoji (drawn from Telegram's sticker sets,
|
||||
//! which moho does not draw yet, so it is counted under a star) or a paid star.
|
||||
|
||||
use super::send::{explain, peer_for};
|
||||
use crate::model::{Buffer, Reaction};
|
||||
use crate::state::AppState;
|
||||
use grammers_client::message::InputReactions;
|
||||
use grammers_client::tl;
|
||||
|
||||
/// What stands for a reaction moho cannot draw: custom emoji and paid stars.
|
||||
const STAND_IN: &str = "⭐";
|
||||
|
||||
/// A message's reactions as moho's list.
|
||||
pub fn from_tl(reactions: &tl::enums::MessageReactions) -> Vec<Reaction> {
|
||||
let tl::enums::MessageReactions::Reactions(r) = reactions;
|
||||
let mut out: Vec<Reaction> = Vec::new();
|
||||
for count in &r.results {
|
||||
let tl::enums::ReactionCount::Count(c) = count;
|
||||
let emoji = match &c.reaction {
|
||||
tl::enums::Reaction::Emoji(e) => e.emoticon.clone(),
|
||||
tl::enums::Reaction::CustomEmoji(_) | tl::enums::Reaction::Paid => STAND_IN.to_string(),
|
||||
tl::enums::Reaction::Empty => continue,
|
||||
};
|
||||
// Two kinds that share the stand-in are one pill.
|
||||
if let Some(existing) = out.iter_mut().find(|x| x.emoji == emoji) {
|
||||
existing.count += i64::from(c.count);
|
||||
existing.me |= c.chosen_order.is_some();
|
||||
} else {
|
||||
out.push(Reaction { emoji, count: i64::from(c.count), me: c.chosen_order.is_some(), animated: false });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Records what a message's reactions are now, and says so.
|
||||
pub fn apply(state: &AppState, buffer_id: &str, msg_id: &str, reactions: &[Reaction]) {
|
||||
match state.store.set_reactions(buffer_id, msg_id, reactions) {
|
||||
Ok(true) => state.events.emit("reactionsChanged", serde_json::json!({ "bufferId": buffer_id, "id": msg_id, "reactions": reactions })),
|
||||
Ok(false) => {}
|
||||
Err(e) => tracing::warn!("telegram: recording reactions: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The reactions the account itself has put on a message, as Telegram's own type.
|
||||
fn chosen(reactions: &[Reaction]) -> Vec<tl::enums::Reaction> {
|
||||
reactions.iter().filter(|r| r.me && r.emoji != STAND_IN).map(|r| tl::enums::Reaction::Emoji(tl::types::ReactionEmoji { emoticon: r.emoji.clone() })).collect()
|
||||
}
|
||||
|
||||
/// Puts an emoji on a message or takes the account's off. Telegram lets an ordinary account have one reaction on a
|
||||
/// message, so adding replaces the account's earlier one.
|
||||
pub async fn toggle(state: &AppState, buffer: &Buffer, msg_id: &str, emoji: &str, add: bool) -> Result<(), String> {
|
||||
let id: i32 = msg_id.parse().map_err(|_| "that message cannot take reactions".to_string())?;
|
||||
let (handle, peer) = peer_for(state, buffer).await?;
|
||||
let stored = state.store.get_message(&buffer.id, msg_id).ok().flatten().map(|m| m.reactions).unwrap_or_default();
|
||||
let stored_again = stored.clone();
|
||||
let mut mine = chosen(&stored);
|
||||
let this = tl::enums::Reaction::Emoji(tl::types::ReactionEmoji { emoticon: emoji.to_string() });
|
||||
if add {
|
||||
mine = vec![this];
|
||||
} else {
|
||||
mine.retain(|r| *r != this);
|
||||
}
|
||||
let input: InputReactions = if mine.is_empty() { InputReactions::remove() } else { mine.into() };
|
||||
handle.client.send_reactions(peer, id, input).await.map_err(|e| explain(&e))?;
|
||||
// Telegram answers with the message's new state as an update, which is read below; this is the same fact said
|
||||
// at once so the pill does not wait on it.
|
||||
apply(state, &buffer.id, msg_id, &after_change(&stored_again, emoji, add));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The reactions a message has once the account has put an emoji on it (replacing its earlier one) or taken it off.
|
||||
pub fn after_change(stored: &[Reaction], emoji: &str, add: bool) -> Vec<Reaction> {
|
||||
let mut next: Vec<Reaction> = stored.to_vec();
|
||||
for r in next.iter_mut() {
|
||||
let own_ordinary = r.me && r.emoji != STAND_IN;
|
||||
if own_ordinary && (add || r.emoji == emoji) {
|
||||
r.me = false;
|
||||
r.count -= 1;
|
||||
}
|
||||
}
|
||||
next.retain(|r| r.count > 0);
|
||||
if add {
|
||||
match next.iter_mut().find(|r| r.emoji == emoji) {
|
||||
Some(r) => {
|
||||
r.count += 1;
|
||||
r.me = true;
|
||||
}
|
||||
None => next.push(Reaction { emoji: emoji.to_string(), count: 1, me: true, animated: false }),
|
||||
}
|
||||
}
|
||||
next
|
||||
}
|
||||
|
||||
/// A reactions update from Telegram.
|
||||
pub fn update(state: &AppState, account_id: &str, remote: &str, msg_id: i32, reactions: &tl::enums::MessageReactions) {
|
||||
let Some(buffer) = super::topics::holding(state, account_id, remote, msg_id) else { return };
|
||||
apply(state, &buffer.id, &msg_id.to_string(), &from_tl(reactions));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn count(reaction: tl::enums::Reaction, n: i32, chosen: Option<i32>) -> tl::enums::ReactionCount {
|
||||
tl::enums::ReactionCount::Count(tl::types::ReactionCount { chosen_order: chosen, reaction, count: n })
|
||||
}
|
||||
|
||||
fn emoji(e: &str) -> tl::enums::Reaction {
|
||||
tl::enums::Reaction::Emoji(tl::types::ReactionEmoji { emoticon: e.into() })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reactions_become_pills_with_counts_and_whether_the_account_chose_them() {
|
||||
let results = vec![count(emoji("👍"), 5, Some(0)), count(emoji("🔥"), 2, None), count(tl::enums::Reaction::CustomEmoji(tl::types::ReactionCustomEmoji { document_id: 9 }), 3, None), count(tl::enums::Reaction::Paid, 4, None)];
|
||||
let reactions = tl::enums::MessageReactions::Reactions(tl::types::MessageReactions { min: false, can_see_list: false, reactions_as_tags: false, results, recent_reactions: None, top_reactors: None });
|
||||
let got = from_tl(&reactions);
|
||||
assert_eq!(got.iter().map(|r| (r.emoji.as_str(), r.count, r.me)).collect::<Vec<_>>(), vec![("👍", 5, true), ("🔥", 2, false), ("⭐", 7, false)], "two kinds drawn the same are one pill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_an_emoji_replaces_the_accounts_earlier_one_and_taking_it_off_leaves_the_rest() {
|
||||
let r = |e: &str, n: i64, me: bool| Reaction { emoji: e.into(), count: n, me, animated: false };
|
||||
let stored = vec![r("👍", 5, true), r("🔥", 2, false)];
|
||||
assert_eq!(after_change(&stored, "🔥", true), vec![r("👍", 4, false), r("🔥", 3, true)]);
|
||||
assert_eq!(after_change(&stored, "👍", false), vec![r("👍", 4, false), r("🔥", 2, false)]);
|
||||
assert_eq!(after_change(&[r("👍", 1, true)], "👍", false), Vec::<Reaction>::new(), "a pill nobody has any more goes");
|
||||
assert_eq!(after_change(&[], "😂", true), vec![r("😂", 1, true)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_accounts_own_ordinary_reactions_are_carried_into_a_change() {
|
||||
let stored = vec![Reaction { emoji: "👍".into(), count: 5, me: true, animated: false }, Reaction { emoji: "🔥".into(), count: 2, me: false, animated: false }, Reaction { emoji: STAND_IN.into(), count: 1, me: true, animated: false }];
|
||||
assert_eq!(chosen(&stored), vec![emoji("👍")]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Searching on Telegram's side: in one conversation or in every one the account has.
|
||||
//!
|
||||
//! The window searches its own copy first and shows that at once; this adds what the account's history has that this
|
||||
//! client never read. A hit is answered as a message, and nothing is recorded: pressing one reads the messages around
|
||||
//! it (see `history::context`) the way a jump to anything old does.
|
||||
|
||||
use super::media;
|
||||
use super::messages;
|
||||
use super::send::{explain, peer_for};
|
||||
use crate::model::Buffer;
|
||||
use crate::state::AppState;
|
||||
use grammers_client::message::Message;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// One hit as the window reads a message.
|
||||
fn hit(state: &AppState, account_id: &str, own_name: &str, own_id: i64, message: &Message) -> Option<Value> {
|
||||
let peer = message.peer()?;
|
||||
let buffer = super::peers::buffer_for(state, account_id, peer);
|
||||
let from = if message.outgoing() { own_name.to_string() } else { message.sender().map(messages::name_of).unwrap_or_else(|| super::peers::title_of(peer)) };
|
||||
let remote = buffer.remote_id.clone().unwrap_or_default();
|
||||
Some(json!({
|
||||
"id": message.id().to_string(),
|
||||
"bufferId": buffer.id,
|
||||
"from": from,
|
||||
"body": messages::body_of(message),
|
||||
"ts": message.date().as_second(),
|
||||
"kind": "chat",
|
||||
"isAction": false,
|
||||
"isHighlight": false,
|
||||
"edited": false,
|
||||
"isOwn": message.outgoing(),
|
||||
"attachments": media::attachments_of(own_id, &remote, message),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The matches for a query, in this conversation or across the account (`account` scope).
|
||||
pub async fn run(state: &AppState, buffer: &Buffer, query: &str, account_wide: bool, limit: usize) -> Result<Value, String> {
|
||||
let (handle, peer) = peer_for(state, buffer).await?;
|
||||
let mut results: Vec<Value> = Vec::new();
|
||||
if account_wide {
|
||||
let mut found = handle.client.search_all_messages().query(query);
|
||||
while results.len() < limit {
|
||||
let Some(message) = found.next().await.map_err(|e| explain(&e))? else { break };
|
||||
results.extend(hit(state, &buffer.account_id, &handle.own.name, handle.own.user_id, &message));
|
||||
}
|
||||
} else {
|
||||
let mut found = handle.client.search_messages(peer).query(query);
|
||||
while results.len() < limit {
|
||||
let Some(message) = found.next().await.map_err(|e| explain(&e))? else { break };
|
||||
results.extend(hit(state, &buffer.account_id, &handle.own.name, handle.own.user_id, &message));
|
||||
}
|
||||
}
|
||||
let names: std::collections::HashMap<String, String> = state.runtime.list_buffers().into_iter().filter(|b| b.account_id == buffer.account_id).map(|b| (b.id, b.name)).collect();
|
||||
let count = results.len();
|
||||
Ok(json!({ "results": results, "roomNames": names, "count": count }))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user