Compare commits
93
Commits
v1.0.0
...
development
Generated
+22
-12
@@ -1325,7 +1325,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36e10ac4bd47bf8d24a4852f001df23b7acbbca6e59d0adec0c2e2c4409dfe5b"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"indexmap 1.9.3",
|
||||
"indexmap 2.14.0",
|
||||
"itertools 0.15.0",
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
@@ -1463,7 +1463,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.5.2",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1683,7 +1683,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2682,6 +2682,12 @@ dependencies = [
|
||||
"png",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "imagesize"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285"
|
||||
|
||||
[[package]]
|
||||
name = "imara-diff"
|
||||
version = "0.2.0"
|
||||
@@ -3680,7 +3686,7 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nobilis"
|
||||
version = "1.0.0"
|
||||
version = "1.2.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -3694,6 +3700,7 @@ dependencies = [
|
||||
"cpal",
|
||||
"davey",
|
||||
"dirs 5.0.1",
|
||||
"flate2",
|
||||
"fs2",
|
||||
"futures",
|
||||
"hex",
|
||||
@@ -3702,6 +3709,7 @@ dependencies = [
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"image",
|
||||
"imagesize",
|
||||
"irc",
|
||||
"js_int",
|
||||
"libc",
|
||||
@@ -3709,8 +3717,10 @@ dependencies = [
|
||||
"matrix-sdk-crypto",
|
||||
"matrix-sdk-sqlite",
|
||||
"opus2",
|
||||
"prost",
|
||||
"qrcode",
|
||||
"rand 0.8.7",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"rsa",
|
||||
"ruma-client-api",
|
||||
@@ -3795,7 +3805,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4626,7 +4636,7 @@ dependencies = [
|
||||
"derive-deftly",
|
||||
"libc",
|
||||
"paste",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4697,7 +4707,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5301,7 +5311,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5882,7 +5892,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6273,10 +6283,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8237,7 +8247,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+11
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "nobilis"
|
||||
version = "1.0.0"
|
||||
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"
|
||||
@@ -33,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
|
||||
@@ -74,6 +75,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)
|
||||
@@ -111,6 +120,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.
|
||||
|
||||
@@ -7,31 +7,12 @@ into Kiwi Farms), and translates all of them into one unified JSON model — acc
|
||||
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`:
|
||||
@@ -52,51 +33,6 @@ shape of it and what each backend implements. The ones every service shares are
|
||||
`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, Kick and Sneedchat, but
|
||||
`"message"` from Matrix. Treat both as "something a person said". Every other kind is a log
|
||||
line or an event - a join, a topic change, a Kick raid or redeemed reward - and which of them
|
||||
to draw is the frontend's choice; nobilis always records them. A kind a frontend does not know
|
||||
is best drawn as a log line.
|
||||
- A message can carry `embeds`: what a service sent with it (a Discord embed, a Matrix link
|
||||
preview) and what nobilis worked out for it - a YouTube link's title and channel, a GIF's
|
||||
address, and for a Kick event the amount, the recipients and the colours. They are added
|
||||
after the message, as a `messageUpdated` with `edited: false`.
|
||||
- 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
|
||||
|
||||
IRC, Discord, Matrix, Kick and Sneedchat, each one module under `src/backend/`. Any account on
|
||||
any of them can be routed through Tor or a SOCKS5 proxy. What each one implements is in moho's
|
||||
[ARCHITECTURE.md](https://github.com/Moho-Chat/moho/blob/master/ARCHITECTURE.md).
|
||||
|
||||
## 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 |
|
||||
| `~/.config/nobilis/voice.toml` | microphone, speakers, volumes and voice processing |
|
||||
| `~/.config/nobilis/highlights.toml`, `ignores.toml` | what counts as a mention, and who is ignored |
|
||||
| `~/.config/nobilis/dcc.toml`, `irc-sts.toml` | DCC settings, and IRC networks that have pinned TLS |
|
||||
| `~/.config/nobilis/tor-state/`, `tor-cache/` | the embedded Tor client's state |
|
||||
| `~/.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).
|
||||
|
||||
+549
-4
@@ -366,6 +366,129 @@ 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()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||
struct AccountsFile {
|
||||
#[serde(default, rename = "account")]
|
||||
@@ -382,6 +505,10 @@ 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>,
|
||||
}
|
||||
|
||||
/// Account/credential persistence at ~/.config/nobilis/accounts.toml,
|
||||
@@ -395,11 +522,23 @@ 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>>,
|
||||
}
|
||||
|
||||
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) = 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 +548,22 @@ 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();
|
||||
(irc, discord, sneedchat, matrix, kick, steam, twitch)
|
||||
} 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())
|
||||
};
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
/// Callers already hold whichever map they just mutated - the others
|
||||
@@ -433,6 +583,8 @@ 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(),
|
||||
};
|
||||
let text = toml::to_string_pretty(&file)?;
|
||||
// Atomic write-temp-then-rename, same spirit as libpurple's periodic
|
||||
@@ -576,6 +728,100 @@ 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)
|
||||
}
|
||||
|
||||
/// 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 +956,14 @@ 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 {
|
||||
false
|
||||
};
|
||||
@@ -734,6 +988,12 @@ 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);
|
||||
}
|
||||
self.get_kick(account_id).and_then(|a| pick(a.use_tor, a.strict_route))
|
||||
}
|
||||
|
||||
@@ -807,6 +1067,20 @@ 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 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 +1239,12 @@ 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);
|
||||
}
|
||||
let mut kick = self.kick.lock().unwrap();
|
||||
match kick.get_mut(account_id) {
|
||||
None => Ok(false),
|
||||
@@ -995,6 +1275,12 @@ 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);
|
||||
}
|
||||
self.kick.lock().unwrap().get(account_id).and_then(|a| named(&a.display_name))
|
||||
}
|
||||
|
||||
@@ -1114,6 +1400,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1153,6 +1441,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1191,6 +1481,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1238,6 +1530,99 @@ 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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1280,6 +1665,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1335,3 +1722,161 @@ 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -1320,7 +1330,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.
|
||||
//
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -909,6 +909,7 @@ pub(super) fn extract_reply(d: &Value) -> Option<ReplyPreview> {
|
||||
body: String::new(),
|
||||
thread: false,
|
||||
forwarded: true,
|
||||
thread_root: None,
|
||||
});
|
||||
}
|
||||
let referenced = &d["referenced_message"];
|
||||
@@ -920,7 +921,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 })
|
||||
}
|
||||
|
||||
/// Shared by both the initial backfill and extend_history - Discord
|
||||
|
||||
@@ -157,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"];
|
||||
@@ -200,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
|
||||
}))
|
||||
@@ -368,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 };
|
||||
@@ -386,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();
|
||||
@@ -395,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::*;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -489,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);
|
||||
}
|
||||
}
|
||||
@@ -847,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);
|
||||
}
|
||||
}
|
||||
@@ -1135,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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
+260
-44
@@ -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,
|
||||
@@ -261,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,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,
|
||||
@@ -460,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.
|
||||
@@ -487,32 +527,69 @@ 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),
|
||||
})
|
||||
}
|
||||
|
||||
/// One line for something somebody did in a channel, or None if this event is
|
||||
@@ -912,14 +989,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"));
|
||||
@@ -950,7 +1032,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");
|
||||
@@ -982,7 +1064,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");
|
||||
}
|
||||
@@ -994,12 +1076,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]
|
||||
@@ -1008,10 +1090,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]
|
||||
@@ -1260,8 +1342,12 @@ mod tests {
|
||||
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_leaves_the_log() {
|
||||
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");
|
||||
@@ -1270,7 +1356,94 @@ mod tests {
|
||||
// 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" }));
|
||||
assert_eq!(testkit::lines(&state, "odablock"), vec!["stays"]);
|
||||
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]
|
||||
@@ -1341,17 +1514,60 @@ mod tests {
|
||||
|
||||
#[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(), count: None };
|
||||
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()), badges: vec![] })).color, None);
|
||||
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]
|
||||
@@ -1360,14 +1576,14 @@ mod tests {
|
||||
"original_sender": { "id": 1, "username": "Greg" },
|
||||
"original_message": { "id": "abc", "content": "hello" }
|
||||
});
|
||||
let preview = reply_preview_from_value(Some(&whole)).unwrap();
|
||||
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" } }))).is_none());
|
||||
assert!(reply_preview_from_value(Some(&serde_json::json!({ "original_sender": { "id": 1, "username": "Greg" } }))).is_none());
|
||||
assert!(reply_preview_from_value(Some(&serde_json::json!({ "original_sender": { "id": 1 }, "original_message": { "id": "abc" } }))).is_none());
|
||||
assert!(reply_preview_from_value(None).is_none());
|
||||
assert!(reply_preview_from_value(Some(&serde_json::json!("not metadata"))).is_none());
|
||||
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]
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
+15
-4
@@ -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,6 +35,7 @@ pub mod cards;
|
||||
pub mod chat;
|
||||
pub mod live;
|
||||
pub mod pins;
|
||||
pub mod realtime;
|
||||
pub mod socket;
|
||||
pub mod watch;
|
||||
#[cfg(test)]
|
||||
@@ -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)
|
||||
@@ -109,17 +119,18 @@ impl Watched {
|
||||
/// 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!({}));
|
||||
}
|
||||
}
|
||||
+132
-74
@@ -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,20 +659,24 @@ async fn prepare(
|
||||
|
||||
/// Asks the socket for everything that happens in a channel's chat.
|
||||
///
|
||||
/// Five 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.
|
||||
///
|
||||
@@ -663,7 +685,6 @@ 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
|
||||
@@ -680,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>,
|
||||
}
|
||||
|
||||
@@ -711,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.
|
||||
@@ -823,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}");
|
||||
}
|
||||
}
|
||||
@@ -883,18 +890,17 @@ mod tests {
|
||||
names.sort();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec!["channel.2401072", "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)));
|
||||
@@ -902,3 +908,55 @@ mod tests {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
+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]
|
||||
|
||||
@@ -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 }),
|
||||
_ => 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,5 @@ pub mod irc;
|
||||
pub mod kick;
|
||||
pub mod matrix;
|
||||
pub mod sneedchat;
|
||||
pub mod steam;
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
+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,436 @@
|
||||
//! Signing in to Twitch: the device code flow, and the tokens it leaves.
|
||||
//!
|
||||
//! The person never types a password into moho. Moho asks Twitch for a short
|
||||
//! code, shows it, and the person approves it on twitch.tv in their own
|
||||
//! browser, signed in however they like; moho polls until Twitch says yes and
|
||||
//! then holds two tokens:
|
||||
//!
|
||||
//! - an **access token**, which every call carries and which lasts four hours
|
||||
//! - a **refresh token**, single use, which buys the next pair and which
|
||||
//! Twitch lets go of after thirty days without one
|
||||
//!
|
||||
//! moho is a *public* client (it is a program on somebody's own computer, and
|
||||
//! cannot keep a secret), so there is no client secret anywhere in this: the
|
||||
//! device flow and the refresh both go without one, and the Client ID below
|
||||
//! is not a secret either.
|
||||
//!
|
||||
//! The functions take the server's address, so the tests can answer them.
|
||||
//! Everything here is checked against a real Twitch account in the plan's
|
||||
//! round 1; what the answers look like is from the docs
|
||||
//! (dev.twitch.tv/docs/authentication) and from watching them.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// The application registered at dev.twitch.tv for moho: a *public* client.
|
||||
pub const CLIENT_ID: &str = "ybpi52doogxn2gzsa7415i18bq0vlb";
|
||||
|
||||
/// Where Twitch's sign-in lives.
|
||||
pub const ID_ROOT: &str = "https://id.twitch.tv";
|
||||
|
||||
/// What moho asks to be allowed, and what each is for.
|
||||
///
|
||||
/// No more than the features need, and said beside each in the account form.
|
||||
/// Moderation and whispers are asked for when somebody turns them on, not
|
||||
/// here: a scope nobody can see a use for is a reason to refuse.
|
||||
pub const SCOPES: &[(&str, &str)] = &[
|
||||
("chat:read", "read chat"),
|
||||
("chat:edit", "send messages"),
|
||||
("user:write:chat", "hear why Twitch turned a message down"),
|
||||
("user:read:follows", "list the channels you follow"),
|
||||
("user:read:emotes", "show the emotes you can use"),
|
||||
];
|
||||
|
||||
/// What moderation, whispers, shoutouts, blocking and a name colour need, and what each is for.
|
||||
/// Asked for in the one sign-in with the rest, so a person signs in once and has every feature: none of
|
||||
/// it does anything in a channel where the account has no standing (Twitch checks that on every call), and
|
||||
/// what moho offers follows what the account may do there.
|
||||
pub const MODERATION_SCOPES: &[(&str, &str)] = &[
|
||||
("moderator:manage:banned_users", "time people out, ban them and let them back"),
|
||||
("moderator:manage:chat_messages", "delete messages"),
|
||||
("moderator:manage:chat_settings", "change the room's modes (slow, followers-only and the rest)"),
|
||||
("moderator:manage:announcements", "make announcements"),
|
||||
("user:manage:chat_color", "change your name colour"),
|
||||
("user:manage:whispers", "send whispers"),
|
||||
("whispers:read", "read whispers sent to you"),
|
||||
("moderator:manage:shoutouts", "give shoutouts"),
|
||||
("user:manage:blocked_users", "block and unblock people"),
|
||||
];
|
||||
|
||||
/// The scopes as Twitch wants them in a request: one string, space between.
|
||||
pub fn scope_string() -> String {
|
||||
SCOPES.iter().chain(MODERATION_SCOPES.iter()).map(|(s, _)| *s).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// Whether a set of scopes is the one that turned moderation on.
|
||||
pub fn has_moderation(scopes: &[String]) -> bool {
|
||||
scopes.iter().any(|s| s == "moderator:manage:banned_users")
|
||||
}
|
||||
|
||||
/// The code to show, and where to enter it.
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
pub struct DeviceCode {
|
||||
pub device_code: String,
|
||||
pub user_code: String,
|
||||
/// Twitch's own page, with the code already in it.
|
||||
pub verification_uri: String,
|
||||
/// How long the code can be used, in seconds.
|
||||
pub expires_in: u64,
|
||||
/// How often to ask whether it has been approved, in seconds.
|
||||
pub interval: u64,
|
||||
}
|
||||
|
||||
/// A pair of tokens, as Twitch hands them out.
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
pub struct Tokens {
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
/// Seconds from now until the access token stops working.
|
||||
pub expires_in: i64,
|
||||
#[serde(default, deserialize_with = "scopes_either_way")]
|
||||
pub scope: Vec<String>,
|
||||
}
|
||||
|
||||
/// Twitch says the scopes as a list; some of its endpoints have said them as
|
||||
/// one string. Both are read.
|
||||
fn scopes_either_way<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Vec<String>, D::Error> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Either {
|
||||
List(Vec<String>),
|
||||
Text(String),
|
||||
}
|
||||
Ok(match Option::<Either>::deserialize(d)? {
|
||||
Some(Either::List(list)) => list,
|
||||
Some(Either::Text(text)) => text.split_whitespace().map(str::to_string).collect(),
|
||||
None => Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// What asking whether the code was approved can answer.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Poll {
|
||||
/// Not yet. Ask again after the interval.
|
||||
Pending,
|
||||
/// Twitch wants asking less often.
|
||||
SlowDown,
|
||||
/// The person said no.
|
||||
Denied,
|
||||
/// The code ran out before anyone approved it.
|
||||
Expired,
|
||||
Approved(Tokens),
|
||||
}
|
||||
|
||||
/// Who a token belongs to and what it may do.
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
pub struct Validation {
|
||||
pub client_id: String,
|
||||
pub login: String,
|
||||
pub user_id: String,
|
||||
#[serde(default)]
|
||||
pub scopes: Vec<String>,
|
||||
/// Seconds the token has left.
|
||||
pub expires_in: i64,
|
||||
}
|
||||
|
||||
/// Why a token could not be renewed.
|
||||
#[derive(Debug)]
|
||||
pub enum Renewal {
|
||||
/// Twitch will not accept it any more: revoked, used twice, or thirty
|
||||
/// days old. The only answer is to sign in again.
|
||||
Refused(String),
|
||||
/// Anything else - the network, a server having a bad moment. The token
|
||||
/// may well be fine; try again later.
|
||||
Failed(anyhow::Error),
|
||||
}
|
||||
|
||||
fn form(pairs: &[(&str, &str)]) -> String {
|
||||
let mut out = url::form_urlencoded::Serializer::new(String::new());
|
||||
for (k, v) in pairs {
|
||||
out.append_pair(k, v);
|
||||
}
|
||||
out.finish()
|
||||
}
|
||||
|
||||
async fn post_form(http: &reqwest::Client, url: &str, pairs: &[(&str, &str)]) -> Result<(u16, serde_json::Value)> {
|
||||
let res = http
|
||||
.post(url)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.header("Accept", "application/json")
|
||||
.body(form(pairs))
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("reaching Twitch at {url}"))?;
|
||||
let status = res.status().as_u16();
|
||||
// An error answer is JSON too, and says why; one that is not is read as nothing.
|
||||
let body = res.json::<serde_json::Value>().await.unwrap_or(serde_json::Value::Null);
|
||||
Ok((status, body))
|
||||
}
|
||||
|
||||
fn message_of(body: &serde_json::Value) -> String {
|
||||
body.get("message").and_then(|m| m.as_str()).unwrap_or("").to_string()
|
||||
}
|
||||
|
||||
/// Asks Twitch for a code for somebody to approve.
|
||||
pub async fn start(http: &reqwest::Client, root: &str) -> Result<DeviceCode> {
|
||||
let (status, body) = post_form(http, &format!("{root}/oauth2/device"), &[("client_id", CLIENT_ID), ("scopes", &scope_string())]).await?;
|
||||
if status != 200 {
|
||||
bail!("Twitch would not start a sign-in ({status}): {}", message_of(&body));
|
||||
}
|
||||
serde_json::from_value(body).context("reading Twitch's sign-in code")
|
||||
}
|
||||
|
||||
/// Asks whether the code has been approved.
|
||||
pub async fn poll(http: &reqwest::Client, root: &str, device_code: &str) -> Result<Poll> {
|
||||
let (status, body) = post_form(
|
||||
http,
|
||||
&format!("{root}/oauth2/token"),
|
||||
&[
|
||||
("client_id", CLIENT_ID),
|
||||
("scopes", &scope_string()),
|
||||
("device_code", device_code),
|
||||
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
if status == 200 {
|
||||
return Ok(Poll::Approved(serde_json::from_value(body).context("reading the tokens Twitch gave")?));
|
||||
}
|
||||
// The refusals are the ordinary course of a sign-in, said in a message
|
||||
// rather than a status, and spelled as the standard does.
|
||||
let message = message_of(&body).to_lowercase();
|
||||
Ok(match message.as_str() {
|
||||
"authorization_pending" => Poll::Pending,
|
||||
m if m.contains("slow_down") => Poll::SlowDown,
|
||||
m if m.contains("access_denied") || m.contains("denied") => Poll::Denied,
|
||||
m if m.contains("expired") || m.contains("invalid device code") || m.contains("invalid_device_code") => Poll::Expired,
|
||||
_ => bail!("Twitch refused the sign-in ({status}): {}", message_of(&body)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Buys a new pair of tokens with the refresh token. The old pair is spent
|
||||
/// whether or not this works out, so what comes back must be kept.
|
||||
pub async fn refresh(http: &reqwest::Client, root: &str, refresh_token: &str) -> std::result::Result<Tokens, Renewal> {
|
||||
let (status, body) = match post_form(
|
||||
http,
|
||||
&format!("{root}/oauth2/token"),
|
||||
&[("client_id", CLIENT_ID), ("grant_type", "refresh_token"), ("refresh_token", refresh_token)],
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(answer) => answer,
|
||||
Err(e) => return Err(Renewal::Failed(e)),
|
||||
};
|
||||
match status {
|
||||
200 => serde_json::from_value(body).map_err(|e| Renewal::Failed(anyhow::Error::new(e).context("reading renewed tokens"))),
|
||||
// Twitch says 400 "Invalid refresh token" for one that is used, revoked or old, and 401 for the same family.
|
||||
400 | 401 => Err(Renewal::Refused(message_of(&body))),
|
||||
other => Err(Renewal::Failed(anyhow::anyhow!("Twitch answered {other} to a renewal: {}", message_of(&body)))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Who a token belongs to, or `None` when Twitch no longer accepts it.
|
||||
///
|
||||
/// Twitch asks apps to do this when they start and from time to time, and it
|
||||
/// is how the login, the id and the scopes are read: the sign-in does not say.
|
||||
pub async fn validate(http: &reqwest::Client, root: &str, access_token: &str) -> Result<Option<Validation>> {
|
||||
let res = http
|
||||
.get(format!("{root}/oauth2/validate"))
|
||||
.header("Authorization", format!("OAuth {access_token}"))
|
||||
.send()
|
||||
.await
|
||||
.context("reaching Twitch to check a sign-in")?;
|
||||
match res.status().as_u16() {
|
||||
200 => Ok(Some(res.json().await.context("reading who the token belongs to")?)),
|
||||
401 => Ok(None),
|
||||
other => bail!("Twitch answered {other} when checking a sign-in"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Tells Twitch to forget a token, so signing out here ends it there too
|
||||
/// rather than leaving it good for the rest of its four hours.
|
||||
pub async fn revoke(http: &reqwest::Client, root: &str, token: &str) -> Result<()> {
|
||||
let (status, body) = post_form(http, &format!("{root}/oauth2/revoke"), &[("client_id", CLIENT_ID), ("token", token)]).await?;
|
||||
// 400 is a token that was already gone, which is what was wanted.
|
||||
if status != 200 && status != 400 {
|
||||
bail!("Twitch answered {status} when ending a sign-in: {}", message_of(&body));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// When a token handed out `expires_in` seconds from `now` should be renewed:
|
||||
/// a little before it stops, so a call made at the edge does not meet a dead one.
|
||||
pub fn renew_at(now: i64, expires_in: i64) -> i64 {
|
||||
now + (expires_in - RENEW_BEFORE).max(0)
|
||||
}
|
||||
|
||||
/// How long before its end a token is renewed, in seconds.
|
||||
pub const RENEW_BEFORE: i64 = 300;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
/// A Twitch that answers each request with the next canned answer, and
|
||||
/// remembers what it was asked: the request line and the body.
|
||||
async fn twitch(answers: Vec<(u16, &'static str)>) -> (String, Arc<Mutex<Vec<(String, String)>>>) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let root = format!("http://{}", listener.local_addr().unwrap());
|
||||
let seen: Arc<Mutex<Vec<(String, String)>>> = Default::default();
|
||||
let log = seen.clone();
|
||||
let answers = Arc::new(Mutex::new(answers.into_iter().collect::<std::collections::VecDeque<_>>()));
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut sock, _)) = listener.accept().await else { return };
|
||||
let (log, answers) = (log.clone(), answers.clone());
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 8192];
|
||||
let mut got = 0;
|
||||
// Until the headers and as much body as it says there is.
|
||||
loop {
|
||||
let n = sock.read(&mut buf[got..]).await.unwrap_or(0);
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
got += n;
|
||||
let text = String::from_utf8_lossy(&buf[..got]).to_string();
|
||||
if let Some(end) = text.find("\r\n\r\n") {
|
||||
let length = text[..end]
|
||||
.lines()
|
||||
.find_map(|l| l.to_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0)))
|
||||
.unwrap_or(0);
|
||||
if got >= end + 4 + length {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let text = String::from_utf8_lossy(&buf[..got]).to_string();
|
||||
let (head, body) = text.split_once("\r\n\r\n").unwrap_or((&text, ""));
|
||||
log.lock().unwrap().push((head.lines().next().unwrap_or("").to_string(), body.to_string()));
|
||||
let (status, answer) = answers.lock().unwrap().pop_front().unwrap_or((500, "{}"));
|
||||
let reply = format!("HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{answer}", answer.len());
|
||||
let _ = sock.write_all(reply.as_bytes()).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
(root, seen)
|
||||
}
|
||||
|
||||
fn http() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
const CODE: &str = r#"{"device_code":"dev123","expires_in":1800,"interval":5,"user_code":"ABCDEFGH","verification_uri":"https://www.twitch.tv/activate?device-code=ABCDEFGH"}"#;
|
||||
const TOKENS: &str = r#"{"access_token":"acc","expires_in":14344,"refresh_token":"ref","scope":["chat:read","chat:edit"],"token_type":"bearer"}"#;
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_sign_in_asks_for_the_scopes_moho_needs_with_its_own_client_id() {
|
||||
let (root, seen) = twitch(vec![(200, CODE)]).await;
|
||||
let code = start(&http(), &root).await.unwrap();
|
||||
assert_eq!((code.user_code.as_str(), code.interval, code.expires_in), ("ABCDEFGH", 5, 1800));
|
||||
assert!(code.verification_uri.ends_with("device-code=ABCDEFGH"));
|
||||
let seen = seen.lock().unwrap();
|
||||
assert_eq!(seen[0].0, "POST /oauth2/device HTTP/1.1");
|
||||
assert!(seen[0].1.contains(&format!("client_id={CLIENT_ID}")), "{}", seen[0].1);
|
||||
// Space is a plus in a form, which is how Twitch reads a list of scopes.
|
||||
assert!(seen[0].1.contains("scopes=chat%3Aread+chat%3Aedit"), "{}", seen[0].1);
|
||||
// And no secret anywhere: moho is a public client.
|
||||
assert!(!seen[0].1.contains("secret"));
|
||||
// One sign-in has every feature: moderation, whispers, shoutouts and blocking are asked for with the rest.
|
||||
for scope in ["moderator%3Amanage%3Abanned_users", "whispers%3Aread", "user%3Amanage%3Awhispers", "moderator%3Amanage%3Ashoutouts", "user%3Amanage%3Ablocked_users"] {
|
||||
assert!(seen[0].1.contains(scope), "{scope} in {}", seen[0].1);
|
||||
}
|
||||
assert!(scope_string().contains("moderator:manage:banned_users") && scope_string().contains("whispers:read"));
|
||||
assert!(has_moderation(&["chat:read".to_string(), "moderator:manage:banned_users".to_string()]) && !has_moderation(&["chat:read".to_string()]));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_poll_tells_waiting_from_a_no_from_an_old_code() {
|
||||
let (root, _) = twitch(vec![
|
||||
(400, r#"{"status":400,"message":"authorization_pending"}"#),
|
||||
(400, r#"{"status":400,"message":"slow_down"}"#),
|
||||
(400, r#"{"status":400,"message":"access_denied"}"#),
|
||||
(400, r#"{"status":400,"message":"invalid device code"}"#),
|
||||
(200, TOKENS),
|
||||
])
|
||||
.await;
|
||||
let client = http();
|
||||
let ask = || poll(&client, &root, "dev123");
|
||||
assert_eq!(ask().await.unwrap(), Poll::Pending);
|
||||
assert_eq!(ask().await.unwrap(), Poll::SlowDown);
|
||||
assert_eq!(ask().await.unwrap(), Poll::Denied);
|
||||
assert_eq!(ask().await.unwrap(), Poll::Expired);
|
||||
match ask().await.unwrap() {
|
||||
Poll::Approved(t) => assert_eq!((t.access_token.as_str(), t.refresh_token.as_str(), t.scope.len()), ("acc", "ref", 2)),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_poll_names_the_grant_and_the_code() {
|
||||
let (root, seen) = twitch(vec![(400, r#"{"message":"authorization_pending"}"#)]).await;
|
||||
let _ = poll(&http(), &root, "dev123").await;
|
||||
let seen = seen.lock().unwrap();
|
||||
assert!(seen[0].1.contains("device_code=dev123"), "{}", seen[0].1);
|
||||
assert!(seen[0].1.contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"), "{}", seen[0].1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_answer_nobody_expected_is_an_error_with_twitchs_words() {
|
||||
let (root, _) = twitch(vec![(500, r#"{"message":"the sky fell"}"#)]).await;
|
||||
let e = poll(&http(), &root, "d").await.unwrap_err();
|
||||
assert!(format!("{e}").contains("the sky fell"), "{e}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refresh_returns_the_new_pair_and_a_used_one_is_refused() {
|
||||
let (root, seen) = twitch(vec![(200, TOKENS), (400, r#"{"status":400,"message":"Invalid refresh token"}"#), (503, "{}")]).await;
|
||||
let t = refresh(&http(), &root, "old").await.unwrap();
|
||||
assert_eq!(t.access_token, "acc");
|
||||
assert!(seen.lock().unwrap()[0].1.contains("grant_type=refresh_token"));
|
||||
assert!(seen.lock().unwrap()[0].1.contains("refresh_token=old"));
|
||||
match refresh(&http(), &root, "old").await {
|
||||
Err(Renewal::Refused(why)) => assert_eq!(why, "Invalid refresh token"),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
// A server having a bad moment is not a verdict on the token.
|
||||
assert!(matches!(refresh(&http(), &root, "old").await, Err(Renewal::Failed(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validating_reads_who_a_token_belongs_to_and_a_dead_one_is_none() {
|
||||
let (root, seen) = twitch(vec![
|
||||
(200, r#"{"client_id":"c","login":"someone","scopes":["chat:read"],"user_id":"42","expires_in":3600}"#),
|
||||
(401, r#"{"status":401,"message":"invalid access token"}"#),
|
||||
])
|
||||
.await;
|
||||
let v = validate(&http(), &root, "tok").await.unwrap().expect("accepted");
|
||||
assert_eq!((v.login.as_str(), v.user_id.as_str(), v.scopes.len()), ("someone", "42", 1));
|
||||
assert!(seen.lock().unwrap()[0].0.starts_with("GET /oauth2/validate"));
|
||||
assert!(validate(&http(), &root, "tok").await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn revoking_a_token_that_is_already_gone_is_fine() {
|
||||
let (root, _) = twitch(vec![(200, "{}"), (400, r#"{"message":"Invalid token"}"#), (500, "{}")]).await;
|
||||
revoke(&http(), &root, "t").await.unwrap();
|
||||
revoke(&http(), &root, "t").await.unwrap();
|
||||
assert!(revoke(&http(), &root, "t").await.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scopes_are_read_as_a_list_or_as_text() {
|
||||
let list: Tokens = serde_json::from_str(r#"{"access_token":"a","refresh_token":"r","expires_in":1,"scope":["a","b"]}"#).unwrap();
|
||||
let text: Tokens = serde_json::from_str(r#"{"access_token":"a","refresh_token":"r","expires_in":1,"scope":"a b"}"#).unwrap();
|
||||
let none: Tokens = serde_json::from_str(r#"{"access_token":"a","refresh_token":"r","expires_in":1}"#).unwrap();
|
||||
assert_eq!((list.scope, text.scope, none.scope), (vec!["a".to_string(), "b".into()], vec!["a".to_string(), "b".into()], vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_is_renewed_before_it_runs_out() {
|
||||
assert_eq!(renew_at(1000, 14400), 1000 + 14400 - RENEW_BEFORE);
|
||||
assert_eq!(renew_at(1000, 60), 1000, "a token with less time than the margin is renewed at once");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//! The pictures for the badges beside a name: Twitch's global ones and each
|
||||
//! channel's own (the subscriber badge is a different picture in every
|
||||
//! channel, and changes with the months).
|
||||
//!
|
||||
//! A message's `badges` tag names each as `set/version`; what it looks like is
|
||||
//! in a table Twitch publishes, asked for once for the global set and once per
|
||||
//! channel when it is joined. Through Twitch's GraphQL (see `gql.rs`), because
|
||||
//! the official API will answer neither without an account.
|
||||
|
||||
use super::gql;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// One badge as Twitch describes it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Picture {
|
||||
pub title: String,
|
||||
pub image: String,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Held {
|
||||
global: Option<Arc<HashMap<String, Picture>>>,
|
||||
channels: HashMap<String, Arc<HashMap<String, Picture>>>,
|
||||
}
|
||||
|
||||
fn held() -> &'static Mutex<Held> {
|
||||
static HELD: std::sync::OnceLock<Mutex<Held>> = std::sync::OnceLock::new();
|
||||
HELD.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
const GLOBAL: &str = "query{ badges { setID version title imageURL(size:DOUBLE) } }";
|
||||
const CHANNEL: &str = "query($login:String!){ user(login:$login){ broadcastBadges { setID version title imageURL(size:DOUBLE) } } }";
|
||||
|
||||
/// Reads a list of badges, whichever query it answered.
|
||||
pub fn read(list: &Value) -> HashMap<String, Picture> {
|
||||
let mut out = HashMap::new();
|
||||
for b in list.as_array().into_iter().flatten() {
|
||||
let (Some(set), Some(version), Some(image)) = (b["setID"].as_str(), b["version"].as_str(), b["imageURL"].as_str()) else { continue };
|
||||
if !image.starts_with("https://") {
|
||||
continue;
|
||||
}
|
||||
out.insert(format!("{set}/{version}"), Picture { title: b["title"].as_str().unwrap_or(set).to_string(), image: image.to_string() });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The picture for a badge in a channel: the channel's own, else the global one.
|
||||
pub fn find(channel: &str, set_and_version: &str) -> Option<Picture> {
|
||||
let held = held().lock().unwrap();
|
||||
held.channels.get(channel).and_then(|c| c.get(set_and_version)).or_else(|| held.global.as_ref().and_then(|g| g.get(set_and_version))).cloned()
|
||||
}
|
||||
|
||||
pub fn have_global() -> bool {
|
||||
held().lock().unwrap().global.is_some()
|
||||
}
|
||||
|
||||
pub fn forget(channel: &str) {
|
||||
held().lock().unwrap().channels.remove(channel);
|
||||
}
|
||||
|
||||
pub async fn load_global(http: &reqwest::Client, root: &str) {
|
||||
if have_global() {
|
||||
return;
|
||||
}
|
||||
let Ok(answer) = gql::call(http, root, &gql::query(GLOBAL, json!({})), &gql::Who::default()).await else { return };
|
||||
let table = read(&answer["data"]["badges"]);
|
||||
if !table.is_empty() {
|
||||
held().lock().unwrap().global = Some(Arc::new(table));
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn load_channel(http: &reqwest::Client, root: &str, login: &str) {
|
||||
let Ok(answer) = gql::call(http, root, &gql::query(CHANNEL, json!({ "login": login })), &gql::Who::default()).await else { return };
|
||||
let table = read(&answer["data"]["user"]["broadcastBadges"]);
|
||||
held().lock().unwrap().channels.insert(login.to_string(), Arc::new(table));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn keep_for_test(channel: Option<&str>, table: HashMap<String, Picture>) {
|
||||
let mut held = held().lock().unwrap();
|
||||
match channel {
|
||||
Some(c) => {
|
||||
held.channels.insert(c.to_string(), Arc::new(table));
|
||||
}
|
||||
None => held.global = Some(Arc::new(table)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_badge_list_is_read_by_set_and_version_and_an_address_that_is_not_https_is_refused() {
|
||||
let table = read(&json!([
|
||||
{ "setID": "subscriber", "version": "36", "title": "3-Year Subscriber", "imageURL": "https://static-cdn.jtvnw.net/badges/v1/a/2" },
|
||||
{ "setID": "moderator", "version": "1", "title": "Moderator", "imageURL": "https://static-cdn.jtvnw.net/badges/v1/b/2" },
|
||||
{ "setID": "bad", "version": "1", "title": "X", "imageURL": "file:///etc/passwd" },
|
||||
{ "setID": "no-picture", "version": "1" }
|
||||
]));
|
||||
assert_eq!(table.len(), 2);
|
||||
assert_eq!(table["subscriber/36"].title, "3-Year Subscriber");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_channels_own_picture_comes_before_the_global_one() {
|
||||
let pic = |t: &str| Picture { title: t.into(), image: format!("https://x/{t}") };
|
||||
keep_for_test(None, [("subscriber/0".to_string(), pic("global")), ("vip/1".to_string(), pic("vip"))].into_iter().collect());
|
||||
keep_for_test(Some("badgechan"), [("subscriber/0".to_string(), pic("channel"))].into_iter().collect());
|
||||
assert_eq!(find("badgechan", "subscriber/0").unwrap().title, "channel");
|
||||
assert_eq!(find("badgechan", "vip/1").unwrap().title, "vip");
|
||||
assert_eq!(find("other", "subscriber/0").unwrap().title, "global");
|
||||
assert!(find("badgechan", "no/such").is_none());
|
||||
forget("badgechan");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,207 @@
|
||||
//! The emotes of Twitch's own that a chat is written in: whether one is animated, when an account's own were
|
||||
//! last read (they change under it: a reward unlocks one, a 24-hour unlock lapses), and how a message the
|
||||
//! account sends is turned between the picture the box holds and the word Twitch reads.
|
||||
//!
|
||||
//! BetterTTV, FrankerFaceZ and 7TV are not here: they are browser extensions that draw over Twitch's chat, and
|
||||
//! Twitch itself shows those words as words, so moho does not pretend to.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Who owns emotes an account has: found out once from Twitch and kept, since a channel's name and picture change
|
||||
/// rarely and a subscription list can name dozens.
|
||||
#[derive(Clone)]
|
||||
pub struct Owner {
|
||||
pub login: String,
|
||||
pub name: String,
|
||||
pub picture: String,
|
||||
}
|
||||
|
||||
fn known_owners() -> &'static Mutex<HashMap<String, Owner>> {
|
||||
static KNOWN: std::sync::OnceLock<Mutex<HashMap<String, Owner>>> = std::sync::OnceLock::new();
|
||||
KNOWN.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// The channels these numbers belong to, by number; one whose owner Twitch would not name is not in the answer.
|
||||
pub async fn owners(http: &reqwest::Client, who: &super::helix::Caller<'_>, ids: &[String]) -> HashMap<String, Owner> {
|
||||
let missing: Vec<String> = {
|
||||
let known = known_owners().lock().unwrap();
|
||||
ids.iter().filter(|i| !known.contains_key(*i)).cloned().collect()
|
||||
};
|
||||
if !missing.is_empty() {
|
||||
let found = super::helix::users_by_id(http, who, &missing).await;
|
||||
let mut known = known_owners().lock().unwrap();
|
||||
for (id, login, name, picture) in found {
|
||||
known.insert(id, Owner { login, name, picture });
|
||||
}
|
||||
}
|
||||
let known = known_owners().lock().unwrap();
|
||||
ids.iter().filter_map(|i| known.get(i).map(|o| (i.clone(), o.clone()))).collect()
|
||||
}
|
||||
|
||||
/// When an account's own emotes in a channel were last fetched, and the channel's number (for fetching them again
|
||||
/// from anywhere): Twitch lets an emote lapse (a 24-hour unlock, a limited-time one) and gives no word of it, so the
|
||||
/// list is read again when it is asked for and has gone stale, and the picker shows only what is there.
|
||||
fn refreshed() -> &'static Mutex<HashMap<String, (std::time::Instant, String)>> {
|
||||
static REFRESHED: std::sync::OnceLock<Mutex<HashMap<String, (std::time::Instant, String)>>> = std::sync::OnceLock::new();
|
||||
REFRESHED.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// Says the account's emotes in a channel were just fetched.
|
||||
pub fn note_user_emotes(account_id: &str, channel: &str, room_id: &str) {
|
||||
refreshed().lock().unwrap().insert(format!("{account_id}|{channel}"), (std::time::Instant::now(), room_id.to_string()));
|
||||
}
|
||||
|
||||
/// The channel's number, and whether the account's emotes there are older than `max_age` (or never fetched).
|
||||
pub fn user_emotes_state(account_id: &str, channel: &str, max_age: std::time::Duration) -> (Option<String>, bool) {
|
||||
match refreshed().lock().unwrap().get(&format!("{account_id}|{channel}")) {
|
||||
Some((at, room)) => (Some(room.clone()), at.elapsed() > max_age),
|
||||
None => (None, true),
|
||||
}
|
||||
}
|
||||
|
||||
/// The words an emote token stands for: `[twitch:emote:25:Kappa]` is sent as `Kappa`.
|
||||
///
|
||||
/// The window puts a picture in the message box where an emote was picked, and what that picture is made of is
|
||||
/// this token (the same one an incoming message carries, so one piece of code draws both). Twitch reads an emote
|
||||
/// as its word, so that is what leaves.
|
||||
pub fn tokens_to_names(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut rest = text;
|
||||
while let Some(start) = rest.find("[twitch:") {
|
||||
out.push_str(&rest[..start]);
|
||||
let after = &rest[start..];
|
||||
let parsed = after.find(']').and_then(|end| {
|
||||
let mut parts = after[1..end].splitn(4, ':');
|
||||
let (_, kind, _id, name) = (parts.next()?, parts.next()?, parts.next()?, parts.next()?);
|
||||
matches!(kind, "emote" | "aemote").then_some((name, end))
|
||||
});
|
||||
match parsed {
|
||||
Some((name, end)) => {
|
||||
out.push_str(name);
|
||||
rest = &after[end + 1..];
|
||||
}
|
||||
None => {
|
||||
out.push_str("[twitch:");
|
||||
rest = &after["[twitch:".len()..];
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// What the sender's own message is shown as: each word that is one of the sender's emotes becomes its token, so
|
||||
/// the line the sender sees has the same pictures the others see (Twitch does not send a message back to the one
|
||||
/// who sent it, so nothing else would draw them). `mine` is the sender's emotes in this channel by word, as the
|
||||
/// picker holds them (the entry's id is the token); a token already in the text, from the picker, is kept.
|
||||
pub fn tokens_for_own(text: &str, mine: &HashMap<String, String>) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut word = String::new();
|
||||
let flush = |word: &mut String, out: &mut String| {
|
||||
if word.is_empty() {
|
||||
return;
|
||||
}
|
||||
match mine.get(word.as_str()) {
|
||||
Some(token) if !word.starts_with("[twitch:") => out.push_str(token),
|
||||
_ => out.push_str(word),
|
||||
}
|
||||
word.clear();
|
||||
};
|
||||
for c in text.chars() {
|
||||
if c.is_whitespace() {
|
||||
flush(&mut word, &mut out);
|
||||
out.push(c);
|
||||
} else {
|
||||
word.push(c);
|
||||
}
|
||||
}
|
||||
flush(&mut word, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Twitch's own, animated or not
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Default)]
|
||||
struct Animated {
|
||||
known: HashMap<String, bool>,
|
||||
asking: HashSet<String>,
|
||||
}
|
||||
|
||||
fn animated_cache() -> &'static Mutex<Animated> {
|
||||
static CACHE: std::sync::OnceLock<Mutex<Animated>> = std::sync::OnceLock::new();
|
||||
CACHE.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// Whether one of Twitch's own emotes is animated, if that is known yet.
|
||||
///
|
||||
/// Twitch's picture address has an animated form for those that are and answers
|
||||
/// 404 for those that are not, and the message carries no word on which, so the
|
||||
/// first time an emote is seen it is asked about, in the background, and drawn
|
||||
/// still until the answer is in; every message after that draws it moving.
|
||||
pub fn animated(id: &str) -> Option<bool> {
|
||||
let mut cache = animated_cache().lock().unwrap();
|
||||
if let Some(known) = cache.known.get(id) {
|
||||
return Some(*known);
|
||||
}
|
||||
if cache.asking.insert(id.to_string()) {
|
||||
// Only where there is something to ask with: a unit test calling this has no runtime.
|
||||
if let Ok(runtime) = tokio::runtime::Handle::try_current() {
|
||||
let id = id.to_string();
|
||||
runtime.spawn(async move {
|
||||
let yes = match super::client() {
|
||||
Ok(http) => http.head(animated_url(&id)).send().await.map(|r| r.status().is_success()).unwrap_or(false),
|
||||
Err(_) => false,
|
||||
};
|
||||
let mut cache = animated_cache().lock().unwrap();
|
||||
cache.asking.remove(&id);
|
||||
// Bounded: a long session sees a great many emotes.
|
||||
if cache.known.len() > 20_000 {
|
||||
cache.known.clear();
|
||||
}
|
||||
cache.known.insert(id, yes);
|
||||
});
|
||||
} else {
|
||||
cache.asking.remove(id);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Twitch's address for the moving form of one of its own emotes.
|
||||
pub fn animated_url(id: &str) -> String {
|
||||
format!("https://static-cdn.jtvnw.net/emoticons/v2/{id}/animated/dark/2.0")
|
||||
}
|
||||
|
||||
/// For a test: says how an emote is, as if it had been asked.
|
||||
#[cfg(test)]
|
||||
pub fn say_animated(id: &str, yes: bool) {
|
||||
animated_cache().lock().unwrap().known.insert(id.to_string(), yes);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_senders_own_line_shows_each_emote_as_a_picture_whether_it_was_picked_or_typed() {
|
||||
let mine: HashMap<String, String> = [("Kappa".to_string(), "[twitch:emote:25:Kappa]".to_string())].into_iter().collect();
|
||||
assert_eq!(tokens_for_own("hi Kappa [twitch:emote:9:Pog] plain", &mine), "hi [twitch:emote:25:Kappa] [twitch:emote:9:Pog] plain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picked_emote_leaves_as_its_word_and_nothing_else_is_touched() {
|
||||
assert_eq!(tokens_to_names("hi [twitch:emote:25:Kappa] and [twitch:emote:9:Pog]!"), "hi Kappa and Pog!");
|
||||
assert_eq!(tokens_to_names("[twitch:aemote:emotesv2_ab12:Wow]"), "Wow");
|
||||
assert_eq!(tokens_to_names("[twitch:cheer:cheer:100] stays, [twitch:bad"), "[twitch:cheer:cheer:100] stays, [twitch:bad");
|
||||
assert_eq!(tokens_to_names("plain [text] here"), "plain [text] here");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twitchs_own_animated_form_is_a_different_address_and_an_unasked_emote_is_unknown() {
|
||||
assert_eq!(animated_url("301445381"), "https://static-cdn.jtvnw.net/emoticons/v2/301445381/animated/dark/2.0");
|
||||
assert_eq!(animated("never-asked-for"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Twitch's own GraphQL, the one twitch.tv's pages are built on.
|
||||
//!
|
||||
//! It is private - Twitch does not offer it to anybody else, and it has been
|
||||
//! changed before under the clients that use it (the plan, Gitea issue #55,
|
||||
//! says why moho uses it anyway and what happens if it breaks). Three things
|
||||
//! are asked of it, none of which Twitch's official API will answer without an
|
||||
//! account: whether a channel is live and what it is called, the signed token
|
||||
//! a stream's playlist needs, and the badges a channel's chat draws.
|
||||
//!
|
||||
//! It is spoken as twitch.tv speaks it: the site's own Client ID, and queries
|
||||
//! that are either plain GraphQL or, for the player's token, the *persisted*
|
||||
//! query the player sends by hash. Answers are checked with care - a response
|
||||
//! that is not what was expected is an error to say, never a crash.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub const ROOT: &str = "https://gql.twitch.tv/gql";
|
||||
|
||||
/// The Client ID twitch.tv's own pages send. Public (it is in every page),
|
||||
/// and the only one the GraphQL endpoint accepts: it answers 400 "The
|
||||
/// Client-ID header is invalid" to moho's own.
|
||||
pub const SITE_CLIENT_ID: &str = "kimne78kx3ncx6brgo4mv6wki5h1ko";
|
||||
|
||||
/// A device id for this run, the one the site makes up for a browser: random
|
||||
/// and sent with every request, so the token Twitch signs is for one device.
|
||||
pub fn device_id() -> &'static str {
|
||||
static ID: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
ID.get_or_init(|| {
|
||||
let a = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(1);
|
||||
let b = std::process::id() as u128;
|
||||
format!("{:032x}", a.wrapping_mul(6364136223846793005).wrapping_add(b))
|
||||
})
|
||||
}
|
||||
|
||||
/// Extra things a call may carry: who is asking, and proof that it is a
|
||||
/// person's browser asking.
|
||||
#[derive(Default, Clone)]
|
||||
pub struct Who<'a> {
|
||||
/// The `auth-token` of a twitch.tv session, for a call that should be
|
||||
/// answered for that person (their Turbo, their subscriptions).
|
||||
pub web_token: Option<&'a str>,
|
||||
/// An integrity token Twitch demanded, and the device id it was minted for.
|
||||
pub integrity: Option<(&'a str, &'a str)>,
|
||||
}
|
||||
|
||||
/// Sends one query or persisted query and answers with its JSON, or says what
|
||||
/// Twitch said when it refused or reported errors.
|
||||
pub async fn call(http: &reqwest::Client, root: &str, body: &Value, who: &Who<'_>) -> Result<Value> {
|
||||
let mut req = http
|
||||
.post(root)
|
||||
.header("Client-ID", SITE_CLIENT_ID)
|
||||
// The site sends its JSON as text/plain, and so does this.
|
||||
.header("Content-Type", "text/plain;charset=UTF-8")
|
||||
.header("X-Device-Id", who.integrity.map(|(device, _)| device).unwrap_or_else(|| device_id()))
|
||||
.body(body.to_string());
|
||||
if let Some(token) = who.web_token.filter(|t| !t.is_empty()) {
|
||||
req = req.header("Authorization", format!("OAuth {token}"));
|
||||
}
|
||||
if let Some((_, token)) = who.integrity {
|
||||
req = req.header("Client-Integrity", token);
|
||||
}
|
||||
let res = req.send().await.context("reaching Twitch")?;
|
||||
let status = res.status();
|
||||
let text = res.text().await.unwrap_or_default();
|
||||
let value: Value = serde_json::from_str(&text).unwrap_or(Value::Null);
|
||||
if !status.is_success() {
|
||||
let said = value.get("message").and_then(|m| m.as_str()).unwrap_or("");
|
||||
bail!("Twitch answered {status}{}", if said.is_empty() { String::new() } else { format!(": {said}") });
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// The first error message Twitch put in an answer, if it put any.
|
||||
pub fn error_of(answer: &Value) -> Option<String> {
|
||||
answer.get("errors")?.as_array()?.first()?.get("message")?.as_str().map(str::to_string)
|
||||
}
|
||||
|
||||
/// A plain GraphQL query with variables.
|
||||
pub fn query(text: &str, variables: Value) -> Value {
|
||||
json!({ "query": text, "variables": variables })
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
//! Twitch's official API, Helix: only what moho uses of it.
|
||||
//!
|
||||
//! Sending a chat message is the first use. Over IRC a message that Twitch
|
||||
//! drops (a banned word, slow mode, a link it will not have) is dropped
|
||||
//! silently, or explained by a NOTICE that nothing ties to the message; Helix
|
||||
//! answers each message with whether it was sent, and if not, why. It also
|
||||
//! answers with the message's id, which IRC never does for one's own.
|
||||
//!
|
||||
//! Every call carries the account's access token and moho's own Client ID.
|
||||
//! The functions take the server's address, so the tests can answer them.
|
||||
|
||||
use anyhow::Context;
|
||||
use serde::Deserialize;
|
||||
|
||||
pub const API_ROOT: &str = "https://api.twitch.tv/helix";
|
||||
|
||||
/// What Twitch said about a message sent.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Sent {
|
||||
/// Twitch's id for the message: what a reply to it, or its removal, names.
|
||||
pub message_id: String,
|
||||
/// Why it was not sent, when it was not: Twitch's words.
|
||||
pub dropped: Option<String>,
|
||||
}
|
||||
|
||||
/// Why a message could not be sent by Helix at all.
|
||||
#[derive(Debug)]
|
||||
pub enum SendError {
|
||||
/// Twitch understood and said no, in words that should reach the person.
|
||||
Refused(String),
|
||||
/// It never got a hearing - the network, a server error, a token or a
|
||||
/// scope Twitch will not take for this call. Sending can fall back to IRC.
|
||||
Unavailable(anyhow::Error),
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SendAnswer {
|
||||
#[serde(default)]
|
||||
data: Vec<SendResult>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SendResult {
|
||||
#[serde(default)]
|
||||
message_id: String,
|
||||
#[serde(default)]
|
||||
is_sent: bool,
|
||||
#[serde(default)]
|
||||
drop_reason: Option<DropReason>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DropReason {
|
||||
#[serde(default)]
|
||||
code: String,
|
||||
#[serde(default)]
|
||||
message: String,
|
||||
}
|
||||
|
||||
/// Where Helix is and who is asking it.
|
||||
pub struct Caller<'a> {
|
||||
pub root: &'a str,
|
||||
pub client_id: &'a str,
|
||||
pub token: &'a str,
|
||||
}
|
||||
|
||||
/// A message to send: to which channel, as whom, saying what.
|
||||
pub struct Outgoing<'a> {
|
||||
pub broadcaster_id: &'a str,
|
||||
pub sender_id: &'a str,
|
||||
pub message: &'a str,
|
||||
/// The id of the message this answers.
|
||||
pub reply_parent: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// Sends a message as the account, optionally as a reply to one.
|
||||
pub async fn send_chat_message(http: &reqwest::Client, who: &Caller<'_>, out: &Outgoing<'_>) -> std::result::Result<Sent, SendError> {
|
||||
let mut body = serde_json::json!({ "broadcaster_id": out.broadcaster_id, "sender_id": out.sender_id, "message": out.message });
|
||||
if let Some(parent) = out.reply_parent {
|
||||
body["reply_parent_message_id"] = serde_json::json!(parent);
|
||||
}
|
||||
let res = http
|
||||
.post(format!("{}/chat/messages", who.root))
|
||||
.header("Authorization", format!("Bearer {}", who.token))
|
||||
.header("Client-Id", who.client_id)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("reaching Twitch")
|
||||
.map_err(SendError::Unavailable)?;
|
||||
let status = res.status().as_u16();
|
||||
let text = res.text().await.unwrap_or_default();
|
||||
let said = || serde_json::from_str::<serde_json::Value>(&text).ok().and_then(|v| v.get("message").and_then(|m| m.as_str()).map(str::to_string)).unwrap_or_default();
|
||||
match status {
|
||||
200 => {
|
||||
let answer: SendAnswer = serde_json::from_str(&text).context("reading Twitch's answer to a message").map_err(SendError::Unavailable)?;
|
||||
let result = answer.data.into_iter().next().ok_or_else(|| SendError::Unavailable(anyhow::anyhow!("Twitch answered a message with nothing")))?;
|
||||
let dropped = (!result.is_sent).then(|| match result.drop_reason {
|
||||
Some(r) if !r.message.is_empty() => r.message,
|
||||
Some(r) if !r.code.is_empty() => format!("Twitch did not send it ({})", r.code),
|
||||
_ => "Twitch did not send it".to_string(),
|
||||
});
|
||||
Ok(Sent { message_id: result.message_id, dropped })
|
||||
}
|
||||
// Twitch understood and said no: banned, not allowed to talk, a bad id.
|
||||
400 | 403 | 422 => Err(SendError::Refused(match said() {
|
||||
m if m.is_empty() => format!("Twitch refused the message ({status})"),
|
||||
m => m,
|
||||
})),
|
||||
// The token or what it may do is the problem, which is not a verdict on the message.
|
||||
401 => Err(SendError::Unavailable(anyhow::anyhow!("Twitch does not accept this sign-in for sending: {}", said()))),
|
||||
429 => Err(SendError::Refused("Twitch says you are sending too fast. Wait a moment.".to_string())),
|
||||
other => Err(SendError::Unavailable(anyhow::anyhow!("Twitch answered {other} to a message: {}", said()))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a call to Helix did not work.
|
||||
#[derive(Debug)]
|
||||
pub enum HelixError {
|
||||
/// Twitch understood and said no, in words that should reach the person.
|
||||
Refused(String),
|
||||
/// The token or what it was allowed to do will not do for this.
|
||||
Auth(String),
|
||||
Failed(anyhow::Error),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HelixError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
HelixError::Refused(why) => write!(f, "{why}"),
|
||||
// A reason about the account itself (no verified phone number) is not cured by signing in again.
|
||||
HelixError::Auth(why) if why.to_lowercase().contains("verified") || why.to_lowercase().contains("phone") => write!(f, "Twitch does not allow this for this account ({why}). Verify the account on twitch.tv to enable it."),
|
||||
HelixError::Auth(why) => write!(f, "Twitch does not allow this for this account{}. Sign in again from Accounts to refresh what it may do.", if why.is_empty() { String::new() } else { format!(" ({why})") }),
|
||||
HelixError::Failed(e) => write!(f, "{e:#}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One call to Helix with the account's token: the answer's JSON, or why not.
|
||||
async fn request(http: &reqwest::Client, who: &Caller<'_>, method: reqwest::Method, path: &str, query: &[(&str, &str)], body: Option<serde_json::Value>) -> Result<serde_json::Value, HelixError> {
|
||||
let mut url = format!("{}{path}", who.root);
|
||||
if !query.is_empty() {
|
||||
let mut q = url::form_urlencoded::Serializer::new(String::new());
|
||||
for (k, v) in query {
|
||||
q.append_pair(k, v);
|
||||
}
|
||||
url.push('?');
|
||||
url.push_str(&q.finish());
|
||||
}
|
||||
let mut req = http.request(method, &url).header("Authorization", format!("Bearer {}", who.token)).header("Client-Id", who.client_id);
|
||||
if let Some(body) = body {
|
||||
req = req.json(&body);
|
||||
}
|
||||
let res = req.send().await.context("reaching Twitch").map_err(HelixError::Failed)?;
|
||||
let status = res.status().as_u16();
|
||||
let text = res.text().await.unwrap_or_default();
|
||||
let value: serde_json::Value = serde_json::from_str(&text).unwrap_or(serde_json::Value::Null);
|
||||
let said = value.get("message").and_then(|m| m.as_str()).unwrap_or("").to_string();
|
||||
match status {
|
||||
200..=204 => Ok(value),
|
||||
401 => Err(HelixError::Auth(said)),
|
||||
// 403 is "you may not do that here", and is a verdict on the action when it carries Twitch's reason.
|
||||
400 | 403 | 404 | 409 | 422 | 425 => Err(HelixError::Refused(if said.is_empty() { format!("Twitch refused ({status})") } else { said })),
|
||||
429 => Err(HelixError::Refused("Twitch says you are doing that too fast. Wait a moment.".to_string())),
|
||||
other => Err(HelixError::Failed(anyhow::anyhow!("Twitch answered {other}{}", if said.is_empty() { String::new() } else { format!(": {said}") }))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Twitch's number for a login, or `None` where it has no such user.
|
||||
pub async fn user_id_of(http: &reqwest::Client, who: &Caller<'_>, login: &str) -> Result<Option<String>, HelixError> {
|
||||
let answer = request(http, who, reqwest::Method::GET, "/users", &[("login", login)], None).await?;
|
||||
Ok(answer.pointer("/data/0/id").and_then(|i| i.as_str()).map(str::to_string))
|
||||
}
|
||||
|
||||
/// Which channel and who is acting in it: what every moderation call carries.
|
||||
pub struct Room<'a> {
|
||||
pub broadcaster_id: &'a str,
|
||||
pub moderator_id: &'a str,
|
||||
}
|
||||
|
||||
/// Times somebody out for `seconds`, or bans them when there is no length.
|
||||
pub async fn ban(http: &reqwest::Client, who: &Caller<'_>, room: &Room<'_>, user_id: &str, seconds: Option<u64>, reason: &str) -> Result<(), HelixError> {
|
||||
let mut data = serde_json::json!({ "user_id": user_id });
|
||||
if let Some(seconds) = seconds {
|
||||
data["duration"] = serde_json::json!(seconds);
|
||||
}
|
||||
if !reason.is_empty() {
|
||||
data["reason"] = serde_json::json!(reason);
|
||||
}
|
||||
request(http, who, reqwest::Method::POST, "/moderation/bans", &[("broadcaster_id", room.broadcaster_id), ("moderator_id", room.moderator_id)], Some(serde_json::json!({ "data": data }))).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// Lets somebody back: ends a ban, or a timeout early.
|
||||
pub async fn unban(http: &reqwest::Client, who: &Caller<'_>, room: &Room<'_>, user_id: &str) -> Result<(), HelixError> {
|
||||
request(http, who, reqwest::Method::DELETE, "/moderation/bans", &[("broadcaster_id", room.broadcaster_id), ("moderator_id", room.moderator_id), ("user_id", user_id)], None).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// Removes one message, or - with no id - everything in the chat.
|
||||
pub async fn delete_chat(http: &reqwest::Client, who: &Caller<'_>, room: &Room<'_>, message_id: Option<&str>) -> Result<(), HelixError> {
|
||||
let mut query = vec![("broadcaster_id", room.broadcaster_id), ("moderator_id", room.moderator_id)];
|
||||
if let Some(id) = message_id {
|
||||
query.push(("message_id", id));
|
||||
}
|
||||
request(http, who, reqwest::Method::DELETE, "/moderation/chat", &query, None).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// Changes the chat's modes: only the fields given are changed.
|
||||
pub async fn chat_settings(http: &reqwest::Client, who: &Caller<'_>, room: &Room<'_>, patch: serde_json::Value) -> Result<(), HelixError> {
|
||||
request(http, who, reqwest::Method::PATCH, "/chat/settings", &[("broadcaster_id", room.broadcaster_id), ("moderator_id", room.moderator_id)], Some(patch)).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// Makes an announcement in the channel, in one of Twitch's colours.
|
||||
pub async fn announce(http: &reqwest::Client, who: &Caller<'_>, room: &Room<'_>, message: &str, color: &str) -> Result<(), HelixError> {
|
||||
request(http, who, reqwest::Method::POST, "/chat/announcements", &[("broadcaster_id", room.broadcaster_id), ("moderator_id", room.moderator_id)], Some(serde_json::json!({ "message": message, "color": color }))).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// Changes the account's own name colour: one of Twitch's names, or a `#rrggbb` where the account may.
|
||||
pub async fn set_color(http: &reqwest::Client, who: &Caller<'_>, user_id: &str, color: &str) -> Result<(), HelixError> {
|
||||
request(http, who, reqwest::Method::PUT, "/chat/color", &[("user_id", user_id), ("color", color)], None).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// Blocks somebody (for the account, on Twitch's side, everywhere).
|
||||
pub async fn block(http: &reqwest::Client, who: &Caller<'_>, user_id: &str) -> Result<(), HelixError> {
|
||||
request(http, who, reqwest::Method::PUT, "/users/blocks", &[("target_user_id", user_id)], None).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// Lets somebody through again.
|
||||
pub async fn unblock(http: &reqwest::Client, who: &Caller<'_>, user_id: &str) -> Result<(), HelixError> {
|
||||
request(http, who, reqwest::Method::DELETE, "/users/blocks", &[("target_user_id", user_id)], None).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// Points the channel's viewers at another channel, in chat.
|
||||
pub async fn shoutout(http: &reqwest::Client, who: &Caller<'_>, room: &Room<'_>, to_id: &str) -> Result<(), HelixError> {
|
||||
request(http, who, reqwest::Method::POST, "/chat/shoutouts", &[("from_broadcaster_id", room.broadcaster_id), ("to_broadcaster_id", to_id), ("moderator_id", room.moderator_id)], None).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// Sends a whisper.
|
||||
pub async fn whisper(http: &reqwest::Client, who: &Caller<'_>, from_id: &str, to_id: &str, message: &str) -> Result<(), HelixError> {
|
||||
request(http, who, reqwest::Method::POST, "/whispers", &[("from_user_id", from_id), ("to_user_id", to_id)], Some(serde_json::json!({ "message": message }))).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// One emote an account can use in a channel.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct UserEmote {
|
||||
/// The broadcaster who owns it: a channel's number, or empty / `twitch` for Twitch's own.
|
||||
pub owner: String,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub animated: bool,
|
||||
}
|
||||
|
||||
/// The emotes an account can use in a channel: Twitch's global ones, the
|
||||
/// channel's, and any the account has from elsewhere (a subscription to
|
||||
/// another streamer's that carries over, a follower emote). Needs the
|
||||
/// `user:read:emotes` scope.
|
||||
pub async fn user_emotes(http: &reqwest::Client, who: &Caller<'_>, user_id: &str, broadcaster_id: &str, cap: usize) -> anyhow::Result<Vec<UserEmote>> {
|
||||
let mut out: Vec<UserEmote> = Vec::new();
|
||||
let mut after = String::new();
|
||||
loop {
|
||||
let mut url = format!("{}/chat/emotes/user?user_id={user_id}&broadcaster_id={broadcaster_id}", who.root);
|
||||
if !after.is_empty() {
|
||||
url.push_str(&format!("&after={after}"));
|
||||
}
|
||||
let res = http
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", who.token))
|
||||
.header("Client-Id", who.client_id)
|
||||
.send()
|
||||
.await
|
||||
.context("reaching Twitch for the emotes you can use")?;
|
||||
let status = res.status();
|
||||
let body: serde_json::Value = res.json().await.unwrap_or(serde_json::Value::Null);
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("Twitch would not list your emotes ({status})");
|
||||
}
|
||||
let template = body.get("template").and_then(|t| t.as_str()).unwrap_or("https://static-cdn.jtvnw.net/emoticons/v2/{{id}}/{{format}}/{{theme_mode}}/{{scale}}");
|
||||
for e in body.get("data").and_then(|d| d.as_array()).into_iter().flatten() {
|
||||
let (Some(id), Some(name)) = (e.get("id").and_then(|i| i.as_str()), e.get("name").and_then(|n| n.as_str())) else { continue };
|
||||
let animated = e.get("format").and_then(|f| f.as_array()).is_some_and(|f| f.iter().any(|x| x == "animated"));
|
||||
// The still form at twice the size: a cell in the picker is small, and the window animates by token.
|
||||
let url = template.replace("{{id}}", id).replace("{{format}}", "static").replace("{{theme_mode}}", "dark").replace("{{scale}}", "2.0");
|
||||
let owner = e.get("owner_id").and_then(|o| o.as_str()).unwrap_or("").to_string();
|
||||
out.push(UserEmote { owner, id: id.to_string(), name: name.to_string(), url, animated });
|
||||
}
|
||||
after = body.pointer("/pagination/cursor").and_then(|c| c.as_str()).unwrap_or("").to_string();
|
||||
if after.is_empty() || out.len() >= cap {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.truncate(cap);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Every emote a channel has of its own (subscriber tiers, Bits tiers, follower emotes), whether or not an account
|
||||
/// can use them: what the picker greys out is what is in here and not in `user_emotes`.
|
||||
pub async fn channel_emotes(http: &reqwest::Client, who: &Caller<'_>, broadcaster_id: &str) -> anyhow::Result<Vec<UserEmote>> {
|
||||
let res = http
|
||||
.get(format!("{}/chat/emotes?broadcaster_id={broadcaster_id}", who.root))
|
||||
.header("Authorization", format!("Bearer {}", who.token))
|
||||
.header("Client-Id", who.client_id)
|
||||
.send()
|
||||
.await
|
||||
.context("reaching Twitch for the channel's emotes")?;
|
||||
let status = res.status();
|
||||
let body: serde_json::Value = res.json().await.unwrap_or(serde_json::Value::Null);
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("Twitch would not list the channel's emotes ({status})");
|
||||
}
|
||||
let template = body.get("template").and_then(|t| t.as_str()).unwrap_or("https://static-cdn.jtvnw.net/emoticons/v2/{{id}}/{{format}}/{{theme_mode}}/{{scale}}");
|
||||
Ok(body
|
||||
.get("data")
|
||||
.and_then(|d| d.as_array())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let (id, name) = (e.get("id")?.as_str()?, e.get("name")?.as_str()?);
|
||||
let animated = e.get("format").and_then(|f| f.as_array()).is_some_and(|f| f.iter().any(|x| x == "animated"));
|
||||
let url = template.replace("{{id}}", id).replace("{{format}}", "static").replace("{{theme_mode}}", "dark").replace("{{scale}}", "2.0");
|
||||
let owner = e.get("owner_id").and_then(|o| o.as_str()).unwrap_or("").to_string();
|
||||
Some(UserEmote { owner, id: id.to_string(), name: name.to_string(), url, animated })
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Who some accounts are: (id, login, name shown, picture), as many as are found. A hundred to a request.
|
||||
pub async fn users_by_id(http: &reqwest::Client, who: &Caller<'_>, ids: &[String]) -> Vec<(String, String, String, String)> {
|
||||
let mut out = Vec::new();
|
||||
for chunk in ids.chunks(100) {
|
||||
let query: Vec<(&str, &str)> = chunk.iter().map(|i| ("id", i.as_str())).collect();
|
||||
let Ok(answer) = request(http, who, reqwest::Method::GET, "/users", &query, None).await else { continue };
|
||||
for u in answer.get("data").and_then(|d| d.as_array()).into_iter().flatten() {
|
||||
let text = |k: &str| u.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
out.push((text("id"), text("login"), text("display_name"), text("profile_image_url")));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The channels an account follows, by login: only the ones live now when
|
||||
/// `live_only` (one request), otherwise all of them up to `cap`, a hundred a page.
|
||||
///
|
||||
/// Needs the `user:read:follows` scope. A 401 means the token will not do
|
||||
/// for it and is said as such, since the likely cause is an account that
|
||||
/// signed in before the scope was asked for.
|
||||
pub async fn followed(http: &reqwest::Client, who: &Caller<'_>, user_id: &str, live_only: bool, cap: usize) -> anyhow::Result<Vec<String>> {
|
||||
let mut logins: Vec<String> = Vec::new();
|
||||
let mut after = String::new();
|
||||
loop {
|
||||
let mut url = format!("{}/{}?user_id={user_id}&first=100", who.root, if live_only { "streams/followed" } else { "channels/followed" });
|
||||
if !after.is_empty() {
|
||||
url.push_str(&format!("&after={after}"));
|
||||
}
|
||||
let res = http
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", who.token))
|
||||
.header("Client-Id", who.client_id)
|
||||
.send()
|
||||
.await
|
||||
.context("reaching Twitch for the channels you follow")?;
|
||||
let status = res.status();
|
||||
let body: serde_json::Value = res.json().await.unwrap_or(serde_json::Value::Null);
|
||||
if !status.is_success() {
|
||||
let said = body.get("message").and_then(|m| m.as_str()).unwrap_or("");
|
||||
anyhow::bail!("Twitch would not list the channels you follow ({status}){}. Signing in to Twitch again from Accounts may fix it.", if said.is_empty() { String::new() } else { format!(": {said}") });
|
||||
}
|
||||
for row in body.get("data").and_then(|d| d.as_array()).into_iter().flatten() {
|
||||
if let Some(login) = row.get("broadcaster_login").or_else(|| row.get("user_login")).and_then(|l| l.as_str()) {
|
||||
logins.push(login.to_lowercase());
|
||||
}
|
||||
}
|
||||
after = body.pointer("/pagination/cursor").and_then(|c| c.as_str()).unwrap_or("").to_string();
|
||||
// The live list is one page of a hundred, which is every channel a person follows that is on air at once.
|
||||
if live_only || after.is_empty() || logins.len() >= cap {
|
||||
break;
|
||||
}
|
||||
}
|
||||
logins.truncate(cap);
|
||||
Ok(logins)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
async fn twitch(answers: Vec<(u16, &'static str)>) -> (String, Arc<Mutex<Vec<String>>>) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let root = format!("http://{}", listener.local_addr().unwrap());
|
||||
let seen: Arc<Mutex<Vec<String>>> = Default::default();
|
||||
let log = seen.clone();
|
||||
let answers = Arc::new(Mutex::new(answers.into_iter().collect::<std::collections::VecDeque<_>>()));
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut sock, _)) = listener.accept().await else { return };
|
||||
let (log, answers) = (log.clone(), answers.clone());
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 8192];
|
||||
let mut got = 0;
|
||||
loop {
|
||||
let n = sock.read(&mut buf[got..]).await.unwrap_or(0);
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
got += n;
|
||||
let text = String::from_utf8_lossy(&buf[..got]).to_string();
|
||||
if let Some(end) = text.find("\r\n\r\n") {
|
||||
let length = text[..end].lines().find_map(|l| l.to_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))).unwrap_or(0);
|
||||
if got >= end + 4 + length {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
log.lock().unwrap().push(String::from_utf8_lossy(&buf[..got]).to_string());
|
||||
let (status, body) = answers.lock().unwrap().pop_front().unwrap_or((500, "{}"));
|
||||
let reply = format!("HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
|
||||
let _ = sock.write_all(reply.as_bytes()).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
(root, seen)
|
||||
}
|
||||
|
||||
async fn send(root: &str, reply: Option<&str>) -> std::result::Result<Sent, SendError> {
|
||||
let who = Caller { root, client_id: "cid", token: "tok" };
|
||||
let out = Outgoing { broadcaster_id: "100", sender_id: "42", message: "hello", reply_parent: reply };
|
||||
send_chat_message(&reqwest::Client::new(), &who, &out).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_sent_message_comes_back_with_its_id_and_carries_the_account_and_client() {
|
||||
let (root, seen) = twitch(vec![(200, r#"{"data":[{"message_id":"abc","is_sent":true}]}"#)]).await;
|
||||
let sent = send(&root, Some("parent-1")).await.unwrap();
|
||||
assert_eq!(sent, Sent { message_id: "abc".into(), dropped: None });
|
||||
let request = seen.lock().unwrap()[0].clone();
|
||||
assert!(request.starts_with("POST /chat/messages"), "{request}");
|
||||
assert!(request.to_lowercase().contains("authorization: bearer tok"));
|
||||
assert!(request.to_lowercase().contains("client-id: cid"));
|
||||
assert!(request.contains(r#""broadcaster_id":"100""#) && request.contains(r#""sender_id":"42""#) && request.contains(r#""reply_parent_message_id":"parent-1""#), "{request}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_message_twitch_drops_says_why() {
|
||||
let (root, _) = twitch(vec![
|
||||
(200, r#"{"data":[{"message_id":"","is_sent":false,"drop_reason":{"code":"msg_duplicate","message":"Your message was not sent because it is identical to the previous one you sent, less than 30 seconds ago."}}]}"#),
|
||||
(200, r#"{"data":[{"message_id":"","is_sent":false,"drop_reason":{"code":"x","message":""}}]}"#),
|
||||
])
|
||||
.await;
|
||||
assert!(send(&root, None).await.unwrap().dropped.unwrap().contains("identical to the previous one"));
|
||||
assert_eq!(send(&root, None).await.unwrap().dropped.as_deref(), Some("Twitch did not send it (x)"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_no_is_a_refusal_and_a_bad_token_is_a_reason_to_try_another_way() {
|
||||
let (root, _) = twitch(vec![
|
||||
(403, r#"{"error":"Forbidden","status":403,"message":"The sender is not permitted to send chat messages in this channel."}"#),
|
||||
(401, r#"{"message":"Missing scope: user:write:chat"}"#),
|
||||
(429, "{}"),
|
||||
(500, "{}"),
|
||||
])
|
||||
.await;
|
||||
match send(&root, None).await {
|
||||
Err(SendError::Refused(why)) => assert!(why.contains("not permitted"), "{why}"),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
assert!(matches!(send(&root, None).await, Err(SendError::Unavailable(_))));
|
||||
assert!(matches!(send(&root, None).await, Err(SendError::Refused(_))));
|
||||
assert!(matches!(send(&root, None).await, Err(SendError::Unavailable(_))));
|
||||
assert!(matches!(send("http://127.0.0.1:9", None).await, Err(SendError::Unavailable(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_channels_somebody_follows_are_listed_page_by_page_up_to_a_cap_and_the_live_ones_in_one_request() {
|
||||
let (root, seen) = twitch(vec![
|
||||
(200, r#"{"data":[{"broadcaster_login":"Alpha"},{"broadcaster_login":"beta"}],"pagination":{"cursor":"next-page"}}"#),
|
||||
(200, r#"{"data":[{"broadcaster_login":"gamma"}],"pagination":{}}"#),
|
||||
(200, r#"{"data":[{"user_login":"Live1"},{"user_login":"live2"}],"pagination":{"cursor":"ignored"}}"#),
|
||||
(200, r#"{"data":[{"broadcaster_login":"a"},{"broadcaster_login":"b"},{"broadcaster_login":"c"}],"pagination":{"cursor":"more"}}"#),
|
||||
(401, r#"{"message":"Missing scope: user:read:follows"}"#),
|
||||
])
|
||||
.await;
|
||||
let who = Caller { root: &root, client_id: "cid", token: "tok" };
|
||||
let client = reqwest::Client::new();
|
||||
assert_eq!(followed(&client, &who, "42", false, 200).await.unwrap(), vec!["alpha", "beta", "gamma"]);
|
||||
let asked = seen.lock().unwrap().clone();
|
||||
assert!(asked[0].starts_with("GET /channels/followed?user_id=42&first=100 "), "{}", asked[0]);
|
||||
assert!(asked[1].contains("after=next-page"), "{}", asked[1]);
|
||||
assert_eq!(followed(&client, &who, "42", true, 200).await.unwrap(), vec!["live1", "live2"]);
|
||||
assert!(seen.lock().unwrap()[2].starts_with("GET /streams/followed?"), "the live list is its own call, once");
|
||||
assert_eq!(followed(&client, &who, "42", false, 2).await.unwrap(), vec!["a", "b"], "cut at the cap, and no further page asked for");
|
||||
let e = followed(&client, &who, "42", false, 200).await.unwrap_err();
|
||||
assert!(e.to_string().contains("Missing scope") && e.to_string().contains("Signing in"), "{e}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_emotes_somebody_may_use_in_a_channel_come_with_their_pictures_and_whether_they_move() {
|
||||
let (root, seen) = twitch(vec![
|
||||
(200, r#"{"data":[{"id":"25","name":"Kappa","emote_type":"globals","format":["static"]},{"id":"emotesv2_abc","name":"Dance","emote_type":"subscriptions","format":["static","animated"]}],"template":"https://static-cdn.jtvnw.net/emoticons/v2/{{id}}/{{format}}/{{theme_mode}}/{{scale}}","pagination":{"cursor":"p2"}}"#),
|
||||
(200, r#"{"data":[{"id":"9","name":"Last","format":["static"]}],"pagination":{}}"#),
|
||||
(401, "{}"),
|
||||
])
|
||||
.await;
|
||||
let who = Caller { root: &root, client_id: "cid", token: "tok" };
|
||||
let client = reqwest::Client::new();
|
||||
let got = user_emotes(&client, &who, "42", "100", 1000).await.unwrap();
|
||||
assert_eq!(got.iter().map(|e| (e.name.as_str(), e.animated)).collect::<Vec<_>>(), vec![("Kappa", false), ("Dance", true), ("Last", false)]);
|
||||
assert_eq!(got[0].url, "https://static-cdn.jtvnw.net/emoticons/v2/25/static/dark/2.0");
|
||||
assert!(seen.lock().unwrap()[0].starts_with("GET /chat/emotes/user?user_id=42&broadcaster_id=100 "));
|
||||
assert!(seen.lock().unwrap()[1].contains("after=p2"));
|
||||
assert!(user_emotes(&client, &who, "42", "100", 1000).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_owners_of_emotes_are_named_with_their_pictures() {
|
||||
let (root, seen) = twitch(vec![(200, r#"{"data":[{"id":"7","login":"skitzy_va","display_name":"Skitzy_VA","profile_image_url":"https://img/s.png"}]}"#)]).await;
|
||||
let who = Caller { root: &root, client_id: "cid", token: "tok" };
|
||||
let got = users_by_id(&reqwest::Client::new(), &who, &["7".to_string(), "8".to_string()]).await;
|
||||
assert_eq!(got, vec![("7".to_string(), "skitzy_va".to_string(), "Skitzy_VA".to_string(), "https://img/s.png".to_string())]);
|
||||
assert!(seen.lock().unwrap()[0].starts_with("GET /users?id=7&id=8 "), "{}", seen.lock().unwrap()[0]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_channels_own_emotes_are_listed_whether_or_not_they_can_be_used() {
|
||||
let (root, seen) = twitch(vec![
|
||||
(200, r#"{"data":[{"id":"emotesv2_a","name":"binxSub","emote_type":"subscriptions","format":["static","animated"]},{"id":"7","name":"binxFollow","emote_type":"follower","format":["static"]}],"template":"https://static-cdn.jtvnw.net/emoticons/v2/{{id}}/{{format}}/{{theme_mode}}/{{scale}}"}"#),
|
||||
(401, "{}"),
|
||||
])
|
||||
.await;
|
||||
let who = Caller { root: &root, client_id: "cid", token: "tok" };
|
||||
let client = reqwest::Client::new();
|
||||
let got = channel_emotes(&client, &who, "100").await.unwrap();
|
||||
assert_eq!(got.iter().map(|e| (e.name.as_str(), e.animated)).collect::<Vec<_>>(), vec![("binxSub", true), ("binxFollow", false)]);
|
||||
assert_eq!(got[1].url, "https://static-cdn.jtvnw.net/emoticons/v2/7/static/dark/2.0");
|
||||
assert!(seen.lock().unwrap()[0].starts_with("GET /chat/emotes?broadcaster_id=100 "));
|
||||
assert!(channel_emotes(&client, &who, "100").await.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//! A channel's recent past.
|
||||
//!
|
||||
//! Twitch has no chat history: a client that joins a channel sees it from that
|
||||
//! moment. The usual answer, Chatterino's among others, is a third-party
|
||||
//! service that keeps the last few hundred lines of every channel it has been
|
||||
//! asked about and hands them back as the raw IRC lines Twitch sent
|
||||
//! (`recent-messages.robotty.de`). They go through the same reading as live
|
||||
//! ones, recorded under Twitch's own message ids so that one already heard is
|
||||
//! not shown twice, and timed by Twitch's own timestamps so they sort before
|
||||
//! the present.
|
||||
//!
|
||||
//! It is somebody else's server being told which channel is being opened, so
|
||||
//! it is off until the person turns it on (Settings, Twitch), and goes through
|
||||
//! the account's route like every other request.
|
||||
|
||||
use super::chat::{self, Rooms};
|
||||
use super::irc;
|
||||
use crate::state::AppState;
|
||||
use anyhow::{bail, Context, Result};
|
||||
|
||||
/// The service, and how many lines to ask for.
|
||||
pub const ROOT: &str = "https://recent-messages.robotty.de/api/v2/recent-messages";
|
||||
const LIMIT: usize = 400;
|
||||
|
||||
/// What the service answers.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Answer {
|
||||
#[serde(default)]
|
||||
messages: Vec<String>,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// Reads a channel's recent lines out of the service's answer into the
|
||||
/// scrollback. Only what was said - messages, and subscriptions and raids
|
||||
/// announced - and not what the room did then (a ban, a mode, a notice),
|
||||
/// which is past and would be wrong to apply now.
|
||||
pub async fn load(state: &AppState, account_id: &str, channel: &str, root: &str) -> Result<usize> {
|
||||
let http = super::client()?;
|
||||
let res = http
|
||||
.get(format!("{root}/{channel}?limit={LIMIT}&hide_moderated_messages=true&clearchat_to_notice=false"))
|
||||
.send()
|
||||
.await
|
||||
.context("reaching the recent-messages service")?;
|
||||
let status = res.status();
|
||||
let answer: Answer = res.json().await.unwrap_or(Answer { messages: Vec::new(), error: None });
|
||||
if !status.is_success() {
|
||||
bail!("the recent-messages service answered {status}{}", answer.error.map(|e| format!(": {e}")).unwrap_or_default());
|
||||
}
|
||||
let cfg = state.accounts.get_twitch(account_id);
|
||||
let mut rooms = Rooms::new(cfg.as_ref().map(|c| c.login.as_str()).unwrap_or(""), "");
|
||||
let mut read = 0;
|
||||
for raw in &answer.messages {
|
||||
let Some(line) = irc::parse(raw) else { continue };
|
||||
if line.channel().as_deref() != Some(channel) {
|
||||
continue;
|
||||
}
|
||||
if matches!(line.command.as_str(), "PRIVMSG" | "USERNOTICE") {
|
||||
chat::handle(state, account_id, &mut rooms, &line, true);
|
||||
read += 1;
|
||||
}
|
||||
}
|
||||
Ok(read)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::backend::kick::testkit;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
async fn service(body: String, status: u16) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let root = format!("http://{}", listener.local_addr().unwrap());
|
||||
let seen: std::sync::Arc<std::sync::Mutex<Vec<String>>> = Default::default();
|
||||
let log = seen.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut sock, _)) = listener.accept().await else { return };
|
||||
let (log, body) = (log.clone(), body.clone());
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let n = sock.read(&mut buf).await.unwrap_or(0);
|
||||
log.lock().unwrap().push(String::from_utf8_lossy(&buf[..n]).lines().next().unwrap_or("").to_string());
|
||||
let reply = format!("HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
|
||||
let _ = sock.write_all(reply.as_bytes()).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
(root, seen)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_past_is_read_as_the_present_was_and_what_is_already_here_is_not_doubled() {
|
||||
let state = testkit::simulated_daemon("twitch-history");
|
||||
state.accounts.add_twitch(crate::accounts::TwitchAccountConfig { anonymous: true, ..Default::default() }).unwrap();
|
||||
let lines = [
|
||||
"@display-name=Old;id=h1;room-id=1;tmi-sent-ts=1791949000000;user-id=5;historical=1 :[email protected] PRIVMSG #xqc :said a while ago",
|
||||
"@display-name=Fan;id=h2;room-id=1;tmi-sent-ts=1791949001000;user-id=6;historical=1 :[email protected] PRIVMSG #xqc :still here",
|
||||
"@display-name=Fan;login=fan;msg-id=sub;room-id=1;tmi-sent-ts=1791949002000;user-id=6 :tmi.twitch.tv USERNOTICE #xqc",
|
||||
"@room-id=1;target-user-id=5 :tmi.twitch.tv CLEARCHAT #xqc :old",
|
||||
"@emote-only=1;room-id=1 :tmi.twitch.tv ROOMSTATE #xqc",
|
||||
"@display-name=Elsewhere;id=h9;room-id=2;tmi-sent-ts=1791949003000;user-id=7 :[email protected] PRIVMSG #other :another channel",
|
||||
];
|
||||
let body = serde_json::json!({ "messages": lines, "error": null }).to_string();
|
||||
let (root, seen) = service(body, 200).await;
|
||||
// One of them was already heard live.
|
||||
state.runtime.record_message_at(&state, "twitch:~anonymous", "xqc", "channel", "Fan", "still here", false, "chat", None, Some("h2".into()), false, None, vec![], vec![], None, Some(1791949001), None, None);
|
||||
|
||||
let read = load(&state, "twitch:~anonymous", "xqc", &root).await.unwrap();
|
||||
assert_eq!(read, 3, "two messages and a subscription; not the ban, the mode or another channel");
|
||||
assert!(seen.lock().unwrap()[0].contains("/xqc?limit="), "{seen:?}");
|
||||
let log = state.store.get_backlog("twitch:~anonymous|xqc", 0, 20).unwrap();
|
||||
let bodies: Vec<&str> = log.iter().filter(|m| m.kind == "chat").map(|m| m.body.as_str()).collect();
|
||||
assert_eq!(bodies, vec!["said a while ago", "still here"], "in the order they were said, with the one heard live not repeated");
|
||||
assert!(log.iter().all(|m| m.moderated.is_none()), "an old ban is not applied now");
|
||||
assert!(state.runtime.get_presence("twitch:~anonymous|xqc").is_none(), "the past does not make the roster");
|
||||
assert_eq!(state.runtime.get_buffer("twitch:~anonymous|xqc").unwrap().channel_modes, None, "nor the modes");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_service_that_will_not_answer_is_an_error_and_nothing_else() {
|
||||
let state = testkit::simulated_daemon("twitch-history-down");
|
||||
let (root, _) = service(r#"{"messages":[],"error":"Channel not joined yet"}"#.to_string(), 404).await;
|
||||
let e = load(&state, "twitch:~anonymous", "xqc", &root).await.unwrap_err();
|
||||
assert!(format!("{e}").contains("404") && format!("{e}").contains("Channel not joined yet"), "{e}");
|
||||
assert!(load(&state, "twitch:~anonymous", "xqc", "http://127.0.0.1:9").await.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//! Twitch's IRC, as a line: tags, a prefix, a command and its parameters.
|
||||
//!
|
||||
//! Twitch speaks IRC with IRCv3 message tags - `@key=value;key=value` ahead of
|
||||
//! the line - and nearly everything worth knowing about a message is in them:
|
||||
//! who it was, what colour, what badges, which parts are emotes, what it was
|
||||
//! a reply to. This reads one line into that shape and does nothing else; what
|
||||
//! the lines mean is `chat.rs`'s. Written against lines captured from live
|
||||
//! channels (the tests keep some), and tolerant of what it does not know: a tag
|
||||
//! it has no use for is carried along, and a line it cannot read is nothing
|
||||
//! rather than an error, because one odd line must not end a connection.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// One line from Twitch.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct Line {
|
||||
pub tags: HashMap<String, String>,
|
||||
/// Who sent it: `nick!user@host`, or a server name.
|
||||
pub prefix: Option<String>,
|
||||
pub command: String,
|
||||
/// Everything after the command; the trailing parameter (the one that may
|
||||
/// hold spaces, after `:`) is last.
|
||||
pub params: Vec<String>,
|
||||
}
|
||||
|
||||
impl Line {
|
||||
/// A tag's value, or an empty string where it is absent or empty - which
|
||||
/// Twitch writes the same way (`emotes=` and no `emotes` tag are one thing).
|
||||
pub fn tag(&self, key: &str) -> &str {
|
||||
self.tags.get(key).map(String::as_str).unwrap_or("")
|
||||
}
|
||||
|
||||
/// The nick the line is from, out of `nick!user@host`.
|
||||
pub fn nick(&self) -> Option<&str> {
|
||||
let prefix = self.prefix.as_deref()?;
|
||||
Some(prefix.split('!').next().unwrap_or(prefix)).filter(|n| !n.is_empty())
|
||||
}
|
||||
|
||||
/// The channel it is about, without the `#`, lower-cased as Twitch's are.
|
||||
pub fn channel(&self) -> Option<String> {
|
||||
self.params.first().and_then(|p| p.strip_prefix('#')).map(|c| c.to_lowercase())
|
||||
}
|
||||
|
||||
/// The last parameter: what was said, for a message.
|
||||
pub fn text(&self) -> &str {
|
||||
if self.params.len() > 1 { self.params.last().map(String::as_str).unwrap_or("") } else { "" }
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a tag's value as the IRCv3 spec escapes it.
|
||||
///
|
||||
/// `\:` is `;`, `\s` a space, `\\` a backslash, `\r` and `\n` the controls; a
|
||||
/// backslash before anything else is dropped and the character kept, and a
|
||||
/// lone one at the end is dropped. A reply quotes the message it answers in a
|
||||
/// tag, so this is what turns `hello\sthere` back into "hello there".
|
||||
pub fn unescape(value: &str) -> String {
|
||||
let mut out = String::with_capacity(value.len());
|
||||
let mut chars = value.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c != '\\' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
match chars.next() {
|
||||
Some(':') => out.push(';'),
|
||||
Some('s') => out.push(' '),
|
||||
Some('r') => out.push('\r'),
|
||||
Some('n') => out.push('\n'),
|
||||
Some('\\') => out.push('\\'),
|
||||
Some(other) => out.push(other),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Reads one line, without its line ending. `None` for a line with no command.
|
||||
pub fn parse(raw: &str) -> Option<Line> {
|
||||
let mut rest = raw.trim_end_matches(['\r', '\n']);
|
||||
let mut line = Line::default();
|
||||
|
||||
if let Some(after) = rest.strip_prefix('@') {
|
||||
let (tags, tail) = after.split_once(' ')?;
|
||||
for tag in tags.split(';').filter(|t| !t.is_empty()) {
|
||||
let (key, value) = tag.split_once('=').unwrap_or((tag, ""));
|
||||
line.tags.insert(key.to_string(), unescape(value));
|
||||
}
|
||||
rest = tail.trim_start();
|
||||
}
|
||||
if let Some(after) = rest.strip_prefix(':') {
|
||||
let (prefix, tail) = after.split_once(' ').unwrap_or((after, ""));
|
||||
line.prefix = Some(prefix.to_string());
|
||||
rest = tail.trim_start();
|
||||
}
|
||||
let (command, mut tail) = rest.split_once(' ').unwrap_or((rest, ""));
|
||||
if command.is_empty() {
|
||||
return None;
|
||||
}
|
||||
line.command = command.to_string();
|
||||
|
||||
while !tail.is_empty() {
|
||||
if let Some(trailing) = tail.strip_prefix(':') {
|
||||
line.params.push(trailing.to_string());
|
||||
break;
|
||||
}
|
||||
let (param, next) = tail.split_once(' ').unwrap_or((tail, ""));
|
||||
line.params.push(param.to_string());
|
||||
tail = next.trim_start();
|
||||
}
|
||||
Some(line)
|
||||
}
|
||||
|
||||
/// What a client sends to get in: the capabilities that put the tags on the
|
||||
/// lines, then the password and the nick. In that order - Twitch wants the
|
||||
/// capability request first.
|
||||
pub fn greeting(login: &str, token: Option<&str>) -> Vec<String> {
|
||||
// Any password does for an anonymous reader, and a `justinfan` nick marks it.
|
||||
let (nick, pass) = match token {
|
||||
Some(token) => (login.to_string(), format!("oauth:{token}")),
|
||||
None => (format!("justinfan{}", 10_000 + (std::process::id() % 80_000)), "SCHMOOPIIE".to_string()),
|
||||
};
|
||||
vec!["CAP REQ :twitch.tv/tags twitch.tv/commands".to_string(), format!("PASS {pass}"), format!("NICK {nick}")]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A line captured from xqc's chat, tags and all.
|
||||
const MESSAGE: &str = "@badge-info=subscriber/37;badges=subscriber/36,premium/1;client-nonce=0c55da2c71da4ff5af5bb3a8ee0ef46d;color=#FF69B4;display-name=Zakktheking13;emotes=301445381:0-7;first-msg=0;flags=;id=6a349c82-69f2-4ed6-ad45-30b2d3e57102;mod=0;returning-chatter=0;room-id=71092938;subscriber=1;tmi-sent-ts=1791949381404;turbo=0;user-id=494821572;user-type= :[email protected] PRIVMSG #xqc :xqcPeepo eyebrow busta";
|
||||
|
||||
#[test]
|
||||
fn a_message_is_read_with_its_tags_prefix_channel_and_text() {
|
||||
let line = parse(MESSAGE).unwrap();
|
||||
assert_eq!(line.command, "PRIVMSG");
|
||||
assert_eq!(line.nick(), Some("zakktheking13"));
|
||||
assert_eq!(line.channel().as_deref(), Some("xqc"));
|
||||
// The two spaces inside the text are the sender's, and are kept.
|
||||
assert_eq!(line.text(), "xqcPeepo eyebrow busta");
|
||||
assert_eq!(line.tag("display-name"), "Zakktheking13");
|
||||
assert_eq!(line.tag("emotes"), "301445381:0-7");
|
||||
assert_eq!(line.tag("color"), "#FF69B4");
|
||||
assert_eq!(line.tag("tmi-sent-ts"), "1791949381404");
|
||||
// An empty tag and an absent one are the same thing.
|
||||
assert_eq!(line.tag("flags"), "");
|
||||
assert_eq!(line.tag("no-such-tag"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_values_are_unescaped() {
|
||||
assert_eq!(unescape(r"what\sdoes\sno\seyebrows\smean\sbru"), "what does no eyebrows mean bru");
|
||||
assert_eq!(unescape(r"a\:b\\c\nd\re"), "a;b\\c\nd\re");
|
||||
assert_eq!(unescape(r"trailing\"), "trailing");
|
||||
assert_eq!(unescape(r"odd\qescape"), "oddqescape");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_names_what_it_answers_in_its_tags() {
|
||||
let line = parse("@reply-parent-display-name=deathsashes112;reply-parent-msg-body=what\\sdoes\\sno\\seyebrows\\smean\\sbru;reply-parent-msg-id=bc4a0663-4e5d-49c4-8d5b-0c78ec73d901;reply-parent-user-login=deathsashes112;reply-thread-parent-msg-id=bc4a0663-4e5d-49c4-8d5b-0c78ec73d901;id=b4d3 :a_t_m_0_s!a_t_m_0_s@a_t_m_0_s.tmi.twitch.tv PRIVMSG #xqc :@deathsashes112 it means nothing").unwrap();
|
||||
assert_eq!(line.tag("reply-parent-msg-body"), "what does no eyebrows mean bru");
|
||||
assert_eq!(line.tag("reply-thread-parent-msg-id"), "bc4a0663-4e5d-49c4-8d5b-0c78ec73d901");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_lines_with_no_tags_are_read_too() {
|
||||
let line = parse(":tmi.twitch.tv 001 justinfan26983 :Welcome, GLHF!").unwrap();
|
||||
assert_eq!((line.command.as_str(), line.prefix.as_deref(), line.params.clone()), ("001", Some("tmi.twitch.tv"), vec!["justinfan26983".to_string(), "Welcome, GLHF!".to_string()]));
|
||||
let ping = parse("PING :tmi.twitch.tv\r\n").unwrap();
|
||||
assert_eq!((ping.command.as_str(), ping.text()), ("PING", ""));
|
||||
assert_eq!(ping.params, vec!["tmi.twitch.tv".to_string()]);
|
||||
let cap = parse(":tmi.twitch.tv CAP * ACK :twitch.tv/tags twitch.tv/commands").unwrap();
|
||||
assert_eq!((cap.command.as_str(), cap.params.len()), ("CAP", 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_roomstate_is_just_tags() {
|
||||
let line = parse("@emote-only=0;followers-only=10;r9k=0;room-id=71092938;slow=0;subs-only=0 :tmi.twitch.tv ROOMSTATE #xqc").unwrap();
|
||||
assert_eq!((line.command.as_str(), line.channel().as_deref(), line.tag("followers-only"), line.tag("room-id")), ("ROOMSTATE", Some("xqc"), "10", "71092938"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_action_keeps_its_markers_for_the_caller_to_read() {
|
||||
let line = parse(":[email protected] PRIVMSG #xqc :\u{1}ACTION waves\u{1}").unwrap();
|
||||
assert_eq!(line.text(), "\u{1}ACTION waves\u{1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_is_not_a_line_is_nothing() {
|
||||
assert!(parse("").is_none());
|
||||
assert!(parse("@tags-and-nothing-else").is_none());
|
||||
assert!(parse("\r\n").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_greeting_asks_for_the_tags_first_and_is_anonymous_without_a_token() {
|
||||
let signed = greeting("someone", Some("abc"));
|
||||
assert_eq!(signed, vec!["CAP REQ :twitch.tv/tags twitch.tv/commands", "PASS oauth:abc", "NICK someone"]);
|
||||
let anon = greeting("", None);
|
||||
assert!(anon[2].starts_with("NICK justinfan"), "{anon:?}");
|
||||
assert!(anon[1].starts_with("PASS "));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
//! Adding an account: the sign-in a person takes part in.
|
||||
//!
|
||||
//! Twitch's sign-in is a conversation held in two places. Moho asks for a
|
||||
//! code and shows it; the person types it (or follows the link, which has it
|
||||
//! in) on twitch.tv, signed in however they sign in, and says yes; moho,
|
||||
//! meanwhile, asks every few seconds whether that has happened yet. It can
|
||||
//! take minutes, so it runs as its own task and talks to the window through
|
||||
//! events rather than answering one request:
|
||||
//!
|
||||
//! - `twitchLoginCode` - the code to show, where to enter it, and how long it
|
||||
//! is good for
|
||||
//! - `twitchLoginStatus` - what it is doing, for a line of text
|
||||
//! - `twitchLoginResult` - done, with the account, or the reason it failed
|
||||
//!
|
||||
//! The window closing the form ends the wait with `cancelTwitchLogin`.
|
||||
|
||||
use super::auth::{self, Poll};
|
||||
use crate::accounts::TwitchAccountConfig;
|
||||
use crate::state::AppState;
|
||||
use anyhow::{bail, Result};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Notify;
|
||||
|
||||
/// The sign-ins waiting on a person, by id, and the way to end each one.
|
||||
fn waiting() -> &'static Mutex<HashMap<String, Arc<Notify>>> {
|
||||
static WAITING: std::sync::OnceLock<Mutex<HashMap<String, Arc<Notify>>>> = std::sync::OnceLock::new();
|
||||
WAITING.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// The twitch.tv session some sign-ins bring with them, by sign-in: the window that signs a person in to twitch.tv
|
||||
/// and then has them approve the code is one action, and the session it saw on the way is for the account the
|
||||
/// code becomes. Kept only for as long as the sign-in lasts.
|
||||
fn web_tokens() -> &'static Mutex<HashMap<String, String>> {
|
||||
static TOKENS: std::sync::OnceLock<Mutex<HashMap<String, String>>> = std::sync::OnceLock::new();
|
||||
TOKENS.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// Hands a sign-in the person's twitch.tv session, to be saved with the account it makes.
|
||||
pub fn set_web_token(login_id: &str, token: &str) -> bool {
|
||||
if token.trim().is_empty() || !waiting().lock().unwrap().contains_key(login_id) {
|
||||
return false;
|
||||
}
|
||||
web_tokens().lock().unwrap().insert(login_id.to_string(), token.trim().to_string());
|
||||
true
|
||||
}
|
||||
|
||||
/// Ends a sign-in that is waiting, because the form was closed.
|
||||
pub fn cancel(login_id: &str) {
|
||||
web_tokens().lock().unwrap().remove(login_id);
|
||||
if let Some(stop) = waiting().lock().unwrap().remove(login_id) {
|
||||
stop.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a sign-in. Answers at once; the rest is events.
|
||||
pub fn start(state: AppState, login_id: String) {
|
||||
start_at(state, login_id, auth::ID_ROOT.to_string(), true)
|
||||
}
|
||||
|
||||
/// The same against a given server, and with or without starting the
|
||||
/// connection once it is done - which is what lets a test do it without one.
|
||||
pub fn start_at(state: AppState, login_id: String, root: String, connect: bool) {
|
||||
let stop = Arc::new(Notify::new());
|
||||
waiting().lock().unwrap().insert(login_id.clone(), stop.clone());
|
||||
tokio::spawn(async move {
|
||||
let outcome = flow(&state, &login_id, &root, stop).await;
|
||||
waiting().lock().unwrap().remove(&login_id);
|
||||
web_tokens().lock().unwrap().remove(&login_id);
|
||||
match outcome {
|
||||
Ok(Some(saved)) => {
|
||||
if connect {
|
||||
super::spawn(state.clone(), saved.clone());
|
||||
}
|
||||
let account = crate::accounts::twitch_account_to_json(&saved, if connect { "connecting" } else { "disconnected" });
|
||||
state.events.emit("twitchLoginResult", json!({ "loginId": login_id, "success": true, "account": account }));
|
||||
}
|
||||
// The form was closed: nobody is waiting to be told.
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
let error = format!("{e:#}");
|
||||
tracing::warn!("twitch sign-in {login_id}: {error}");
|
||||
state.events.emit("twitchLoginResult", json!({ "loginId": login_id, "success": false, "error": error }));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The sign-in itself. `None` when it was cancelled.
|
||||
pub async fn flow(state: &AppState, login_id: &str, root: &str, stop: Arc<Notify>) -> Result<Option<TwitchAccountConfig>> {
|
||||
let status = |detail: &str| state.events.emit("twitchLoginStatus", json!({ "loginId": login_id, "detail": detail }));
|
||||
let http = super::client()?;
|
||||
|
||||
status("Asking Twitch for a code");
|
||||
let code = auth::start(&http, root).await?;
|
||||
state.events.emit(
|
||||
"twitchLoginCode",
|
||||
json!({
|
||||
"loginId": login_id,
|
||||
"userCode": code.user_code,
|
||||
"verificationUri": code.verification_uri,
|
||||
"expiresIn": code.expires_in,
|
||||
"scopes": auth::SCOPES.iter().chain(auth::MODERATION_SCOPES.iter()).map(|(s, why)| json!({ "scope": s, "why": why })).collect::<Vec<_>>(),
|
||||
}),
|
||||
);
|
||||
status("Waiting for you to approve it on twitch.tv");
|
||||
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(code.expires_in);
|
||||
let mut interval = Duration::from_secs(code.interval.max(1));
|
||||
let tokens = loop {
|
||||
tokio::select! {
|
||||
_ = stop.notified() => return Ok(None),
|
||||
_ = tokio::time::sleep(interval) => {}
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
bail!("The code ran out before it was approved. Start again for a new one.");
|
||||
}
|
||||
match auth::poll(&http, root, &code.device_code).await {
|
||||
Ok(Poll::Pending) => {}
|
||||
Ok(Poll::SlowDown) => interval += Duration::from_secs(5),
|
||||
Ok(Poll::Denied) => bail!("The sign-in was refused on twitch.tv."),
|
||||
Ok(Poll::Expired) => bail!("The code ran out before it was approved. Start again for a new one."),
|
||||
Ok(Poll::Approved(tokens)) => break tokens,
|
||||
// A network blip while waiting is not the end of a sign-in the
|
||||
// person may be halfway through approving; the deadline is.
|
||||
Err(e) => tracing::debug!("twitch sign-in {login_id}: {e:#}"),
|
||||
}
|
||||
};
|
||||
|
||||
status("Checking who signed in");
|
||||
let Some(who) = auth::validate(&http, root, &tokens.access_token).await? else {
|
||||
bail!("Twitch gave a token and then would not accept it. Try again.");
|
||||
};
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let existing = state.accounts.get_twitch(&format!("twitch:{}", who.login.to_lowercase()));
|
||||
let saved = state.accounts.add_twitch(TwitchAccountConfig {
|
||||
login: who.login,
|
||||
user_id: who.user_id,
|
||||
display_name: existing.as_ref().and_then(|e| e.display_name.clone()),
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
expires_at: now + tokens.expires_in,
|
||||
scopes: if who.scopes.is_empty() { tokens.scope } else { who.scopes },
|
||||
// Signing in again keeps the channels that were open: a renewed
|
||||
// sign-in is not a reason to forget where the person was.
|
||||
followed_synced: existing.as_ref().map(|e| e.followed_synced).unwrap_or(false),
|
||||
channels: existing.map(|e| e.channels).unwrap_or_default(),
|
||||
web_token: String::new(),
|
||||
anonymous: false,
|
||||
use_tor: crate::net::route::router().wanted(&crate::net::route::pending_key("twitch")),
|
||||
// Never strict at sign-in; the account store keeps an existing
|
||||
// account's strict routing across a sign-in.
|
||||
strict_route: false,
|
||||
})?;
|
||||
// The twitch.tv session the window signed in to on the way, if it did: video plays as the person from the start.
|
||||
if let Some(token) = web_tokens().lock().unwrap().remove(login_id) {
|
||||
state.accounts.mutate_twitch(&saved.account_id(), |a| a.web_token = token.clone())?;
|
||||
return Ok(state.accounts.get_twitch(&saved.account_id()));
|
||||
}
|
||||
Ok(Some(saved))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::backend::kick::testkit;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
/// A Twitch whose answers depend on what is asked and when: the code at
|
||||
/// once, "pending" a couple of times, then the tokens, then who they are.
|
||||
async fn twitch(approve_after: usize) -> String {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let root = format!("http://{}", listener.local_addr().unwrap());
|
||||
let polls = Arc::new(Mutex::new(0usize));
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut sock, _)) = listener.accept().await else { return };
|
||||
let polls = polls.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 8192];
|
||||
let n = sock.read(&mut buf).await.unwrap_or(0);
|
||||
let head = String::from_utf8_lossy(&buf[..n]).to_string();
|
||||
let line = head.lines().next().unwrap_or("").to_string();
|
||||
let (status, body) = if line.starts_with("POST /oauth2/device") {
|
||||
(200, r#"{"device_code":"dev","expires_in":60,"interval":1,"user_code":"WXYZ1234","verification_uri":"https://www.twitch.tv/activate?device-code=WXYZ1234"}"#.to_string())
|
||||
} else if line.starts_with("POST /oauth2/token") {
|
||||
let mut p = polls.lock().unwrap();
|
||||
*p += 1;
|
||||
if *p > approve_after {
|
||||
(200, r#"{"access_token":"acc","refresh_token":"ref","expires_in":14000,"scope":["chat:read"],"token_type":"bearer"}"#.to_string())
|
||||
} else {
|
||||
(400, r#"{"status":400,"message":"authorization_pending"}"#.to_string())
|
||||
}
|
||||
} else if line.starts_with("GET /oauth2/validate") {
|
||||
(200, r#"{"client_id":"c","login":"Someone","scopes":["chat:read"],"user_id":"42","expires_in":14000}"#.to_string())
|
||||
} else {
|
||||
(404, "{}".to_string())
|
||||
};
|
||||
let reply = format!("HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
|
||||
let _ = sock.write_all(reply.as_bytes()).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
root
|
||||
}
|
||||
|
||||
async fn next_event(rx: &mut tokio::sync::broadcast::Receiver<crate::events::Event>, name: &str) -> serde_json::Value {
|
||||
loop {
|
||||
let event = tokio::time::timeout(Duration::from_secs(15), rx.recv()).await.expect("an event in time").expect("the bus is open");
|
||||
if event.name == name {
|
||||
return event.data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_person_is_shown_a_code_approves_it_and_has_an_account() {
|
||||
let state = testkit::simulated_daemon("twitch-login");
|
||||
let mut events = state.events.subscribe();
|
||||
let root = twitch(1).await;
|
||||
start_at(state.clone(), "login-1".into(), root, false);
|
||||
|
||||
let code = next_event(&mut events, "twitchLoginCode").await;
|
||||
assert_eq!(code["userCode"], "WXYZ1234");
|
||||
assert!(code["verificationUri"].as_str().unwrap().contains("device-code=WXYZ1234"));
|
||||
assert!(code["scopes"].as_array().unwrap().iter().any(|s| s["scope"] == "chat:send" || s["scope"] == "chat:edit"));
|
||||
|
||||
let result = next_event(&mut events, "twitchLoginResult").await;
|
||||
assert_eq!(result["success"], true, "{result}");
|
||||
assert_eq!(result["account"]["id"], "twitch:someone", "the login is read from Twitch and lower-cased");
|
||||
assert_eq!(result["account"]["service"], "twitch");
|
||||
let saved = state.accounts.get_twitch("twitch:someone").expect("saved");
|
||||
assert_eq!((saved.user_id.as_str(), saved.access_token.as_str(), saved.refresh_token.as_str()), ("42", "acc", "ref"));
|
||||
assert!(saved.expires_at > chrono::Utc::now().timestamp() + 13000);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn closing_the_form_ends_the_wait_without_saving_anything() {
|
||||
let state = testkit::simulated_daemon("twitch-login-cancel");
|
||||
let mut events = state.events.subscribe();
|
||||
let root = twitch(1000).await;
|
||||
start_at(state.clone(), "login-2".into(), root, false);
|
||||
next_event(&mut events, "twitchLoginCode").await;
|
||||
cancel("login-2");
|
||||
tokio::time::sleep(Duration::from_millis(1500)).await;
|
||||
assert!(state.accounts.all_twitch().is_empty());
|
||||
assert!(waiting().lock().unwrap().get("login-2").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unreachable_twitch_is_said_by_the_form() {
|
||||
let state = testkit::simulated_daemon("twitch-login-down");
|
||||
let mut events = state.events.subscribe();
|
||||
start_at(state.clone(), "login-3".into(), "http://127.0.0.1:9".into(), false);
|
||||
let result = next_event(&mut events, "twitchLoginResult").await;
|
||||
assert_eq!(result["success"], false);
|
||||
assert!(result["error"].as_str().unwrap().to_lowercase().contains("twitch"), "{result}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_twitch_session_handed_over_while_waiting_is_saved_with_the_account_the_code_becomes() {
|
||||
let state = testkit::simulated_daemon("twitch-login-web");
|
||||
let mut events = state.events.subscribe();
|
||||
let root = twitch(1).await;
|
||||
start_at(state.clone(), "login-4".into(), root, false);
|
||||
next_event(&mut events, "twitchLoginCode").await;
|
||||
assert!(set_web_token("login-4", " web-session "));
|
||||
assert!(!set_web_token("nobody", "x"), "a sign-in that is not waiting takes nothing");
|
||||
let result = next_event(&mut events, "twitchLoginResult").await;
|
||||
assert_eq!(result["success"], true, "{result}");
|
||||
assert_eq!(result["account"]["webSession"], true);
|
||||
assert_eq!(state.accounts.get_twitch("twitch:someone").unwrap().web_token, "web-session");
|
||||
assert!(web_tokens().lock().unwrap().get("login-4").is_none(), "nothing is left lying about");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Twitch - chat and live video, over Twitch's own IRC service and the same
|
||||
//! playlists its website plays.
|
||||
//!
|
||||
//! Twitch is the plan in Gitea issue #55, read before anything here. In short:
|
||||
//! chat is IRC over a websocket (anonymously readable, signed in to send),
|
||||
//! the official REST API is Helix, and the video comes from the playlist
|
||||
//! service twitch.tv's own player uses, so the stream can be drawn in the
|
||||
//! window's player the way Kick's is.
|
||||
//!
|
||||
//! # How this folder is arranged
|
||||
//!
|
||||
//! - `auth` - Twitch's sign-in (the device code flow) and what comes of it:
|
||||
//! tokens, renewing them, finding out who they belong to
|
||||
//! - `login` - adding an account: the sign-in a person takes part in
|
||||
//! - `moderation` - removing messages, timeouts and bans, the room's modes, whispers: slash commands and menus alike
|
||||
//! - `irc` - one line of Twitch's chat: tags, prefix, command, parameters
|
||||
//! - `emotes` - whether one of Twitch's own emotes is animated, and how an account's own are turned between picture and word
|
||||
//! - `badges` - the pictures for the badges beside a name
|
||||
//! - `watch` - telling Twitch a channel is being watched, so its points build while the video plays
|
||||
//! - `chat` - what the lines mean: messages, cards, removals, a channel's modes
|
||||
//! - `gql` - Twitch's own GraphQL, which answers what its official API will not without an account
|
||||
//! - `stream` - what is on air in a channel, said as the stream event the window draws
|
||||
//! - `playback` - the address of a channel's video, and the integrity token Twitch sometimes wants for it
|
||||
//! - `points` - channel points, the bonus, bets and votes, as the person's twitch.tv session
|
||||
//! - `realtime` - what Twitch says as it happens beyond the chat: raids, polls and predictions
|
||||
//! - `helix` - Twitch's official API, as far as moho uses it
|
||||
//! - `history` - a channel's recent past, from a third-party service, if asked
|
||||
//! - `connect` - an account's connection: keeping its token good, and the chat
|
||||
//! connection with its channels
|
||||
//!
|
||||
//! As in the other backends, `spawn` is re-exported so callers say
|
||||
//! `backend::twitch::spawn` without knowing which file that is.
|
||||
|
||||
pub mod auth;
|
||||
pub mod badges;
|
||||
pub mod chat;
|
||||
pub mod connect;
|
||||
pub mod emotes;
|
||||
pub mod gql;
|
||||
pub mod helix;
|
||||
pub mod history;
|
||||
pub mod irc;
|
||||
pub mod login;
|
||||
pub mod moderation;
|
||||
pub mod playback;
|
||||
pub mod points;
|
||||
pub mod realtime;
|
||||
pub mod stream;
|
||||
pub mod watch;
|
||||
|
||||
pub use connect::{spawn, Command};
|
||||
|
||||
/// What routes this service's requests when any Twitch account goes through
|
||||
/// Tor or the proxy. One key for the whole service, as Kick's is: the
|
||||
/// requests that are made before an account exists (the sign-in) have no
|
||||
/// account to name.
|
||||
pub const SHARED_KEY: &str = "twitch:any-routed";
|
||||
|
||||
/// An HTTP client for Twitch's servers, through the route.
|
||||
pub fn client() -> anyhow::Result<reqwest::Client> {
|
||||
let router = crate::net::route::router();
|
||||
let routed = router.routed(SHARED_KEY) || router.routed(&crate::net::route::pending_key("twitch"));
|
||||
Ok(router.client_if("twitch", routed, |builder| builder.user_agent(concat!("moho/", env!("CARGO_PKG_VERSION"))).timeout(std::time::Duration::from_secs(20))))
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
//! What a person can do to a channel's chat, and to themselves, through
|
||||
//! Twitch's official API: remove a message, time somebody out or ban them (and
|
||||
//! let them back), change the room's modes, announce, change a name colour,
|
||||
//! whisper.
|
||||
//!
|
||||
//! Twitch no longer reads these as chat text (`/ban` typed into IRC does
|
||||
//! nothing), so they are API calls, and this reads the slash commands people
|
||||
//! are used to typing and makes the calls. The window's own menus - a message's
|
||||
//! Delete, a name's Time out - make the same ones.
|
||||
//!
|
||||
//! The rights come with signing in (see `auth::MODERATION_SCOPES`), so one sign-in has every feature;
|
||||
//! what an account may actually do in a channel is Twitch's to say on each call, and moho offers
|
||||
//! the menus only where the account is a moderator or the broadcaster. An account signed in before
|
||||
//! they were part of it is told to sign in again.
|
||||
|
||||
use super::helix::{self, Caller, Room};
|
||||
use crate::state::AppState;
|
||||
|
||||
/// One thing to do.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Action {
|
||||
/// A person, by login, for this many seconds.
|
||||
Timeout { user: String, seconds: u64, reason: String },
|
||||
Ban { user: String, reason: String },
|
||||
Unban { user: String },
|
||||
/// One message by id, or all of them.
|
||||
Delete { message_id: Option<String> },
|
||||
Slow(Option<u32>),
|
||||
/// Followers-only, with the minutes a person must have followed for.
|
||||
Followers(Option<u32>),
|
||||
Subscribers(bool),
|
||||
EmoteOnly(bool),
|
||||
Unique(bool),
|
||||
Announce { text: String, color: String },
|
||||
Color(String),
|
||||
Whisper { to: String, text: String },
|
||||
/// Twitch's own block list, for the account.
|
||||
Block { user: String },
|
||||
Unblock { user: String },
|
||||
/// A shoutout to another channel, in this one.
|
||||
Shoutout { user: String },
|
||||
}
|
||||
|
||||
impl Action {
|
||||
/// Whether it is something a moderator does to the room, which needs the
|
||||
/// right to, as against what a person does as themselves.
|
||||
pub fn needs_moderator(&self) -> bool {
|
||||
!matches!(self, Action::Color(_) | Action::Whisper { .. } | Action::Block { .. } | Action::Unblock { .. })
|
||||
}
|
||||
}
|
||||
|
||||
/// Twitch's longest timeout is fourteen days.
|
||||
const LONGEST_TIMEOUT: u64 = 14 * 24 * 3600;
|
||||
|
||||
fn login_of(word: &str) -> Option<String> {
|
||||
let login = word.trim().trim_start_matches('@').to_lowercase();
|
||||
(!login.is_empty() && login.len() <= 25 && login.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')).then_some(login)
|
||||
}
|
||||
|
||||
/// A length of time as people type it: `600`, `10m`, `2h`, `1d`, `2w`, in seconds.
|
||||
pub fn duration_seconds(word: &str) -> Option<u64> {
|
||||
let word = word.trim().to_lowercase();
|
||||
let digits: String = word.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
let unit = &word[digits.len()..];
|
||||
let n: u64 = digits.parse().ok()?;
|
||||
let per = match unit {
|
||||
"" | "s" => 1,
|
||||
"m" => 60,
|
||||
"h" => 3600,
|
||||
"d" => 86_400,
|
||||
"w" => 604_800,
|
||||
_ => return None,
|
||||
};
|
||||
n.checked_mul(per)
|
||||
}
|
||||
|
||||
/// Reads a slash command. `None` for text that is not one (and for `/me`,
|
||||
/// which Twitch reads itself); an error for one that is wrong or unknown, in
|
||||
/// words, so a mistyped command is never sent as a message.
|
||||
pub fn parse(text: &str) -> Option<Result<Action, String>> {
|
||||
let text = text.trim();
|
||||
let body = text.strip_prefix('/')?;
|
||||
// Twitch's own: sent as typed.
|
||||
if body == "me" || body.starts_with("me ") || body.starts_with('/') || body.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (name, rest) = body.split_once(char::is_whitespace).map(|(n, r)| (n, r.trim())).unwrap_or((body, ""));
|
||||
let mut words = rest.split_whitespace();
|
||||
let user = |w: Option<&str>| w.and_then(login_of).ok_or_else(|| format!("/{name} needs a user: /{name} <name>"));
|
||||
Some(match name.to_lowercase().as_str() {
|
||||
"timeout" => (|| {
|
||||
let who = user(words.next())?;
|
||||
let mut seconds = 600;
|
||||
let mut reason: Vec<&str> = Vec::new();
|
||||
if let Some(first) = words.next() {
|
||||
match duration_seconds(first) {
|
||||
Some(s) => seconds = s,
|
||||
None => reason.push(first),
|
||||
}
|
||||
}
|
||||
reason.extend(words);
|
||||
if seconds == 0 || seconds > LONGEST_TIMEOUT {
|
||||
return Err("A timeout is from 1 second to 14 days".to_string());
|
||||
}
|
||||
Ok(Action::Timeout { user: who, seconds, reason: reason.join(" ") })
|
||||
})(),
|
||||
"ban" => user(words.next()).map(|who| Action::Ban { user: who, reason: words.collect::<Vec<_>>().join(" ") }),
|
||||
"unban" | "untimeout" => user(words.next()).map(|who| Action::Unban { user: who }),
|
||||
"delete" => match words.next() {
|
||||
Some(id) => Ok(Action::Delete { message_id: Some(id.to_string()) }),
|
||||
None => Err("/delete needs the id of a message; use a message's own menu to delete one".to_string()),
|
||||
},
|
||||
"clear" => Ok(Action::Delete { message_id: None }),
|
||||
"slow" => match words.next().map(|w| w.parse::<u32>()) {
|
||||
None => Ok(Action::Slow(Some(30))),
|
||||
Some(Ok(s)) if (3..=120).contains(&s) => Ok(Action::Slow(Some(s))),
|
||||
_ => Err("Slow mode is 3 to 120 seconds: /slow <seconds>".to_string()),
|
||||
},
|
||||
"slowoff" => Ok(Action::Slow(None)),
|
||||
"followers" => match words.next() {
|
||||
None => Ok(Action::Followers(Some(0))),
|
||||
Some(w) => match duration_seconds(&if w.chars().all(|c| c.is_ascii_digit()) { format!("{w}m") } else { w.to_string() }) {
|
||||
Some(s) if s / 60 <= 129_600 => Ok(Action::Followers(Some((s / 60) as u32))),
|
||||
_ => Err("Followers-only takes a length up to 3 months: /followers 30m, /followers 2h, /followers 1d".to_string()),
|
||||
},
|
||||
},
|
||||
"followersoff" => Ok(Action::Followers(None)),
|
||||
"subscribers" | "subscriberson" | "subs" => Ok(Action::Subscribers(true)),
|
||||
"subscribersoff" | "subsoff" => Ok(Action::Subscribers(false)),
|
||||
"emoteonly" | "emoteonlyon" => Ok(Action::EmoteOnly(true)),
|
||||
"emoteonlyoff" => Ok(Action::EmoteOnly(false)),
|
||||
"uniquechat" | "r9kbeta" => Ok(Action::Unique(true)),
|
||||
"uniquechatoff" | "r9kbetaoff" => Ok(Action::Unique(false)),
|
||||
"announce" | "announceblue" | "announcegreen" | "announceorange" | "announcepurple" => {
|
||||
let color = name.to_lowercase().trim_start_matches("announce").to_string();
|
||||
let color = if color.is_empty() { "primary".to_string() } else { color };
|
||||
if rest.is_empty() { Err(format!("/{name} needs something to announce")) } else { Ok(Action::Announce { text: rest.to_string(), color }) }
|
||||
}
|
||||
"color" => {
|
||||
if rest.is_empty() { Err("/color needs a colour: a name like coral or blue_violet, or #rrggbb with Twitch Turbo or Prime".to_string()) } else { Ok(Action::Color(rest.split_whitespace().next().unwrap_or("").to_string())) }
|
||||
}
|
||||
"block" => user(words.next()).map(|who| Action::Block { user: who }),
|
||||
"unblock" => user(words.next()).map(|who| Action::Unblock { user: who }),
|
||||
"shoutout" | "so" => user(words.next()).map(|who| Action::Shoutout { user: who }),
|
||||
"w" | "whisper" => match (words.next().and_then(login_of), words.collect::<Vec<_>>().join(" ")) {
|
||||
(Some(to), text) if !text.is_empty() => Ok(Action::Whisper { to, text }),
|
||||
_ => Err("/w needs a name and a message: /w <name> <message>".to_string()),
|
||||
},
|
||||
other => Err(format!("Twitch has no /{other} command here. Type // to send a message that starts with a slash.")),
|
||||
})
|
||||
}
|
||||
|
||||
/// What is needed to act: who and where.
|
||||
pub struct Ctx<'a> {
|
||||
pub token: &'a str,
|
||||
pub my_id: &'a str,
|
||||
/// Twitch's number for the channel, from its room state.
|
||||
pub room_id: &'a str,
|
||||
/// Whether this account is a moderator (or the broadcaster) there.
|
||||
pub moderator: bool,
|
||||
pub api_root: &'a str,
|
||||
pub client_id: &'a str,
|
||||
}
|
||||
|
||||
fn said(r: Result<(), helix::HelixError>, done: String) -> Result<String, String> {
|
||||
r.map(|_| done).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Does it, and says what was done in words.
|
||||
pub async fn run(http: &reqwest::Client, cx: &Ctx<'_>, action: &Action, target_id: Option<&str>) -> Result<String, String> {
|
||||
if action.needs_moderator() && !cx.moderator {
|
||||
return Err("You are not a moderator in this channel".to_string());
|
||||
}
|
||||
let who = Caller { root: cx.api_root, client_id: cx.client_id, token: cx.token };
|
||||
let room = Room { broadcaster_id: cx.room_id, moderator_id: cx.my_id };
|
||||
let resolve = |login: &str| {
|
||||
let (who, login) = (&who, login.to_string());
|
||||
let known = target_id.map(str::to_string);
|
||||
async move {
|
||||
if let Some(id) = known {
|
||||
return Ok(id);
|
||||
}
|
||||
match helix::user_id_of(http, who, &login).await {
|
||||
Ok(Some(id)) => Ok(id),
|
||||
Ok(None) => Err(format!("Twitch has no user called {login}")),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
};
|
||||
match action {
|
||||
Action::Timeout { user, seconds, reason } => {
|
||||
let id = resolve(user).await?;
|
||||
said(helix::ban(http, &who, &room, &id, Some(*seconds), reason).await, format!("{user} was timed out for {}", super::chat::span(*seconds)))
|
||||
}
|
||||
Action::Ban { user, reason } => {
|
||||
let id = resolve(user).await?;
|
||||
said(helix::ban(http, &who, &room, &id, None, reason).await, format!("{user} was banned"))
|
||||
}
|
||||
Action::Unban { user } => {
|
||||
let id = resolve(user).await?;
|
||||
said(helix::unban(http, &who, &room, &id).await, format!("{user} is allowed back"))
|
||||
}
|
||||
Action::Delete { message_id } => said(
|
||||
helix::delete_chat(http, &who, &room, message_id.as_deref()).await,
|
||||
if message_id.is_some() { "The message was deleted".to_string() } else { "Chat was cleared".to_string() },
|
||||
),
|
||||
Action::Slow(Some(s)) => said(helix::chat_settings(http, &who, &room, serde_json::json!({ "slow_mode": true, "slow_mode_wait_time": s })).await, format!("Slow mode is on: {s} seconds")),
|
||||
Action::Slow(None) => said(helix::chat_settings(http, &who, &room, serde_json::json!({ "slow_mode": false })).await, "Slow mode is off".to_string()),
|
||||
Action::Followers(Some(minutes)) => said(
|
||||
helix::chat_settings(http, &who, &room, serde_json::json!({ "follower_mode": true, "follower_mode_duration": minutes })).await,
|
||||
if *minutes == 0 { "Followers-only mode is on".to_string() } else { format!("Followers-only mode is on: followers of {}", super::chat::span(*minutes as u64 * 60)) },
|
||||
),
|
||||
Action::Followers(None) => said(helix::chat_settings(http, &who, &room, serde_json::json!({ "follower_mode": false })).await, "Followers-only mode is off".to_string()),
|
||||
Action::Subscribers(on) => said(helix::chat_settings(http, &who, &room, serde_json::json!({ "subscriber_mode": on })).await, format!("Subscribers-only mode is {}", if *on { "on" } else { "off" })),
|
||||
Action::EmoteOnly(on) => said(helix::chat_settings(http, &who, &room, serde_json::json!({ "emote_mode": on })).await, format!("Emote-only mode is {}", if *on { "on" } else { "off" })),
|
||||
Action::Unique(on) => said(helix::chat_settings(http, &who, &room, serde_json::json!({ "unique_chat_mode": on })).await, format!("Unique-chat mode is {}", if *on { "on" } else { "off" })),
|
||||
Action::Announce { text, color } => said(helix::announce(http, &who, &room, text, color).await, "Announced".to_string()),
|
||||
Action::Color(color) => said(helix::set_color(http, &who, cx.my_id, color).await, format!("Your name is {color}")),
|
||||
Action::Block { user } => {
|
||||
let id = resolve(user).await?;
|
||||
said(helix::block(http, &who, &id).await, format!("{user} is blocked"))
|
||||
}
|
||||
Action::Unblock { user } => {
|
||||
let id = resolve(user).await?;
|
||||
said(helix::unblock(http, &who, &id).await, format!("{user} is unblocked"))
|
||||
}
|
||||
Action::Shoutout { user } => {
|
||||
let id = resolve(user).await?;
|
||||
said(helix::shoutout(http, &who, &room, &id).await, format!("Shouted out {user}"))
|
||||
}
|
||||
Action::Whisper { to, text } => {
|
||||
let id = resolve(to).await?;
|
||||
said(helix::whisper(http, &who, cx.my_id, &id, &super::emotes::tokens_to_names(text)).await, format!("Whisper sent to {to}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Said in the channel, as a line of its own, so the one who acted - and any
|
||||
/// other window of theirs - sees what was done.
|
||||
pub fn say_done(state: &AppState, account_id: &str, channel: &str, action: &Action, what: &str) {
|
||||
// What Twitch tells the whole room comes back as its own card (modes, removals, timeouts, bans, clearing
|
||||
// the chat, announcements); what only this account did is said here, as one.
|
||||
let card = |icon: &str, subject: &str, what: &str| super::chat::moderation_card(state, account_id, channel, icon, subject, what, None);
|
||||
match action {
|
||||
Action::Unban { user } => return card("lock_open", user, "is allowed back"),
|
||||
Action::Block { user } => return card("block", user, "was blocked"),
|
||||
Action::Unblock { user } => return card("check_circle", user, "was unblocked"),
|
||||
Action::Shoutout { user } => return card("campaign", user, "was given a shoutout"),
|
||||
Action::Timeout { .. } | Action::Ban { .. } | Action::Delete { .. } | Action::Slow(_) | Action::Followers(_) | Action::Subscribers(_) | Action::EmoteOnly(_) | Action::Unique(_) | Action::Announce { .. } | Action::Whisper { .. } => return,
|
||||
Action::Color(_) => {}
|
||||
}
|
||||
state.runtime.record_message(state, account_id, channel, "channel", channel, what, false, "system", None, None, false, None, Vec::new(), Vec::new(), None);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ok(text: &str) -> Action {
|
||||
parse(text).expect("a command").expect("a good one")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_is_not_a_command_is_not_read_as_one_and_me_is_twitchs_own() {
|
||||
assert!(parse("hello").is_none());
|
||||
assert!(parse("/me waves").is_none());
|
||||
assert!(parse("//not a command").is_none(), "a doubled slash is how a message starting with one is sent");
|
||||
assert!(parse("/").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_timeout_has_a_length_and_a_reason_either_of_which_may_be_left_out() {
|
||||
assert_eq!(ok("/timeout @Bob"), Action::Timeout { user: "bob".into(), seconds: 600, reason: String::new() });
|
||||
assert_eq!(ok("/timeout bob 30"), Action::Timeout { user: "bob".into(), seconds: 30, reason: String::new() });
|
||||
assert_eq!(ok("/timeout bob 2h being rude"), Action::Timeout { user: "bob".into(), seconds: 7200, reason: "being rude".into() });
|
||||
assert_eq!(ok("/timeout bob spamming links"), Action::Timeout { user: "bob".into(), seconds: 600, reason: "spamming links".into() });
|
||||
assert!(parse("/timeout").unwrap().unwrap_err().contains("needs a user"));
|
||||
assert!(parse("/timeout bob 99w").unwrap().unwrap_err().contains("14 days"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bans_unbans_and_deletes() {
|
||||
assert_eq!(ok("/ban bob spam"), Action::Ban { user: "bob".into(), reason: "spam".into() });
|
||||
assert_eq!(ok("/unban bob"), Action::Unban { user: "bob".into() });
|
||||
assert_eq!(ok("/untimeout bob"), Action::Unban { user: "bob".into() });
|
||||
assert_eq!(ok("/delete 6a349c82"), Action::Delete { message_id: Some("6a349c82".into()) });
|
||||
assert_eq!(ok("/clear"), Action::Delete { message_id: None });
|
||||
assert!(parse("/delete").unwrap().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rooms_modes() {
|
||||
assert_eq!(ok("/slow"), Action::Slow(Some(30)));
|
||||
assert_eq!(ok("/slow 10"), Action::Slow(Some(10)));
|
||||
assert!(parse("/slow 1").unwrap().is_err());
|
||||
assert_eq!(ok("/slowoff"), Action::Slow(None));
|
||||
assert_eq!(ok("/followers"), Action::Followers(Some(0)));
|
||||
assert_eq!(ok("/followers 30"), Action::Followers(Some(30)));
|
||||
assert_eq!(ok("/followers 2h"), Action::Followers(Some(120)));
|
||||
assert_eq!(ok("/followers 1d"), Action::Followers(Some(1440)));
|
||||
assert!(parse("/followers soon").unwrap().is_err());
|
||||
assert_eq!(ok("/followersoff"), Action::Followers(None));
|
||||
assert_eq!((ok("/subscribers"), ok("/subscribersoff")), (Action::Subscribers(true), Action::Subscribers(false)));
|
||||
assert_eq!((ok("/emoteonly"), ok("/emoteonlyoff")), (Action::EmoteOnly(true), Action::EmoteOnly(false)));
|
||||
assert_eq!((ok("/uniquechat"), ok("/uniquechatoff")), (Action::Unique(true), Action::Unique(false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn announcements_colours_and_whispers() {
|
||||
assert_eq!(ok("/announce Giveaway at noon"), Action::Announce { text: "Giveaway at noon".into(), color: "primary".into() });
|
||||
assert_eq!(ok("/announcegreen Hello"), Action::Announce { text: "Hello".into(), color: "green".into() });
|
||||
assert!(parse("/announce").unwrap().is_err());
|
||||
assert_eq!(ok("/color coral"), Action::Color("coral".into()));
|
||||
assert_eq!((ok("/block @Bob"), ok("/unblock bob")), (Action::Block { user: "bob".into() }, Action::Unblock { user: "bob".into() }));
|
||||
assert_eq!(ok("/so Bob"), Action::Shoutout { user: "bob".into() });
|
||||
assert!(!Action::Block { user: "x".into() }.needs_moderator() && Action::Shoutout { user: "x".into() }.needs_moderator());
|
||||
assert_eq!(ok("/w @Bob hello there"), Action::Whisper { to: "bob".into(), text: "hello there".into() });
|
||||
assert!(parse("/w bob").unwrap().is_err());
|
||||
assert!(!Action::Color("x".into()).needs_moderator() && Action::Slow(None).needs_moderator());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_command_is_said_and_never_sent_as_a_message() {
|
||||
let e = parse("/frobnicate now").unwrap().unwrap_err();
|
||||
assert!(e.contains("no /frobnicate command") && e.contains("//"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lengths_are_read_as_people_write_them() {
|
||||
for (typed, seconds) in [("90", 90), ("30s", 30), ("10m", 600), ("2H", 7200), ("1d", 86_400), ("1w", 604_800)] {
|
||||
assert_eq!(duration_seconds(typed), Some(seconds), "{typed}");
|
||||
}
|
||||
for typed in ["", "m", "1y", "ten"] {
|
||||
assert_eq!(duration_seconds(typed), None, "{typed}");
|
||||
}
|
||||
}
|
||||
|
||||
// The calls themselves, against a Twitch that is a server in a test.
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
async fn helix_server(answers: Vec<(u16, &'static str)>) -> (String, Arc<Mutex<Vec<String>>>) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let root = format!("http://{}", listener.local_addr().unwrap());
|
||||
let seen: Arc<Mutex<Vec<String>>> = Default::default();
|
||||
let log = seen.clone();
|
||||
let answers = Arc::new(Mutex::new(answers.into_iter().collect::<std::collections::VecDeque<_>>()));
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut sock, _)) = listener.accept().await else { return };
|
||||
let (log, answers) = (log.clone(), answers.clone());
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 8192];
|
||||
let mut got = 0;
|
||||
loop {
|
||||
let n = sock.read(&mut buf[got..]).await.unwrap_or(0);
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
got += n;
|
||||
let text = String::from_utf8_lossy(&buf[..got]).to_string();
|
||||
if let Some(end) = text.find("\r\n\r\n") {
|
||||
let length = text[..end].lines().find_map(|l| l.to_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))).unwrap_or(0);
|
||||
if got >= end + 4 + length {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
log.lock().unwrap().push(String::from_utf8_lossy(&buf[..got]).to_string());
|
||||
let (status, body) = answers.lock().unwrap().pop_front().unwrap_or((500, "{}"));
|
||||
let reply = format!("HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
|
||||
let _ = sock.write_all(reply.as_bytes()).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
(root, seen)
|
||||
}
|
||||
|
||||
fn cx<'a>(root: &'a str, moderator: bool) -> Ctx<'a> {
|
||||
Ctx { token: "tok", my_id: "42", room_id: "100", moderator, api_root: root, client_id: "cid" }
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_timeout_looks_the_person_up_and_asks_twitch_for_it_in_the_rooms_name() {
|
||||
let (root, seen) = helix_server(vec![(200, r#"{"data":[{"id":"7","login":"bob"}]}"#), (200, r#"{"data":[{}]}"#)]).await;
|
||||
let done = run(&reqwest::Client::new(), &cx(&root, true), &Action::Timeout { user: "bob".into(), seconds: 600, reason: "spam".into() }, None).await.unwrap();
|
||||
assert_eq!(done, "bob was timed out for 10 minutes");
|
||||
let asked = seen.lock().unwrap().clone();
|
||||
assert!(asked[0].starts_with("GET /users?login=bob "), "{}", asked[0]);
|
||||
assert!(asked[1].starts_with("POST /moderation/bans?broadcaster_id=100&moderator_id=42 "), "{}", asked[1]);
|
||||
assert!(asked[1].contains(r#""user_id":"7""#) && asked[1].contains(r#""duration":600"#) && asked[1].contains(r#""reason":"spam""#), "{}", asked[1]);
|
||||
assert!(asked[1].to_lowercase().contains("authorization: bearer tok") && asked[1].to_lowercase().contains("client-id: cid"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_person_whose_id_is_known_is_not_looked_up_and_one_who_does_not_exist_is_said() {
|
||||
let (root, seen) = helix_server(vec![(200, "{}"), (200, r#"{"data":[]}"#)]).await;
|
||||
let client = reqwest::Client::new();
|
||||
run(&client, &cx(&root, true), &Action::Ban { user: "bob".into(), reason: String::new() }, Some("7")).await.unwrap();
|
||||
assert_eq!(seen.lock().unwrap().len(), 1, "the id was given, so only the ban was asked for");
|
||||
let e = run(&client, &cx(&root, true), &Action::Unban { user: "ghost".into() }, None).await.unwrap_err();
|
||||
assert!(e.contains("no user called ghost"), "{e}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_rooms_modes_are_patched_and_deleting_names_the_message() {
|
||||
let (root, seen) = helix_server(vec![(200, "{}"), (200, "{}"), (204, "{}")]).await;
|
||||
let client = reqwest::Client::new();
|
||||
run(&client, &cx(&root, true), &Action::Slow(Some(30)), None).await.unwrap();
|
||||
run(&client, &cx(&root, true), &Action::Followers(Some(60)), None).await.unwrap();
|
||||
run(&client, &cx(&root, true), &Action::Delete { message_id: Some("m1".into()) }, None).await.unwrap();
|
||||
let asked = seen.lock().unwrap().clone();
|
||||
assert!(asked[0].starts_with("PATCH /chat/settings?broadcaster_id=100&moderator_id=42 ") && asked[0].contains(r#""slow_mode_wait_time":30"#), "{}", asked[0]);
|
||||
assert!(asked[1].contains(r#""follower_mode":true"#) && asked[1].contains(r#""follower_mode_duration":60"#), "{}", asked[1]);
|
||||
assert!(asked[2].starts_with("DELETE /moderation/chat?broadcaster_id=100&moderator_id=42&message_id=m1 "), "{}", asked[2]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn not_being_a_moderator_stops_it_before_twitch_is_asked_and_twitchs_no_is_said() {
|
||||
let (root, seen) = helix_server(vec![(400, r#"{"message":"The user specified in user_id is already banned."}"#), (401, r#"{"message":"Missing scope: moderator:manage:banned_users"}"#)]).await;
|
||||
let client = reqwest::Client::new();
|
||||
let e = run(&client, &cx(&root, false), &Action::Slow(None), None).await.unwrap_err();
|
||||
assert!(e.contains("not a moderator") && seen.lock().unwrap().is_empty());
|
||||
let e = run(&client, &cx(&root, true), &Action::Ban { user: "bob".into(), reason: String::new() }, Some("7")).await.unwrap_err();
|
||||
assert!(e.contains("already banned"), "{e}");
|
||||
let e = run(&client, &cx(&root, true), &Action::Ban { user: "bob".into(), reason: String::new() }, Some("7")).await.unwrap_err();
|
||||
assert!(e.contains("Sign in again"), "{e}");
|
||||
// Whispers and colours are what a person does as themselves, with no moderator needed.
|
||||
let (root, _) = helix_server(vec![(204, "{}"), (204, "{}")]).await;
|
||||
run(&client, &cx(&root, false), &Action::Color("coral".into()), None).await.unwrap();
|
||||
run(&client, &cx(&root, false), &Action::Whisper { to: "bob".into(), text: "hi".into() }, Some("7")).await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
//! The address of a live channel's video.
|
||||
//!
|
||||
//! twitch.tv's own player asks Twitch for a signed token (a persisted GraphQL
|
||||
//! query, see `gql.rs`) and then for the channel's playlist from the playlist
|
||||
//! service, passing the token along. Both are reproduced here, and the second is
|
||||
//! only *built* - the player in the window fetches it, as it fetches every
|
||||
//! playlist and segment after it. What this returns is that address, when it
|
||||
//! stops being good (the token lasts twenty minutes), and what the token says
|
||||
//! about the account: whether ads are for it, which renditions it may not have.
|
||||
//!
|
||||
//! Anonymous works: no account, no sign-in, no browser. An account's own
|
||||
//! `auth-token` (the cookie of a twitch.tv session, see Settings) is sent when
|
||||
//! there is one, and is what makes a person's Turbo or subscription count.
|
||||
//!
|
||||
//! Twitch can demand an *integrity token*, which is proof that a real browser
|
||||
//! is asking, and sometimes has, for a week at a time. It is not demanded
|
||||
//! today. When it is, the daemon asks the window to make one (in a hidden page
|
||||
//! on twitch.tv, which is the only place it can be made), keeps it until it is
|
||||
//! nearly out, and asks again.
|
||||
|
||||
use super::gql::{self, Who};
|
||||
use crate::state::AppState;
|
||||
use anyhow::anyhow;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Where the playlist service is.
|
||||
pub const USHER_ROOT: &str = "https://usher.ttvnw.net";
|
||||
|
||||
/// The player's own query for the token, by the hash it is stored under at Twitch.
|
||||
const TOKEN_HASH: &str = "ed230aa1e33e07eebb8928504583da78a5173989fadfb1ac94be06a04f3cdbe9";
|
||||
|
||||
/// The address of a stream and what is known about it.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Playback {
|
||||
/// The playlist, signed. Replaced before `expires`.
|
||||
pub url: String,
|
||||
/// When the signature stops being accepted, in Unix seconds.
|
||||
pub expires: i64,
|
||||
/// Whether Twitch says ads are not for this account.
|
||||
pub hide_ads: bool,
|
||||
/// Renditions this account may not have, by Twitch's names for them.
|
||||
pub restricted: Vec<String>,
|
||||
}
|
||||
|
||||
/// Why there is no address.
|
||||
#[derive(Debug)]
|
||||
pub enum PlaybackError {
|
||||
/// The channel is not broadcasting.
|
||||
Offline,
|
||||
/// Twitch will not give this account the stream: subscribers only, and so on.
|
||||
Forbidden(String),
|
||||
/// Not available where this is being watched from.
|
||||
Blocked(String),
|
||||
/// Twitch wanted proof that a browser is asking, and none could be had.
|
||||
NeedsIntegrity,
|
||||
Failed(anyhow::Error),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PlaybackError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
PlaybackError::Offline => write!(f, "this channel is not streaming"),
|
||||
PlaybackError::Forbidden(why) if why.is_empty() => write!(f, "Twitch will not play this stream for this account (it may be subscribers only)"),
|
||||
PlaybackError::Forbidden(why) => write!(f, "Twitch will not play this stream for this account: {why}"),
|
||||
PlaybackError::Blocked(why) if why.is_empty() => write!(f, "this stream is not available where you are"),
|
||||
PlaybackError::Blocked(why) => write!(f, "this stream is not available where you are ({why})"),
|
||||
PlaybackError::NeedsIntegrity => write!(f, "Twitch is asking for proof that this is a browser, and moho could not give it"),
|
||||
PlaybackError::Failed(e) => write!(f, "{e:#}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn token_query(login: &str) -> Value {
|
||||
json!({
|
||||
"operationName": "PlaybackAccessToken",
|
||||
"variables": { "isLive": true, "login": login, "isVod": false, "vodID": "", "playerType": "site", "platform": "web" },
|
||||
"extensions": { "persistedQuery": { "version": 1, "sha256Hash": TOKEN_HASH } },
|
||||
})
|
||||
}
|
||||
|
||||
/// The playlist's address, from the signed token. Codecs are limited to H.264:
|
||||
/// the window's player has no use for the AV1 and HEVC renditions Twitch offers
|
||||
/// big channels, and a playlist that lists only what plays is one nothing has to
|
||||
/// filter.
|
||||
pub fn usher_url(root: &str, login: &str, signature: &str, token: &str) -> String {
|
||||
let pass = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos() % 999_999).unwrap_or(1);
|
||||
let mut query = url::form_urlencoded::Serializer::new(String::new());
|
||||
for (k, v) in [
|
||||
("allow_source", "true"),
|
||||
("allow_audio_only", "true"),
|
||||
("fast_bread", "true"),
|
||||
("p", &pass.to_string()),
|
||||
("platform", "web"),
|
||||
("player_backend", "mediaplayer"),
|
||||
("playlist_include_framerate", "true"),
|
||||
("reassignments_supported", "true"),
|
||||
("sig", signature),
|
||||
("supported_codecs", "h264"),
|
||||
("token", token),
|
||||
("transcode_mode", "cbr_v1"),
|
||||
] {
|
||||
query.append_pair(k, v);
|
||||
}
|
||||
format!("{root}/api/v2/channel/hls/{}.m3u8?{}", login.to_lowercase(), query.finish())
|
||||
}
|
||||
|
||||
/// Reads Twitch's answer to the token query.
|
||||
pub fn read_token(answer: &Value, login: &str, usher_root: &str) -> Result<Playback, PlaybackError> {
|
||||
let token = answer.pointer("/data/streamPlaybackAccessToken").filter(|t| !t.is_null()).ok_or(PlaybackError::Offline)?;
|
||||
let value = token.get("value").and_then(|v| v.as_str()).ok_or_else(|| PlaybackError::Failed(anyhow!("Twitch's token had no value")))?;
|
||||
let signature = token.get("signature").and_then(|v| v.as_str()).ok_or_else(|| PlaybackError::Failed(anyhow!("Twitch's token had no signature")))?;
|
||||
let about: Value = serde_json::from_str(value).map_err(|e| PlaybackError::Failed(anyhow!("Twitch's token was not readable: {e}")))?;
|
||||
if about.pointer("/authorization/forbidden").and_then(|f| f.as_bool()).unwrap_or(false) {
|
||||
return Err(PlaybackError::Forbidden(about.pointer("/authorization/reason").and_then(|r| r.as_str()).unwrap_or("").to_string()));
|
||||
}
|
||||
if let Some(why) = about.get("geoblock_reason").and_then(|g| g.as_str()).filter(|g| !g.is_empty()) {
|
||||
return Err(PlaybackError::Blocked(why.to_string()));
|
||||
}
|
||||
Ok(Playback {
|
||||
url: usher_url(usher_root, login, signature, value),
|
||||
expires: about.get("expires").and_then(|e| e.as_i64()).unwrap_or_else(|| chrono::Utc::now().timestamp() + 20 * 60),
|
||||
hide_ads: about.get("hide_ads").and_then(|h| h.as_bool()).unwrap_or(false),
|
||||
restricted: about
|
||||
.pointer("/chansub/restricted_bitrates")
|
||||
.and_then(|r| r.as_array())
|
||||
.map(|a| a.iter().filter_map(|n| n.as_str().map(str::to_string)).collect())
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The integrity token
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Integrity {
|
||||
device_id: String,
|
||||
token: String,
|
||||
/// Unix seconds.
|
||||
expires_at: i64,
|
||||
}
|
||||
|
||||
/// Integrity tokens by the session they were made for ("" for anonymous): Twitch wants the one made as the
|
||||
/// account for a call made as the account, and refuses one made anonymously for it.
|
||||
fn cache() -> &'static Mutex<HashMap<String, Integrity>> {
|
||||
static CACHE: std::sync::OnceLock<Mutex<HashMap<String, Integrity>>> = std::sync::OnceLock::new();
|
||||
CACHE.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
type Waiting = HashMap<String, tokio::sync::oneshot::Sender<Result<(String, i64), String>>>;
|
||||
|
||||
fn waiting() -> &'static Mutex<Waiting> {
|
||||
static WAITING: std::sync::OnceLock<Mutex<Waiting>> = std::sync::OnceLock::new();
|
||||
WAITING.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// How long before its end an integrity token is let go of.
|
||||
const INTEGRITY_MARGIN: i64 = 600;
|
||||
|
||||
fn good_integrity(session: Option<&str>) -> Option<Integrity> {
|
||||
let held = cache().lock().unwrap().get(session.unwrap_or("")).cloned()?;
|
||||
(held.expires_at - chrono::Utc::now().timestamp() > INTEGRITY_MARGIN).then_some(held)
|
||||
}
|
||||
|
||||
/// The window's answer to a request for an integrity token: the token and when
|
||||
/// it ends in Unix milliseconds, or why one could not be made.
|
||||
pub fn provide_integrity(request_id: &str, outcome: Result<(String, i64), String>) -> bool {
|
||||
match waiting().lock().unwrap().remove(request_id) {
|
||||
Some(reply) => reply.send(outcome).is_ok(),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks the window to make an integrity token and waits for it.
|
||||
async fn obtain_integrity(state: &AppState, session: Option<&str>) -> Option<Integrity> {
|
||||
if let Some(held) = good_integrity(session) {
|
||||
return Some(held);
|
||||
}
|
||||
let request_id = format!("integrity-{}", crate::model::next_message_id());
|
||||
let (reply, answer) = tokio::sync::oneshot::channel();
|
||||
waiting().lock().unwrap().insert(request_id.clone(), reply);
|
||||
let device_id = gql::device_id().to_string();
|
||||
// The window that makes it asks Twitch as the account (the session goes to main alone: it is not forwarded on).
|
||||
state.events.emit("twitchIntegrityNeeded", json!({ "requestId": request_id, "deviceId": device_id, "authToken": session.unwrap_or("") }));
|
||||
let outcome = tokio::time::timeout(std::time::Duration::from_secs(60), answer).await;
|
||||
waiting().lock().unwrap().remove(&request_id);
|
||||
match outcome {
|
||||
Ok(Ok(Ok((token, expires_ms)))) => {
|
||||
let made = Integrity { device_id, token, expires_at: expires_ms / 1000 };
|
||||
cache().lock().unwrap().insert(session.unwrap_or("").to_string(), made.clone());
|
||||
Some(made)
|
||||
}
|
||||
Ok(Ok(Err(why))) => {
|
||||
tracing::debug!("twitch: no integrity token: {why}");
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes the integrity token for a session ahead of the call that will want it (it takes a few seconds and lasts
|
||||
/// hours), so that call does not wait for it. Nothing is said if it cannot be made: the call tries again then.
|
||||
pub async fn warm_up(state: &AppState, session: &str) {
|
||||
let _ = obtain_integrity(state, Some(session)).await;
|
||||
}
|
||||
|
||||
/// One call to the GraphQL as the person's twitch.tv session, with an integrity token if Twitch asks for one.
|
||||
/// Answers the JSON, whose own `errors` are the caller's to read; a refusal for want of integrity is a `NeedsIntegrity`.
|
||||
pub async fn call_as_session(state: &AppState, http: &reqwest::Client, gql_root: &str, body: &Value, web_token: &str) -> Result<Value, PlaybackError> {
|
||||
let mut integrity = good_integrity(Some(web_token));
|
||||
for attempt in 0..2 {
|
||||
let who = Who { web_token: Some(web_token), integrity: integrity.as_ref().map(|i| (i.device_id.as_str(), i.token.as_str())) };
|
||||
let answer = gql::call(http, gql_root, body, &who).await.map_err(PlaybackError::Failed)?;
|
||||
if gql::error_of(&answer).map(|s| s.to_lowercase().contains("integrity")).unwrap_or(false) {
|
||||
if attempt == 0 {
|
||||
integrity = obtain_integrity(state, Some(web_token)).await;
|
||||
if integrity.is_some() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return Err(PlaybackError::NeedsIntegrity);
|
||||
}
|
||||
return Ok(answer);
|
||||
}
|
||||
Err(PlaybackError::NeedsIntegrity)
|
||||
}
|
||||
|
||||
/// The address of a channel's video, or why there is none.
|
||||
pub async fn playback(state: &AppState, http: &reqwest::Client, gql_root: &str, usher_root: &str, login: &str, web_token: Option<&str>) -> Result<Playback, PlaybackError> {
|
||||
let mut integrity = good_integrity(web_token.filter(|t| !t.is_empty()));
|
||||
for attempt in 0..2 {
|
||||
let who = Who { web_token, integrity: integrity.as_ref().map(|i| (i.device_id.as_str(), i.token.as_str())) };
|
||||
let answer = gql::call(http, gql_root, &token_query(login), &who).await.map_err(PlaybackError::Failed)?;
|
||||
if let Some(said) = gql::error_of(&answer) {
|
||||
if said.to_lowercase().contains("integrity") {
|
||||
if attempt == 0 {
|
||||
integrity = obtain_integrity(state, web_token.filter(|t| !t.is_empty())).await;
|
||||
if integrity.is_some() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return Err(PlaybackError::NeedsIntegrity);
|
||||
}
|
||||
return Err(PlaybackError::Failed(anyhow!("Twitch would not give the stream a token: {said}")));
|
||||
}
|
||||
return read_token(&answer, login, usher_root);
|
||||
}
|
||||
Err(PlaybackError::NeedsIntegrity)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::backend::kick::testkit;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
/// The integrity cache and the requests waiting on the window are the process's, so the tests that touch
|
||||
/// them take turns.
|
||||
fn turn() -> &'static tokio::sync::Mutex<()> {
|
||||
static TURN: std::sync::OnceLock<tokio::sync::Mutex<()>> = std::sync::OnceLock::new();
|
||||
TURN.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// What the token's JSON says, as captured from a live answer, with the fields varied.
|
||||
fn token(extra: Value) -> Value {
|
||||
let mut about = json!({
|
||||
"adblock": false, "authorization": { "forbidden": false, "reason": "" }, "channel": "tylorturner", "channel_id": 106959846,
|
||||
"chansub": { "restricted_bitrates": [], "view_until": 1924905600 }, "device_id": "abc", "expires": 1791950724,
|
||||
"hide_ads": false, "https_required": true, "platform": "web", "player_type": "site", "server_ads": true, "show_ads": true
|
||||
});
|
||||
for (k, v) in extra.as_object().cloned().unwrap_or_default() {
|
||||
about[k] = v;
|
||||
}
|
||||
json!({ "data": { "streamPlaybackAccessToken": { "value": about.to_string(), "signature": "deadbeef" } } })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_becomes_a_playlist_address_that_names_the_channel_and_the_signature() {
|
||||
let p = read_token(&token(json!({})), "TylorTurner", USHER_ROOT).unwrap();
|
||||
assert!(p.url.starts_with("https://usher.ttvnw.net/api/v2/channel/hls/tylorturner.m3u8?"), "{}", p.url);
|
||||
assert!(p.url.contains("sig=deadbeef") && p.url.contains("allow_source=true") && p.url.contains("supported_codecs=h264"), "{}", p.url);
|
||||
assert!(p.url.contains("token=%7B%22adblock%22"), "the token is the JSON, escaped: {}", p.url);
|
||||
assert_eq!((p.expires, p.hide_ads), (1791950724, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_the_token_says_about_the_account_is_kept() {
|
||||
let p = read_token(&token(json!({ "hide_ads": true, "chansub": { "restricted_bitrates": ["chunked", "720p60"] } })), "x", USHER_ROOT).unwrap();
|
||||
assert!(p.hide_ads);
|
||||
assert_eq!(p.restricted, vec!["chunked", "720p60"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_channel_that_is_not_streaming_has_no_token_and_a_stream_that_may_not_be_had_says_why() {
|
||||
assert!(matches!(read_token(&json!({ "data": { "streamPlaybackAccessToken": null } }), "x", USHER_ROOT), Err(PlaybackError::Offline)));
|
||||
match read_token(&token(json!({ "authorization": { "forbidden": true, "reason": "This stream is for subscribers only" } })), "x", USHER_ROOT) {
|
||||
Err(PlaybackError::Forbidden(why)) => assert!(why.contains("subscribers only")),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
match read_token(&token(json!({ "geoblock_reason": "blocked in your country" })), "x", USHER_ROOT) {
|
||||
Err(PlaybackError::Blocked(why)) => assert_eq!(why, "blocked in your country"),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
assert!(matches!(read_token(&json!({ "data": { "streamPlaybackAccessToken": { "value": "not json", "signature": "s" } } }), "x", USHER_ROOT), Err(PlaybackError::Failed(_))));
|
||||
let said = PlaybackError::Forbidden(String::new()).to_string();
|
||||
assert!(said.contains("subscribers only"), "{said}");
|
||||
}
|
||||
|
||||
/// A GraphQL that answers each call with the next canned answer, and remembers the headers it was sent.
|
||||
async fn graphql(answers: Vec<(u16, String)>) -> (String, Arc<Mutex<Vec<String>>>) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let root = format!("http://{}", listener.local_addr().unwrap());
|
||||
let seen: Arc<Mutex<Vec<String>>> = Default::default();
|
||||
let log = seen.clone();
|
||||
let answers = Arc::new(Mutex::new(answers.into_iter().collect::<std::collections::VecDeque<_>>()));
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut sock, _)) = listener.accept().await else { return };
|
||||
let (log, answers) = (log.clone(), answers.clone());
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 16384];
|
||||
let mut got = 0;
|
||||
loop {
|
||||
let n = sock.read(&mut buf[got..]).await.unwrap_or(0);
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
got += n;
|
||||
let text = String::from_utf8_lossy(&buf[..got]).to_string();
|
||||
if let Some(end) = text.find("\r\n\r\n") {
|
||||
let length = text[..end].lines().find_map(|l| l.to_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))).unwrap_or(0);
|
||||
if got >= end + 4 + length {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
log.lock().unwrap().push(String::from_utf8_lossy(&buf[..got]).to_string());
|
||||
let (status, body) = answers.lock().unwrap().pop_front().unwrap_or((500, "{}".to_string()));
|
||||
let reply = format!("HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
|
||||
let _ = sock.write_all(reply.as_bytes()).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
(root, seen)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_call_is_the_players_own_and_carries_the_accounts_token_only_when_there_is_one() {
|
||||
let _turn = turn().lock().await;
|
||||
cache().lock().unwrap().clear();
|
||||
let state = testkit::simulated_daemon("twitch-playback");
|
||||
let (root, seen) = graphql(vec![(200, token(json!({})).to_string()), (200, token(json!({ "hide_ads": true })).to_string())]).await;
|
||||
let http = reqwest::Client::new();
|
||||
let anonymous = playback(&state, &http, &root, USHER_ROOT, "xqc", None).await.unwrap();
|
||||
assert!(!anonymous.hide_ads);
|
||||
let signed = playback(&state, &http, &root, USHER_ROOT, "xqc", Some("web-session")).await.unwrap();
|
||||
assert!(signed.hide_ads);
|
||||
let asked = seen.lock().unwrap().clone();
|
||||
let (first, second) = (asked[0].to_lowercase(), asked[1].to_lowercase());
|
||||
assert!(first.contains("client-id: kimne78kx3ncx6brgo4mv6wki5h1ko") && first.contains("x-device-id: "), "{first}");
|
||||
assert!(first.contains(r#""operationname":"playbackaccesstoken""#) && first.contains(&TOKEN_HASH.to_lowercase()), "{first}");
|
||||
assert!(first.contains(r#""playertype":"site""#) && first.contains(r#""login":"xqc""#));
|
||||
assert!(!first.contains("authorization:"), "an anonymous request carries no account");
|
||||
assert!(second.contains("authorization: oauth web-session"), "{second}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_error_that_is_not_about_integrity_is_said_and_a_refusal_is_said_with_its_status() {
|
||||
let _turn = turn().lock().await;
|
||||
cache().lock().unwrap().clear();
|
||||
let state = testkit::simulated_daemon("twitch-playback-errors");
|
||||
let (root, _) = graphql(vec![
|
||||
(200, json!({ "errors": [{ "message": "service timeout" }] }).to_string()),
|
||||
(400, json!({ "message": "The \"Client-ID\" header is invalid." }).to_string()),
|
||||
])
|
||||
.await;
|
||||
let http = reqwest::Client::new();
|
||||
let e = playback(&state, &http, &root, USHER_ROOT, "xqc", None).await.unwrap_err();
|
||||
assert!(e.to_string().contains("service timeout"), "{e}");
|
||||
let e = playback(&state, &http, &root, USHER_ROOT, "xqc", None).await.unwrap_err();
|
||||
assert!(e.to_string().contains("400") && e.to_string().contains("Client-ID"), "{e}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn when_twitch_wants_an_integrity_token_the_window_is_asked_and_the_call_is_made_again_with_it() {
|
||||
let _turn = turn().lock().await;
|
||||
cache().lock().unwrap().clear();
|
||||
let state = testkit::simulated_daemon("twitch-playback-integrity");
|
||||
cache().lock().unwrap().clear();
|
||||
let (root, seen) = graphql(vec![(200, json!({ "errors": [{ "message": "failed integrity check" }] }).to_string()), (200, token(json!({})).to_string())]).await;
|
||||
let mut events = state.events.subscribe();
|
||||
let answerer = tokio::spawn(async move {
|
||||
loop {
|
||||
let event = events.recv().await.expect("the bus is open");
|
||||
if event.name == "twitchIntegrityNeeded" {
|
||||
let id = event.data["requestId"].as_str().unwrap().to_string();
|
||||
assert_eq!(event.data["deviceId"], gql::device_id());
|
||||
let ends = (chrono::Utc::now().timestamp() + 16 * 3600) * 1000;
|
||||
assert!(provide_integrity(&id, Ok(("v4.local.made".to_string(), ends))));
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
let got = playback(&state, &reqwest::Client::new(), &root, USHER_ROOT, "xqc", None).await.expect("works the second time");
|
||||
answerer.await.unwrap();
|
||||
assert!(got.url.contains("xqc.m3u8"));
|
||||
let second = seen.lock().unwrap()[1].to_lowercase();
|
||||
assert!(second.contains("client-integrity: v4.local.made"), "{second}");
|
||||
// Kept: the next call carries it without asking again.
|
||||
assert!(good_integrity(None).is_some());
|
||||
cache().lock().unwrap().clear();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_integrity_token_that_cannot_be_made_is_a_reason_not_a_hang() {
|
||||
let _turn = turn().lock().await;
|
||||
cache().lock().unwrap().clear();
|
||||
let state = testkit::simulated_daemon("twitch-playback-integrity-fails");
|
||||
cache().lock().unwrap().clear();
|
||||
let (root, _) = graphql(vec![(200, json!({ "errors": [{ "message": "failed integrity check" }] }).to_string())]).await;
|
||||
let mut events = state.events.subscribe();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let event = events.recv().await.expect("the bus is open");
|
||||
if event.name == "twitchIntegrityNeeded" {
|
||||
provide_integrity(event.data["requestId"].as_str().unwrap(), Err("the page would not load".to_string()));
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
let e = playback(&state, &reqwest::Client::new(), &root, USHER_ROOT, "xqc", None).await.unwrap_err();
|
||||
assert!(matches!(e, PlaybackError::NeedsIntegrity), "{e}");
|
||||
assert!(!provide_integrity("nobody-asked", Err("late".into())), "a late answer to nothing is refused");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_call_made_as_the_account_asks_for_an_integrity_token_made_as_the_account_and_keeps_it_apart_from_the_anonymous_one() {
|
||||
let _turn = turn().lock().await;
|
||||
cache().lock().unwrap().clear();
|
||||
let state = testkit::simulated_daemon("twitch-session-integrity");
|
||||
let (root, seen) = graphql(vec![(200, json!({ "errors": [{ "message": "failed integrity check" }] }).to_string()), (200, json!({ "data": { "x": 1 } }).to_string()), (200, json!({ "data": { "x": 2 } }).to_string())]).await;
|
||||
let mut events = state.events.subscribe();
|
||||
let answerer = tokio::spawn(async move {
|
||||
loop {
|
||||
let event = events.recv().await.expect("the bus is open");
|
||||
if event.name == "twitchIntegrityNeeded" {
|
||||
// Said to the window that makes it, so it can make it as the account.
|
||||
assert_eq!(event.data["authToken"], "web-session");
|
||||
let id = event.data["requestId"].as_str().unwrap().to_string();
|
||||
let ends = (chrono::Utc::now().timestamp() + 16 * 3600) * 1000;
|
||||
assert!(provide_integrity(&id, Ok(("v4.local.as-account".to_string(), ends))));
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
let http = reqwest::Client::new();
|
||||
call_as_session(&state, &http, &root, &json!({ "query": "mutation { x }" }), "web-session").await.unwrap();
|
||||
answerer.await.unwrap();
|
||||
assert!(seen.lock().unwrap()[1].contains("client-integrity: v4.local.as-account"));
|
||||
// The token made as the account is the account's; an anonymous call has none held.
|
||||
assert!(good_integrity(Some("web-session")).is_some());
|
||||
assert!(good_integrity(None).is_none());
|
||||
// And the next call as the account uses it without asking again.
|
||||
call_as_session(&state, &http, &root, &json!({ "query": "mutation { x }" }), "web-session").await.unwrap();
|
||||
assert!(seen.lock().unwrap()[2].contains("client-integrity: v4.local.as-account"));
|
||||
cache().lock().unwrap().clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,746 @@
|
||||
//! What a signed-in twitch.tv session can do in a channel that the official API cannot: see the channel
|
||||
//! points balance, put points on a prediction, vote in a poll. (The bonus chest is deliberately not claimed from
|
||||
//! here: it only appears to a viewer that reports watching, which moho does not do.)
|
||||
//!
|
||||
//! All of it is Twitch's private GraphQL (see gql.rs), and all of it as the person's own twitch.tv session - the
|
||||
//! `auth-token` that signing in leaves with the account (see `playback`), since the token moho uses for chat
|
||||
//! and the official API is not accepted there. Without that session these say so, in words, and do nothing.
|
||||
//!
|
||||
//! Nothing here runs by itself. A balance is read when the window shows the channel; a bet is placed and a vote
|
||||
//! cast only when the person presses the button for it. The player's "watch time" reports,
|
||||
//! which a points bot sends to earn faster, are not sent (see the plan, Gitea issue #55).
|
||||
|
||||
use super::gql;
|
||||
use super::playback::{self, PlaybackError};
|
||||
use crate::state::AppState;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// The balance in a channel.
|
||||
#[derive(Debug, PartialEq, Clone)]
|
||||
pub struct Points {
|
||||
pub balance: Option<u64>,
|
||||
/// What the channel calls its points, if it renamed them.
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
const CONTEXT: &str = "query($login:String!){ user(login:$login){ channel{ self{ communityPoints{ balance } } communityPointsSettings{ name } } } }";
|
||||
|
||||
pub fn read_points(answer: &Value) -> Option<Points> {
|
||||
let channel = answer.pointer("/data/user/channel").filter(|c| !c.is_null())?;
|
||||
let points = channel.pointer("/self/communityPoints").filter(|p| !p.is_null());
|
||||
Some(Points {
|
||||
balance: points.and_then(|p| p.get("balance")).and_then(|b| b.as_u64()),
|
||||
name: channel.pointer("/communityPointsSettings/name").and_then(|n| n.as_str()).filter(|n| !n.is_empty()).map(str::to_string),
|
||||
})
|
||||
}
|
||||
|
||||
/// What spending on a reward does, which decides what it takes to redeem it.
|
||||
#[derive(Debug, PartialEq, Clone, Copy)]
|
||||
pub enum Kind {
|
||||
/// One the streamer made: by id, with a word if it asks for one.
|
||||
Custom,
|
||||
/// Twitch's "Highlight My Message": the message is typed.
|
||||
Highlight,
|
||||
/// Twitch's "Unlock a Random Sub Emote".
|
||||
RandomEmote,
|
||||
/// "Choose an Emote to Unlock": one of the channel's subscriber emotes the account does not have.
|
||||
ChosenEmote,
|
||||
/// "Modify a Single Emote": one of the changed versions Twitch offers of a subscriber emote.
|
||||
ModifiedEmote,
|
||||
/// One that cannot be had from here (it is sent from twitch.tv's own chat box).
|
||||
Other,
|
||||
}
|
||||
|
||||
/// One of the things a reward asks the person to pick from.
|
||||
#[derive(Debug, PartialEq, Clone)]
|
||||
pub struct Choice {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub image: Option<String>,
|
||||
/// For a changed version of an emote: the emote it is a change of (its name), and that emote's picture, so the
|
||||
/// window can show the emotes first and then what can be done to the one picked.
|
||||
pub group: Option<String>,
|
||||
pub group_image: Option<String>,
|
||||
}
|
||||
|
||||
/// One thing a channel trades its points for.
|
||||
#[derive(Debug, PartialEq, Clone)]
|
||||
pub struct Reward {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub cost: u64,
|
||||
pub prompt: Option<String>,
|
||||
/// Whether it can be had right now: switched on, in stock and not paused.
|
||||
pub enabled: bool,
|
||||
pub paused: bool,
|
||||
/// It asks the person to type something.
|
||||
pub needs_input: bool,
|
||||
pub color: Option<String>,
|
||||
pub image: Option<String>,
|
||||
pub kind: Kind,
|
||||
/// What it asks the person to pick from, for the emote unlocks.
|
||||
pub choices: Vec<Choice>,
|
||||
/// Why it cannot be had from here, where that is known beforehand.
|
||||
pub unavailable: Option<String>,
|
||||
}
|
||||
|
||||
const REWARDS: &str = "query($login:String!){ user(login:$login){ channel{ id self{ communityPoints{ balance } } communityPointsSettings{ name customRewards{ id title cost prompt isEnabled isPaused isInStock isUserInputRequired backgroundColor image{ url } defaultImage{ url } } automaticRewards{ id type cost defaultCost isEnabled defaultBackgroundColor backgroundColor defaultImage{ url } } emoteVariants{ id isUnlockable emote{ id token } modifications{ id } } } } } }";
|
||||
|
||||
fn emote_image(id: &str) -> String {
|
||||
format!("https://static-cdn.jtvnw.net/emoticons/v2/{id}/default/dark/1.0")
|
||||
}
|
||||
|
||||
/// What a modification's two letters do to an emote, in words.
|
||||
fn modification_name(id: &str) -> &'static str {
|
||||
match id.rsplit('_').next().unwrap_or("") {
|
||||
"BW" => "black and white",
|
||||
"HF" => "flipped",
|
||||
"SG" => "with sunglasses",
|
||||
"SQ" => "squished",
|
||||
"TK" => "thick",
|
||||
_ => "changed",
|
||||
}
|
||||
}
|
||||
|
||||
/// A channel's rewards, its number, and this account's balance, from one answer. `owned` is the ids of the
|
||||
/// emotes the account can already use there: an unlock does not offer those again, and a change is offered
|
||||
/// only for those.
|
||||
pub fn read_rewards(answer: &Value, owned: &std::collections::HashSet<String>) -> Option<(String, Vec<Reward>, Points)> {
|
||||
let channel = answer.pointer("/data/user/channel").filter(|c| !c.is_null())?;
|
||||
let flag = |r: &Value, k: &str, default: bool| r.get(k).and_then(|b| b.as_bool()).unwrap_or(default);
|
||||
let mut rewards: Vec<Reward> = channel
|
||||
.pointer("/communityPointsSettings/customRewards")
|
||||
.and_then(|r| r.as_array())
|
||||
.map(|list| {
|
||||
list.iter()
|
||||
.filter_map(|r| {
|
||||
Some(Reward {
|
||||
id: r.get("id")?.as_str()?.to_string(),
|
||||
title: r.get("title")?.as_str()?.to_string(),
|
||||
cost: r.get("cost")?.as_u64()?,
|
||||
prompt: r.get("prompt").and_then(|p| p.as_str()).filter(|p| !p.is_empty()).map(str::to_string),
|
||||
enabled: flag(r, "isEnabled", false) && flag(r, "isInStock", true) && !flag(r, "isPaused", false),
|
||||
paused: flag(r, "isPaused", false),
|
||||
needs_input: flag(r, "isUserInputRequired", false),
|
||||
color: r.get("backgroundColor").and_then(|c| c.as_str()).map(str::to_string),
|
||||
image: r.pointer("/image/url").or_else(|| r.pointer("/defaultImage/url")).and_then(|u| u.as_str()).map(str::to_string),
|
||||
kind: Kind::Custom,
|
||||
choices: Vec::new(),
|
||||
unavailable: None,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// The emotes Twitch lets be unlocked or changed are the channel's `emoteVariants`, and only the ones it marks
|
||||
// unlockable are in the pool (asking for another is refused as an invalid emote). The unlocks offer those the
|
||||
// account does not have; a change is offered for those it does.
|
||||
struct Variant {
|
||||
id: String,
|
||||
token: String,
|
||||
unlockable: bool,
|
||||
modifications: Vec<String>,
|
||||
}
|
||||
let variants: Vec<Variant> = channel
|
||||
.pointer("/communityPointsSettings/emoteVariants")
|
||||
.and_then(|v| v.as_array())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|v| {
|
||||
Some(Variant {
|
||||
id: v.get("id")?.as_str()?.to_string(),
|
||||
token: v.pointer("/emote/token").and_then(|t| t.as_str())?.to_string(),
|
||||
unlockable: flag(v, "isUnlockable", false),
|
||||
modifications: v.get("modifications").and_then(|m| m.as_array()).into_iter().flatten().filter_map(|m| m.get("id").and_then(|i| i.as_str()).map(str::to_string)).collect(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let unowned: Vec<Choice> = variants.iter().filter(|v| v.unlockable && !owned.contains(&v.id)).map(|v| Choice { id: v.id.clone(), label: v.token.clone(), image: Some(emote_image(&v.id)), group: None, group_image: None }).collect();
|
||||
let changes: Vec<Choice> = variants
|
||||
.iter()
|
||||
.filter(|v| owned.contains(&v.id))
|
||||
.flat_map(|v| {
|
||||
v.modifications.iter().map(move |m| Choice { label: format!("{}, {}", v.token, modification_name(m)), image: Some(emote_image(m)), id: m.clone(), group: Some(v.token.clone()), group_image: Some(emote_image(&v.id)) })
|
||||
})
|
||||
.collect();
|
||||
let variants = changes;
|
||||
|
||||
for r in channel.pointer("/communityPointsSettings/automaticRewards").and_then(|r| r.as_array()).into_iter().flatten() {
|
||||
let (Some(id), Some(kind)) = (r.get("id").and_then(|i| i.as_str()), r.get("type").and_then(|t| t.as_str())) else { continue };
|
||||
let cost = r.get("cost").and_then(|c| c.as_u64()).or_else(|| r.get("defaultCost").and_then(|c| c.as_u64())).unwrap_or(0);
|
||||
let (title, prompt, kind, needs_input, choices) = match kind {
|
||||
"SEND_HIGHLIGHTED_MESSAGE" => ("Highlight My Message", "Write something special and give it some glitz in chat", Kind::Highlight, true, Vec::new()),
|
||||
"RANDOM_SUB_EMOTE_UNLOCK" => ("Unlock a Random Sub Emote", "Unlocks a random subscriber emote you do not have", Kind::RandomEmote, false, Vec::new()),
|
||||
"CHOSEN_SUB_EMOTE_UNLOCK" => ("Choose an Emote to Unlock", "Pick a subscriber emote to unlock for 24 hours", Kind::ChosenEmote, false, unowned.clone()),
|
||||
"CHOSEN_MODIFIED_SUB_EMOTE_UNLOCK" => ("Modify a Single Emote", "Change one of this channel's emotes you have", Kind::ModifiedEmote, false, variants.clone()),
|
||||
"SINGLE_MESSAGE_BYPASS_SUB_MODE" => ("Send a Message in Sub-Only Mode", "Send one message past subscribers-only mode", Kind::Other, false, Vec::new()),
|
||||
// Paid for in Bits and not in points: not for here.
|
||||
_ => continue,
|
||||
};
|
||||
let mut unavailable = None;
|
||||
if kind == Kind::Other {
|
||||
unavailable = Some("Send this one from twitch.tv's chat box".to_string());
|
||||
} else if (matches!(kind, Kind::ChosenEmote | Kind::ModifiedEmote) && choices.is_empty()) || (kind == Kind::RandomEmote && unowned.is_empty()) {
|
||||
unavailable = Some(if kind == Kind::ModifiedEmote { "You have none of this channel's emotes to change yet".to_string() } else { "There is nothing left here to unlock".to_string() });
|
||||
}
|
||||
rewards.push(Reward {
|
||||
id: id.to_string(),
|
||||
title: title.to_string(),
|
||||
cost,
|
||||
prompt: Some(prompt.to_string()),
|
||||
enabled: flag(r, "isEnabled", false),
|
||||
paused: false,
|
||||
needs_input,
|
||||
color: r.get("backgroundColor").and_then(|c| c.as_str()).or_else(|| r.get("defaultBackgroundColor").and_then(|c| c.as_str())).map(str::to_string),
|
||||
image: r.pointer("/defaultImage/url").and_then(|u| u.as_str()).map(str::to_string),
|
||||
kind,
|
||||
choices,
|
||||
unavailable,
|
||||
});
|
||||
}
|
||||
Some((channel.get("id")?.as_str()?.to_string(), rewards, read_points(answer)?))
|
||||
}
|
||||
|
||||
fn explain(e: PlaybackError) -> String {
|
||||
match e {
|
||||
PlaybackError::NeedsIntegrity => "Twitch wants proof this is a browser and moho could not make it. Try again in a moment".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
const NEEDS_SESSION: &str = "Sign in to Twitch from Accounts again: channel points are only available to the twitch.tv session a sign-in brings";
|
||||
|
||||
/// A fresh id for one vote or bet, so a request that is repeated is the same one and not a second. In the form
|
||||
/// of a UUID, which is what Twitch's own page sends.
|
||||
fn fresh_id() -> String {
|
||||
use rand::RngCore;
|
||||
let mut b = [0u8; 16];
|
||||
rand::thread_rng().fill_bytes(&mut b);
|
||||
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])
|
||||
}
|
||||
|
||||
/// A mutation's own refusal, in words. Twitch answers with a code in the payload, not an HTTP error.
|
||||
fn refusal(answer: &Value, field: &str) -> Option<String> {
|
||||
if let Some(said) = gql::error_of(answer) {
|
||||
return Some(match said.as_str() {
|
||||
"unauthenticated" => "Twitch no longer accepts the twitch.tv sign-in. Sign in to Twitch again from Accounts".to_string(),
|
||||
other => other.to_string(),
|
||||
});
|
||||
}
|
||||
let code = answer.pointer(&format!("/data/{field}/error/code")).and_then(|c| c.as_str())?;
|
||||
Some(match code {
|
||||
"NOT_ENOUGH_POINTS" | "INSUFFICIENT_FUNDS" => "Not enough channel points".to_string(),
|
||||
"ALREADY_VOTED" | "ALREADY_PREDICTED" => "You have already answered that".to_string(),
|
||||
"CANNOT_PREDICT_ON_BOTH_OUTCOMES" | "OTHER_OUTCOME_ALREADY_PREDICTED" => "You already put points on the other outcome".to_string(),
|
||||
"EVENT_NOT_ACTIVE" | "POLL_NOT_ACTIVE" | "PREDICTION_LOCKED" => "That is closed".to_string(),
|
||||
"GLOBAL_COOLDOWN" | "COOLDOWN" => "That reward is cooling down".to_string(),
|
||||
"MAX_PER_STREAM" | "MAX_PER_USER_PER_STREAM" | "MAX_REDEMPTIONS" => "That reward has been had as many times as this stream allows".to_string(),
|
||||
"REWARD_DISABLED" | "REWARD_NOT_ENABLED" | "REWARD_PAUSED" | "REWARD_OUT_OF_STOCK" => "That reward is not available right now".to_string(),
|
||||
"CHANNEL_NOT_LIVE" | "STREAM_NOT_LIVE" => "That reward can be had while the channel is live".to_string(),
|
||||
"NOT_FOUND" | "POLL_NOT_FOUND" | "EVENT_NOT_FOUND" | "CLAIM_NOT_FOUND" => "That is no longer there".to_string(),
|
||||
"INVALID_EMOTE" | "EMOTE_NOT_UNLOCKABLE" | "EMOTE_ALREADY_UNLOCKED" | "EMOTE_NOT_FOUND" => "Twitch will not unlock that emote (it may already be yours, or not in this channel's pool)".to_string(),
|
||||
"MAX_POINTS_EXCEEDED" => "That is more than the channel allows on one prediction".to_string(),
|
||||
other => format!("Twitch refused: {}", other.to_lowercase().replace('_', " ")),
|
||||
})
|
||||
}
|
||||
|
||||
fn token(web_token: Option<&str>) -> Result<&str, String> {
|
||||
web_token.filter(|t| !t.is_empty()).ok_or_else(|| NEEDS_SESSION.to_string())
|
||||
}
|
||||
|
||||
/// The balance and any bonus waiting, for a channel by login.
|
||||
pub async fn context(state: &AppState, http: &reqwest::Client, root: &str, login: &str, web_token: Option<&str>) -> Result<Points, String> {
|
||||
let token = token(web_token)?;
|
||||
let answer = playback::call_as_session(state, http, root, &gql::query(CONTEXT, json!({ "login": login })), token).await.map_err(explain)?;
|
||||
if let Some(said) = gql::error_of(&answer) {
|
||||
return Err(said);
|
||||
}
|
||||
read_points(&answer).ok_or_else(|| format!("Twitch has no channel points for {login}"))
|
||||
}
|
||||
|
||||
/// Puts points on an outcome of a prediction.
|
||||
pub async fn bet(state: &AppState, http: &reqwest::Client, root: &str, event_id: &str, outcome_id: &str, points: u64, web_token: Option<&str>) -> Result<(), String> {
|
||||
let token = token(web_token)?;
|
||||
const BET: &str = "mutation($i:MakePredictionInput!){ makePrediction(input:$i){ error{ code } } }";
|
||||
let body = gql::query(BET, json!({ "i": { "eventID": event_id, "outcomeID": outcome_id, "points": points, "transactionID": fresh_id() } }));
|
||||
let answer = playback::call_as_session(state, http, root, &body, token).await.map_err(explain)?;
|
||||
refusal(&answer, "makePrediction").map_or(Ok(()), Err)
|
||||
}
|
||||
|
||||
/// Votes in a poll with the free vote everybody has.
|
||||
pub async fn vote(state: &AppState, http: &reqwest::Client, root: &str, poll_id: &str, choice_id: &str, user_id: &str, web_token: Option<&str>) -> Result<(), String> {
|
||||
let token = token(web_token)?;
|
||||
const VOTE: &str = "mutation($i:VoteInPollInput!){ voteInPoll(input:$i){ error{ code } } }";
|
||||
let body = gql::query(VOTE, json!({ "i": { "pollID": poll_id, "choiceID": choice_id, "userID": user_id, "voteID": fresh_id() } }));
|
||||
let answer = playback::call_as_session(state, http, root, &body, token).await.map_err(explain)?;
|
||||
refusal(&answer, "voteInPoll").map_or(Ok(()), Err)
|
||||
}
|
||||
|
||||
/// A channel's rewards and this account's balance: the panel over the chat lists them.
|
||||
pub async fn rewards(state: &AppState, http: &reqwest::Client, root: &str, login: &str, owned: &std::collections::HashSet<String>, web_token: Option<&str>) -> Result<(String, Vec<Reward>, Points), String> {
|
||||
let token = token(web_token)?;
|
||||
let answer = playback::call_as_session(state, http, root, &gql::query(REWARDS, json!({ "login": login })), token).await.map_err(explain)?;
|
||||
if let Some(said) = gql::error_of(&answer) {
|
||||
return Err(said);
|
||||
}
|
||||
read_rewards(&answer, owned).ok_or_else(|| format!("Twitch has no channel points for {login}"))
|
||||
}
|
||||
|
||||
/// Spends points on a reward. `input` is what a reward that asks for something was given.
|
||||
pub async fn redeem(state: &AppState, http: &reqwest::Client, root: &str, channel_id: &str, reward: &Reward, input: Option<&str>, web_token: Option<&str>) -> Result<(), String> {
|
||||
let token = token(web_token)?;
|
||||
const REDEEM: &str = "mutation($i:RedeemCommunityPointsCustomRewardInput!){ redeemCommunityPointsCustomReward(input:$i){ error{ code } } }";
|
||||
let mut i = json!({ "channelID": channel_id, "cost": reward.cost, "prompt": reward.prompt, "rewardID": reward.id, "title": reward.title, "transactionID": fresh_id() });
|
||||
if let Some(text) = input.map(str::trim).filter(|t| !t.is_empty()) {
|
||||
i["textInput"] = json!(text);
|
||||
}
|
||||
let answer = playback::call_as_session(state, http, root, &gql::query(REDEEM, json!({ "i": i })), token).await.map_err(explain)?;
|
||||
refusal(&answer, "redeemCommunityPointsCustomReward").map_or(Ok(()), Err)
|
||||
}
|
||||
|
||||
/// What the person gave a reward: the words typed for it, the emote picked for it.
|
||||
#[derive(Default, Clone, Copy)]
|
||||
pub struct Given<'a> {
|
||||
pub input: Option<&'a str>,
|
||||
pub choice: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// Spends points on one of Twitch's own rewards (a highlighted message, an emote unlock).
|
||||
pub async fn redeem_automatic(state: &AppState, http: &reqwest::Client, root: &str, channel_id: &str, reward: &Reward, given: Given<'_>, web_token: Option<&str>) -> Result<(), String> {
|
||||
let (input, choice) = (given.input, given.choice);
|
||||
let token = token(web_token)?;
|
||||
let base = json!({ "channelID": channel_id, "cost": reward.cost, "transactionID": fresh_id() });
|
||||
let (name, input_type, mut i) = match reward.kind {
|
||||
Kind::Highlight => {
|
||||
let text = input.map(str::trim).filter(|t| !t.is_empty()).ok_or_else(|| "That reward asks you to type something".to_string())?;
|
||||
let mut i = base;
|
||||
i["message"] = json!(text);
|
||||
("sendHighlightedChatMessage", "SendHighlightedChatMessageInput", i)
|
||||
}
|
||||
Kind::RandomEmote => ("unlockRandomSubscriberEmote", "UnlockRandomSubscriberEmoteInput", base),
|
||||
Kind::ChosenEmote | Kind::ModifiedEmote => {
|
||||
let emote = choice.filter(|c| reward.choices.iter().any(|o| o.id == *c)).ok_or_else(|| "Pick an emote first".to_string())?;
|
||||
let mut i = base;
|
||||
i["emoteID"] = json!(emote);
|
||||
if reward.kind == Kind::ChosenEmote {
|
||||
("unlockChosenSubscriberEmote", "UnlockChosenSubscriberEmoteInput", i)
|
||||
} else {
|
||||
("unlockChosenModifiedSubscriberEmote", "UnlockChosenModifiedSubscriberEmoteInput", i)
|
||||
}
|
||||
}
|
||||
_ => return Err(reward.unavailable.clone().unwrap_or_else(|| "That reward cannot be had from here".to_string())),
|
||||
};
|
||||
let mutation = format!("mutation($i:{input_type}!){{ {name}(input:$i){{ error{{ code }} }} }}");
|
||||
i["cost"] = json!(reward.cost);
|
||||
let answer = playback::call_as_session(state, http, root, &gql::query(&mutation, json!({ "i": i })), token).await.map_err(explain)?;
|
||||
refusal(&answer, name).map_or(Ok(()), Err)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// From a conversation: what the window's buttons call
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The account behind a Twitch channel's conversation, and the channel's login.
|
||||
pub(super) fn session_of(state: &AppState, buffer_id: &str) -> Result<(crate::accounts::TwitchAccountConfig, String), String> {
|
||||
let buffer = state.runtime.get_buffer(buffer_id).filter(|b| b.account_id.starts_with("twitch:")).ok_or_else(|| "that is not a Twitch channel".to_string())?;
|
||||
let cfg = state.accounts.get_twitch(&buffer.account_id).ok_or_else(|| "no such Twitch account".to_string())?;
|
||||
Ok((cfg, buffer.name))
|
||||
}
|
||||
|
||||
/// The id Twitch knows a card by, from the one the window knows it by (`poll:<id>`).
|
||||
fn card_id(state: &AppState, buffer_id: &str, kind: &str) -> Result<(String, serde_json::Value), String> {
|
||||
let card = state.runtime.live_card(buffer_id, kind).ok_or_else(|| format!("there is no {kind} open here"))?;
|
||||
let id = card["id"].as_str().and_then(|i| i.strip_prefix(&format!("{kind}:"))).ok_or_else(|| format!("that {kind} has no id to answer"))?.to_string();
|
||||
Ok((id, card))
|
||||
}
|
||||
|
||||
/// Votes in the poll on screen, and shows the vote on its card at once (the tally follows from Twitch).
|
||||
pub async fn vote_in_buffer(state: &AppState, root: &str, buffer_id: &str, choice_id: &str) -> Result<(), String> {
|
||||
let (cfg, _) = session_of(state, buffer_id)?;
|
||||
let (poll_id, mut card) = card_id(state, buffer_id, "poll")?;
|
||||
let http = super::client().map_err(|e| format!("{e:#}"))?;
|
||||
vote(state, &http, root, &poll_id, choice_id, &cfg.user_id, Some(&cfg.web_token)).await?;
|
||||
card["hasVoted"] = json!(true);
|
||||
card["votedOptionId"] = json!(choice_id);
|
||||
crate::backend::kick::cards::publish_card(state, buffer_id, "poll", Some(card));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Puts points on the prediction on screen, and shows the bet on its card at once.
|
||||
pub async fn bet_in_buffer(state: &AppState, root: &str, buffer_id: &str, outcome_id: &str, amount: u64) -> Result<(), String> {
|
||||
let (cfg, channel) = session_of(state, buffer_id)?;
|
||||
let (event_id, mut card) = card_id(state, buffer_id, "prediction")?;
|
||||
if amount < super::realtime::MIN_BET as u64 {
|
||||
return Err(format!("Bets are {} points or more", super::realtime::MIN_BET));
|
||||
}
|
||||
if let Some(balance) = card["balance"].as_u64() {
|
||||
if amount > balance {
|
||||
return Err("Not enough channel points".to_string());
|
||||
}
|
||||
}
|
||||
let http = super::client().map_err(|e| format!("{e:#}"))?;
|
||||
bet(state, &http, root, &event_id, outcome_id, amount, Some(&cfg.web_token)).await?;
|
||||
let stake = card["stake"].as_f64().unwrap_or(0.0) + amount as f64;
|
||||
card["hasVoted"] = json!(true);
|
||||
card["votedOptionId"] = json!(outcome_id);
|
||||
card["stake"] = json!(stake);
|
||||
crate::backend::kick::cards::publish_card(state, buffer_id, "prediction", Some(card));
|
||||
// What is left, from Twitch rather than this one minus the bet: a refund or a cap would make that wrong.
|
||||
super::realtime::refresh_balance(state, root, &cfg.account_id(), &channel).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The balance and any bonus waiting in a conversation's channel; also said to the window as `twitchPoints`.
|
||||
pub async fn points_in_buffer(state: &AppState, root: &str, buffer_id: &str) -> Result<Points, String> {
|
||||
let (cfg, channel) = session_of(state, buffer_id)?;
|
||||
let http = super::client().map_err(|e| format!("{e:#}"))?;
|
||||
let points = context(state, &http, root, &channel, Some(&cfg.web_token)).await?;
|
||||
state.events.emit("twitchPoints", json!({ "bufferId": buffer_id, "points": points.balance, "name": points.name }));
|
||||
Ok(points)
|
||||
}
|
||||
|
||||
/// The ids of the emotes this account can already use in a conversation's channel (the picker's entries are named by
|
||||
/// a token that carries the id: `[twitch:emote:<id>:<name>]`).
|
||||
fn owned_emotes(state: &AppState, account_id: &str, buffer_id: &str) -> std::collections::HashSet<String> {
|
||||
state
|
||||
.runtime
|
||||
.emoji_catalogue(account_id)
|
||||
.into_iter()
|
||||
.filter(|s| s.buffers.iter().any(|b| b == buffer_id))
|
||||
.flat_map(|s| s.emoji)
|
||||
.filter(|e| !e.locked)
|
||||
.filter_map(|e| e.id.strip_prefix("[twitch:").and_then(|t| t.split(':').nth(1)).map(str::to_string))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The rewards of a conversation's channel, and the balance, for the panel; the balance is also said to the window.
|
||||
pub async fn rewards_in_buffer(state: &AppState, root: &str, buffer_id: &str) -> Result<(Vec<Reward>, Points), String> {
|
||||
let (cfg, channel) = session_of(state, buffer_id)?;
|
||||
let http = super::client().map_err(|e| format!("{e:#}"))?;
|
||||
let owned = owned_emotes(state, &cfg.account_id(), buffer_id);
|
||||
let (_, rewards, points) = rewards(state, &http, root, &channel, &owned, Some(&cfg.web_token)).await?;
|
||||
state.events.emit("twitchPoints", json!({ "bufferId": buffer_id, "points": points.balance, "name": points.name }));
|
||||
// Twitch's proof that this is a browser takes a few seconds to make the first time (and then lasts for hours):
|
||||
// made now, while the panel is being read, so a press of Redeem is not what waits for it.
|
||||
if rewards.iter().any(|r| r.enabled && r.kind != Kind::Other) {
|
||||
let (state, token) = (state.clone(), cfg.web_token.clone());
|
||||
tokio::spawn(async move {
|
||||
playback::warm_up(&state, &token).await;
|
||||
});
|
||||
}
|
||||
Ok((rewards, points))
|
||||
}
|
||||
|
||||
/// Redeems a reward of a conversation's channel by its id, after checking it is there, is on offer, and is affordable,
|
||||
/// and answers the balance after. An emote unlock also reads the account's emotes again, so the new one is in the picker.
|
||||
pub async fn redeem_in_buffer(state: &AppState, root: &str, buffer_id: &str, reward_id: &str, input: Option<&str>, choice: Option<&str>) -> Result<Points, String> {
|
||||
let (cfg, channel) = session_of(state, buffer_id)?;
|
||||
let http = super::client().map_err(|e| format!("{e:#}"))?;
|
||||
let owned = owned_emotes(state, &cfg.account_id(), buffer_id);
|
||||
let (channel_id, rewards, points) = rewards(state, &http, root, &channel, &owned, Some(&cfg.web_token)).await?;
|
||||
let reward = rewards.into_iter().find(|r| r.id == reward_id).ok_or_else(|| "That reward is no longer there".to_string())?;
|
||||
if let Some(why) = &reward.unavailable {
|
||||
return Err(why.clone());
|
||||
}
|
||||
if !reward.enabled {
|
||||
return Err(if reward.paused { "That reward is paused right now".to_string() } else { "That reward is not available right now".to_string() });
|
||||
}
|
||||
if reward.needs_input && input.map(str::trim).unwrap_or("").is_empty() {
|
||||
return Err("That reward asks you to type something".to_string());
|
||||
}
|
||||
if let Some(balance) = points.balance {
|
||||
if balance < reward.cost {
|
||||
return Err(format!("You have {balance} of the {} points that costs", reward.cost));
|
||||
}
|
||||
}
|
||||
match reward.kind {
|
||||
Kind::Custom => redeem(state, &http, root, &channel_id, &reward, input, Some(&cfg.web_token)).await?,
|
||||
_ => redeem_automatic(state, &http, root, &channel_id, &reward, Given { input, choice }, Some(&cfg.web_token)).await?,
|
||||
}
|
||||
if matches!(reward.kind, Kind::RandomEmote | Kind::ChosenEmote | Kind::ModifiedEmote) {
|
||||
super::connect::refresh_user_emotes(state, &cfg.account_id(), &channel, &channel_id).await;
|
||||
}
|
||||
points_in_buffer(state, root, buffer_id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_balance_and_the_bonus_are_read_and_a_signed_out_answer_has_neither() {
|
||||
let signed_in = json!({ "data": { "user": { "channel": { "self": { "communityPoints": { "balance": 4200 } }, "communityPointsSettings": { "name": "Sloths" } } } } });
|
||||
assert_eq!(read_points(&signed_in), Some(Points { balance: Some(4200), name: Some("Sloths".into()) }));
|
||||
let anonymous = json!({ "data": { "user": { "channel": { "self": { "communityPoints": null }, "communityPointsSettings": { "name": null } } } } });
|
||||
assert_eq!(read_points(&anonymous), Some(Points { balance: None, name: None }));
|
||||
assert_eq!(read_points(&json!({ "data": { "user": null } })), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refusal_is_said_in_words_whether_it_came_as_an_error_or_in_the_payload() {
|
||||
let payload = json!({ "data": { "makePrediction": { "error": { "code": "NOT_ENOUGH_POINTS" } } } });
|
||||
assert_eq!(refusal(&payload, "makePrediction").as_deref(), Some("Not enough channel points"));
|
||||
let auth = json!({ "errors": [{ "message": "unauthenticated" }], "data": { "makePrediction": null } });
|
||||
assert!(refusal(&auth, "makePrediction").unwrap().contains("Sign in to Twitch again"));
|
||||
let fine = json!({ "data": { "makePrediction": { "error": null } } });
|
||||
assert_eq!(refusal(&fine, "makePrediction"), None);
|
||||
let odd = json!({ "data": { "voteInPoll": { "error": { "code": "SOMETHING_NEW" } } } });
|
||||
assert_eq!(refusal(&odd, "voteInPoll").as_deref(), Some("Twitch refused: something new"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_id_for_a_vote_is_a_uuid_and_is_new_each_time() {
|
||||
let a = fresh_id();
|
||||
assert_eq!(a.len(), 36);
|
||||
assert_eq!(a.matches('-').count(), 4);
|
||||
assert_eq!(&a[14..15], "4");
|
||||
assert_ne!(a, fresh_id());
|
||||
}
|
||||
|
||||
use crate::accounts::TwitchAccountConfig;
|
||||
use crate::backend::kick::testkit;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
/// A GraphQL that answers by what was asked, and keeps what it was sent (headers and body).
|
||||
pub(in crate::backend::twitch) async fn fake_gql(answer: impl Fn(&str) -> String + Send + Sync + 'static) -> (String, Arc<Mutex<Vec<String>>>) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let root = format!("http://{}", listener.local_addr().unwrap());
|
||||
let seen: Arc<Mutex<Vec<String>>> = Default::default();
|
||||
let (log, answer) = (seen.clone(), Arc::new(answer));
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut sock, _)) = listener.accept().await else { return };
|
||||
let (log, answer) = (log.clone(), answer.clone());
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 16384];
|
||||
let mut got = 0;
|
||||
loop {
|
||||
let n = sock.read(&mut buf[got..]).await.unwrap_or(0);
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
got += n;
|
||||
let text = String::from_utf8_lossy(&buf[..got]).to_string();
|
||||
if let Some(end) = text.find("\r\n\r\n") {
|
||||
let length = text[..end].lines().find_map(|l| l.to_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))).unwrap_or(0);
|
||||
if got >= end + 4 + length {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let request = String::from_utf8_lossy(&buf[..got]).to_string();
|
||||
let body = answer(&request);
|
||||
log.lock().unwrap().push(request);
|
||||
let reply = format!("HTTP/1.1 200 X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
|
||||
let _ = sock.write_all(reply.as_bytes()).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
(root, seen)
|
||||
}
|
||||
|
||||
pub(in crate::backend::twitch) fn channel(name: &str, web_token: &str) -> (AppState, String) {
|
||||
let state = testkit::simulated_daemon(name);
|
||||
state.accounts.add_twitch(TwitchAccountConfig { login: "me".into(), user_id: "42".into(), access_token: "t".into(), refresh_token: "r".into(), expires_at: chrono::Utc::now().timestamp() + 9000, web_token: web_token.into(), ..Default::default() }).unwrap();
|
||||
state.runtime.ensure_buffer(&state, "twitch:me", "xqc", "channel");
|
||||
(state, "twitch:me|xqc".to_string())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_vote_is_sent_as_the_twitch_session_and_shows_on_the_card() {
|
||||
let (state, buffer) = channel("twitch-points-vote", "websession");
|
||||
state.runtime.set_live_card(&buffer, "poll", json!({ "id": "poll:q1", "kind": "poll", "options": [] }));
|
||||
let (root, seen) = fake_gql(|_| r#"{"data":{"voteInPoll":{"error":null}}}"#.to_string()).await;
|
||||
vote_in_buffer(&state, &root, &buffer, "choice-x").await.unwrap();
|
||||
let request = seen.lock().unwrap()[0].clone();
|
||||
assert!(request.to_lowercase().contains("authorization: oauth websession"), "{request}");
|
||||
assert!(request.contains("\"pollID\":\"q1\"") && request.contains("\"choiceID\":\"choice-x\"") && request.contains("\"userID\":\"42\""), "{request}");
|
||||
assert!(request.contains("voteID"), "{request}");
|
||||
let card = state.runtime.live_card(&buffer, "poll").unwrap();
|
||||
assert_eq!((card["hasVoted"].as_bool(), card["votedOptionId"].as_str()), (Some(true), Some("choice-x")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refusal_is_said_and_the_card_is_left_alone() {
|
||||
let (state, buffer) = channel("twitch-points-refused", "websession");
|
||||
state.runtime.set_live_card(&buffer, "poll", json!({ "id": "poll:q1", "kind": "poll", "options": [] }));
|
||||
let (root, _) = fake_gql(|_| r#"{"data":{"voteInPoll":{"error":{"code":"ALREADY_VOTED"}}}}"#.to_string()).await;
|
||||
assert_eq!(vote_in_buffer(&state, &root, &buffer, "x").await.unwrap_err(), "You have already answered that");
|
||||
assert!(state.runtime.live_card(&buffer, "poll").unwrap()["hasVoted"].is_null());
|
||||
// And no session, no call.
|
||||
let (state, buffer) = channel("twitch-points-nosession", "");
|
||||
state.runtime.set_live_card(&buffer, "poll", json!({ "id": "poll:q1", "kind": "poll" }));
|
||||
let e = vote_in_buffer(&state, &root, &buffer, "x").await.unwrap_err();
|
||||
assert!(e.contains("Sign in to Twitch from Accounts again"), "{e}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_bet_checks_its_size_places_it_and_asks_what_is_left() {
|
||||
let (state, buffer) = channel("twitch-points-bet", "websession");
|
||||
state.runtime.set_live_card(&buffer, "prediction", json!({ "id": "prediction:e1", "kind": "prediction", "balance": 500, "options": [] }));
|
||||
let (root, seen) = fake_gql(|req| {
|
||||
if req.contains("makePrediction") {
|
||||
r#"{"data":{"makePrediction":{"error":null}}}"#.to_string()
|
||||
} else {
|
||||
r#"{"data":{"user":{"channel":{"self":{"communityPoints":{"balance":400}},"communityPointsSettings":{"name":null}}}}}"#.to_string()
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(bet_in_buffer(&state, &root, &buffer, "o1", 5).await.unwrap_err().contains("10 points or more"));
|
||||
assert_eq!(bet_in_buffer(&state, &root, &buffer, "o1", 900).await.unwrap_err(), "Not enough channel points");
|
||||
assert!(seen.lock().unwrap().is_empty(), "a bet that cannot be placed is not sent");
|
||||
bet_in_buffer(&state, &root, &buffer, "o1", 100).await.unwrap();
|
||||
let requests = seen.lock().unwrap().clone();
|
||||
assert!(requests[0].contains("\"eventID\":\"e1\"") && requests[0].contains("\"outcomeID\":\"o1\"") && requests[0].contains("\"points\":100") && requests[0].contains("transactionID"), "{}", requests[0]);
|
||||
let card = state.runtime.live_card(&buffer, "prediction").unwrap();
|
||||
assert_eq!((card["votedOptionId"].as_str(), card["stake"].as_f64(), card["balance"].as_u64()), (Some("o1"), Some(100.0), Some(400)));
|
||||
}
|
||||
|
||||
/// Against the real Twitch, with nothing at stake: reads a balance, and sends a vote and a bet that
|
||||
/// name things that do not exist, which Twitch refuses in words. Shows the session is accepted and the
|
||||
/// requests are shaped as it wants. `TWITCH_LIVE_WEB_TOKEN=... TWITCH_LIVE_USER_ID=... TWITCH_LIVE_CHANNEL=...
|
||||
/// cargo test --release live_ -- --ignored --nocapture`.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn live_the_session_reads_a_balance_and_twitch_refuses_what_does_not_exist() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let (token, user, channel) = (std::env::var("TWITCH_LIVE_WEB_TOKEN").unwrap(), std::env::var("TWITCH_LIVE_USER_ID").unwrap(), std::env::var("TWITCH_LIVE_CHANNEL").unwrap());
|
||||
let state = testkit::simulated_daemon("twitch-points-live");
|
||||
let http = crate::backend::twitch::client().unwrap();
|
||||
let root = gql::ROOT;
|
||||
// A real prediction and outcome (from `TWITCH_LIVE_EVENT` and `TWITCH_LIVE_OUTCOME`), with more points than
|
||||
// the account has: Twitch refuses it for want of points, which proves the bet is understood and spends nothing.
|
||||
if let (Ok(event), Ok(outcome)) = (std::env::var("TWITCH_LIVE_EVENT"), std::env::var("TWITCH_LIVE_OUTCOME")) {
|
||||
let amount: u64 = std::env::var("TWITCH_LIVE_BET").ok().and_then(|a| a.parse().ok()).unwrap_or(10);
|
||||
println!("a bet of {amount} on a real prediction: {:?}", bet(&state, &http, root, &event, &outcome, amount, Some(&token)).await);
|
||||
}
|
||||
println!("balance: {:?}", context(&state, &http, root, &channel, Some(&token)).await);
|
||||
// The channel's rewards, and a redeem of one that does not exist (nothing is spent).
|
||||
match rewards(&state, &http, root, &channel, &Default::default(), Some(&token)).await {
|
||||
Ok((channel_id, list, points)) => {
|
||||
println!("rewards: {} offered, balance {:?}; first: {:?}", list.len(), points.balance, list.first().map(|r| (&r.title, r.cost, r.enabled)));
|
||||
for r in list.iter().filter(|r| r.kind != Kind::Custom) {
|
||||
println!(" own reward: {} | {} | enabled {} | {} choices | {:?}", r.title, r.cost, r.enabled, r.choices.len(), r.unavailable);
|
||||
}
|
||||
let fake = Reward { id: "00000000-0000-0000-0000-000000000000".into(), title: "nothing".into(), cost: 10, prompt: None, enabled: true, paused: false, needs_input: false, color: None, image: None, kind: Kind::Custom, choices: Vec::new(), unavailable: None };
|
||||
println!("redeem a reward that is not there: {:?}", redeem(&state, &http, root, &channel_id, &fake, None, Some(&token)).await);
|
||||
}
|
||||
Err(e) => println!("rewards: {e}"),
|
||||
}
|
||||
println!("vote in a poll that is not there: {:?}", vote(&state, &http, root, "00000000-0000-0000-0000-000000000000", "00000000-0000-0000-0000-000000000001", &user, Some(&token)).await);
|
||||
println!("bet on a prediction that is not there: {:?}", bet(&state, &http, root, "00000000-0000-0000-0000-000000000000", "00000000-0000-0000-0000-000000000001", 10, Some(&token)).await);
|
||||
}
|
||||
|
||||
const REWARDS_ANSWER: &str = r##"{"data":{"user":{"channel":{"id":"71","self":{"communityPoints":{"balance":600}},"communityPointsSettings":{"name":"Sloths","customRewards":[
|
||||
{"id":"r1","title":"Drink water","cost":500,"prompt":null,"isEnabled":true,"isPaused":false,"isInStock":true,"isUserInputRequired":false,"backgroundColor":"#6BB7FA","image":null,"defaultImage":{"url":"https://img/default.png"}},
|
||||
{"id":"r2","title":"Say a name","cost":100,"prompt":"Who?","isEnabled":true,"isPaused":false,"isInStock":true,"isUserInputRequired":true,"backgroundColor":"#E37C10","image":{"url":"https://img/own.png"},"defaultImage":null},
|
||||
{"id":"r3","title":"Dance","cost":50,"prompt":null,"isEnabled":true,"isPaused":true,"isInStock":true,"isUserInputRequired":false,"backgroundColor":null,"image":null,"defaultImage":null},
|
||||
{"id":"r4","title":"Costly","cost":9000,"prompt":null,"isEnabled":true,"isPaused":false,"isInStock":true,"isUserInputRequired":false,"backgroundColor":null,"image":null,"defaultImage":null}],"automaticRewards":[
|
||||
{"id":"71:SEND_HIGHLIGHTED_MESSAGE","type":"SEND_HIGHLIGHTED_MESSAGE","cost":null,"defaultCost":100,"isEnabled":true,"defaultBackgroundColor":"#FF6905","backgroundColor":null,"defaultImage":{"url":"https://img/hl.png"}},
|
||||
{"id":"71:RANDOM_SUB_EMOTE_UNLOCK","type":"RANDOM_SUB_EMOTE_UNLOCK","cost":null,"defaultCost":320,"isEnabled":true,"defaultBackgroundColor":"#8205B4","backgroundColor":null,"defaultImage":null},
|
||||
{"id":"71:CHOSEN_SUB_EMOTE_UNLOCK","type":"CHOSEN_SUB_EMOTE_UNLOCK","cost":null,"defaultCost":480,"isEnabled":true,"defaultBackgroundColor":"#00C8AF","backgroundColor":null,"defaultImage":null},
|
||||
{"id":"71:CHOSEN_MODIFIED_SUB_EMOTE_UNLOCK","type":"CHOSEN_MODIFIED_SUB_EMOTE_UNLOCK","cost":900,"defaultCost":900,"isEnabled":true,"defaultBackgroundColor":"#00FA05","backgroundColor":null,"defaultImage":null},
|
||||
{"id":"71:SINGLE_MESSAGE_BYPASS_SUB_MODE","type":"SINGLE_MESSAGE_BYPASS_SUB_MODE","cost":350,"defaultCost":350,"isEnabled":true,"defaultBackgroundColor":"#9146FF","backgroundColor":null,"defaultImage":null},
|
||||
{"id":"71:SEND_GIGANTIFIED_EMOTE","type":"SEND_GIGANTIFIED_EMOTE","cost":null,"defaultCost":0,"isEnabled":true,"defaultBackgroundColor":"#69B6FF","backgroundColor":null,"defaultImage":null}],
|
||||
"emoteVariants":[{"id":"e1","isUnlockable":true,"emote":{"id":"e1","token":"binxbWut"},"modifications":[]},{"id":"e2","isUnlockable":true,"emote":{"id":"e2","token":"binxbYAP"},"modifications":[]},{"id":"e3","isUnlockable":true,"emote":{"id":"e3","token":"binxbDrama"},"modifications":[{"id":"e3_BW"},{"id":"e3_SG"}]},{"id":"e4","isUnlockable":false,"emote":{"id":"e4","token":"binxbNope"},"modifications":[{"id":"e4_BW"}]}]}}}}}"##;
|
||||
|
||||
#[test]
|
||||
fn a_channels_rewards_are_read_with_what_makes_each_available() {
|
||||
let (channel, rewards, points) = read_rewards(&serde_json::from_str(REWARDS_ANSWER).unwrap(), &Default::default()).unwrap();
|
||||
assert_eq!((channel.as_str(), points.balance, points.name.as_deref()), ("71", Some(600), Some("Sloths")));
|
||||
let custom: Vec<(&str, u64, bool, bool, bool)> = rewards.iter().filter(|r| r.kind == Kind::Custom).map(|r| (r.id.as_str(), r.cost, r.enabled, r.paused, r.needs_input)).collect();
|
||||
assert_eq!(custom, vec![("r1", 500, true, false, false), ("r2", 100, true, false, true), ("r3", 50, false, true, false), ("r4", 9000, true, false, false)]);
|
||||
assert_eq!(rewards[0].image.as_deref(), Some("https://img/default.png"));
|
||||
assert_eq!(rewards[1].image.as_deref(), Some("https://img/own.png"));
|
||||
assert_eq!(rewards[1].prompt.as_deref(), Some("Who?"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twitchs_own_rewards_are_listed_with_what_each_takes_and_the_bits_ones_are_left_out() {
|
||||
let owned: std::collections::HashSet<String> = ["e1", "e3"].iter().map(|s| s.to_string()).collect();
|
||||
let (_, rewards, _) = read_rewards(&serde_json::from_str(REWARDS_ANSWER).unwrap(), &owned).unwrap();
|
||||
let auto: Vec<&Reward> = rewards.iter().filter(|r| r.kind != Kind::Custom).collect();
|
||||
let kinds: Vec<(Kind, u64, bool)> = auto.iter().map(|r| (r.kind, r.cost, r.needs_input)).collect();
|
||||
// Highlight (typed), random, chosen, modified, and the one that is sent from twitch.tv; the Bits one is not here.
|
||||
assert_eq!(kinds, vec![(Kind::Highlight, 100, true), (Kind::RandomEmote, 320, false), (Kind::ChosenEmote, 480, false), (Kind::ModifiedEmote, 900, false), (Kind::Other, 350, false)]);
|
||||
// What the account already has is not offered again, nor is an emote Twitch does not put in the pool; a change is
|
||||
// offered only for an emote the account has.
|
||||
let chosen: Vec<&str> = auto[2].choices.iter().map(|c| c.label.as_str()).collect();
|
||||
assert_eq!(chosen, vec!["binxbYAP"]);
|
||||
let modified: Vec<&str> = auto[3].choices.iter().map(|c| c.label.as_str()).collect();
|
||||
assert_eq!(modified, vec!["binxbDrama, black and white", "binxbDrama, with sunglasses"]);
|
||||
assert_eq!(auto[4].unavailable.as_deref(), Some("Send this one from twitch.tv's chat box"));
|
||||
// With every emote owned there is nothing to unlock, which is said.
|
||||
let all: std::collections::HashSet<String> = ["e1", "e2", "e3"].iter().map(|s| s.to_string()).collect();
|
||||
let (_, none_left, _) = read_rewards(&serde_json::from_str(REWARDS_ANSWER).unwrap(), &all).unwrap();
|
||||
let chosen_none = none_left.iter().find(|r| r.kind == Kind::ChosenEmote).unwrap();
|
||||
assert_eq!(chosen_none.unavailable.as_deref(), Some("There is nothing left here to unlock"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_automatic_rewards_are_redeemed_through_their_own_calls_with_what_each_needs() {
|
||||
let (state, buffer) = channel("twitch-points-auto", "websession");
|
||||
let (root, seen) = fake_gql(|req| {
|
||||
if req.contains("\"mutation(") || req.contains("mutation($i") {
|
||||
r#"{"data":{"x":{"error":null}}}"#.replace("\"x\"", if req.contains("sendHighlightedChatMessage") { "\"sendHighlightedChatMessage\"" } else if req.contains("unlockRandomSubscriberEmote") { "\"unlockRandomSubscriberEmote\"" } else if req.contains("unlockChosenModifiedSubscriberEmote") { "\"unlockChosenModifiedSubscriberEmote\"" } else { "\"unlockChosenSubscriberEmote\"" })
|
||||
} else {
|
||||
REWARDS_ANSWER.to_string()
|
||||
}
|
||||
})
|
||||
.await;
|
||||
redeem_in_buffer(&state, &root, &buffer, "71:SEND_HIGHLIGHTED_MESSAGE", Some("look at me"), None).await.unwrap();
|
||||
redeem_in_buffer(&state, &root, &buffer, "71:RANDOM_SUB_EMOTE_UNLOCK", None, None).await.unwrap();
|
||||
redeem_in_buffer(&state, &root, &buffer, "71:CHOSEN_SUB_EMOTE_UNLOCK", None, Some("e2")).await.unwrap();
|
||||
redeem_in_buffer(&state, &root, &buffer, "71:CHOSEN_MODIFIED_SUB_EMOTE_UNLOCK", None, Some("e3_SG")).await.unwrap_err();
|
||||
let requests = seen.lock().unwrap().clone();
|
||||
let sent = |name: &str| requests.iter().find(|r| r.contains(name) && r.contains("transactionID")).cloned().unwrap_or_default();
|
||||
let highlight = sent("sendHighlightedChatMessage");
|
||||
for part in ["\"channelID\":\"71\"", "\"cost\":100", "\"message\":\"look at me\"", "SendHighlightedChatMessageInput"] {
|
||||
assert!(highlight.contains(part), "{part} in {highlight}");
|
||||
}
|
||||
assert!(sent("unlockRandomSubscriberEmote").contains("\"cost\":320"));
|
||||
let chosen = sent("unlockChosenSubscriberEmote");
|
||||
assert!(chosen.contains("\"emoteID\":\"e2\"") && chosen.contains("\"cost\":480"), "{chosen}");
|
||||
// An emote that was not offered, and a message that was not typed, are stopped before anything is sent.
|
||||
let before = requests.iter().filter(|r| r.contains("transactionID")).count();
|
||||
assert_eq!(redeem_in_buffer(&state, &root, &buffer, "71:CHOSEN_SUB_EMOTE_UNLOCK", None, Some("not-offered")).await.unwrap_err(), "Pick an emote first");
|
||||
assert_eq!(redeem_in_buffer(&state, &root, &buffer, "71:SEND_HIGHLIGHTED_MESSAGE", Some(" "), None).await.unwrap_err(), "That reward asks you to type something");
|
||||
assert_eq!(redeem_in_buffer(&state, &root, &buffer, "71:SINGLE_MESSAGE_BYPASS_SUB_MODE", None, None).await.unwrap_err(), "Send this one from twitch.tv's chat box");
|
||||
assert_eq!(seen.lock().unwrap().iter().filter(|r| r.contains("transactionID")).count(), before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_reward_is_redeemed_with_what_twitch_needs_and_a_refusal_is_stopped_before_it_is_sent() {
|
||||
let (state, buffer) = channel("twitch-points-redeem", "websession");
|
||||
let asked = Arc::new(Mutex::new(0));
|
||||
let counter = asked.clone();
|
||||
let (root, seen) = fake_gql(move |req| {
|
||||
if req.contains("redeemCommunityPointsCustomReward") {
|
||||
return r#"{"data":{"redeemCommunityPointsCustomReward":{"error":null}}}"#.to_string();
|
||||
}
|
||||
let mut n = counter.lock().unwrap();
|
||||
*n += 1;
|
||||
if *n <= 4 { REWARDS_ANSWER.to_string() } else { REWARDS_ANSWER.replace("\"balance\":600", "\"balance\":500") }
|
||||
})
|
||||
.await;
|
||||
// Paused, too dear, asks for a word and was not given one, and not there: each said, none sent.
|
||||
assert_eq!(redeem_in_buffer(&state, &root, &buffer, "r3", None, None).await.unwrap_err(), "That reward is paused right now");
|
||||
assert_eq!(redeem_in_buffer(&state, &root, &buffer, "r4", None, None).await.unwrap_err(), "You have 600 of the 9000 points that costs");
|
||||
assert_eq!(redeem_in_buffer(&state, &root, &buffer, "r2", Some(" "), None).await.unwrap_err(), "That reward asks you to type something");
|
||||
assert_eq!(redeem_in_buffer(&state, &root, &buffer, "nope", None, None).await.unwrap_err(), "That reward is no longer there");
|
||||
assert!(!seen.lock().unwrap().iter().any(|r| r.contains("redeemCommunityPointsCustomReward")), "nothing was spent");
|
||||
// One that is on offer, with what it asks for.
|
||||
let after = redeem_in_buffer(&state, &root, &buffer, "r2", Some("Greg"), None).await.unwrap();
|
||||
assert_eq!(after.balance, Some(500));
|
||||
let request = seen.lock().unwrap().iter().find(|r| r.contains("redeemCommunityPointsCustomReward")).cloned().unwrap();
|
||||
for part in ["\"channelID\":\"71\"", "\"rewardID\":\"r2\"", "\"cost\":100", "\"title\":\"Say a name\"", "\"prompt\":\"Who?\"", "\"textInput\":\"Greg\"", "transactionID"] {
|
||||
assert!(request.contains(part), "{part} in {request}");
|
||||
}
|
||||
assert!(request.to_lowercase().contains("authorization: oauth websession"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,695 @@
|
||||
//! What Twitch says as it happens, beyond the chat: a channel that raids another,
|
||||
//! the poll it runs, the prediction it opens.
|
||||
//!
|
||||
//! twitch.tv's own pages hear these over a websocket of their own (Hermes), by
|
||||
//! subscribing to a topic per channel and kind of thing. It can be listened to
|
||||
//! with no account at all - the subscriptions are accepted anonymously, which
|
||||
//! is how a logged-out visitor sees a poll - so this works for every channel
|
||||
//! every account has open. Like the GraphQL it is private and not Twitch's to
|
||||
//! promise (see gql.rs and the plan, Gitea issue #55), and a socket that will
|
||||
//! not open costs nothing but these cards.
|
||||
//!
|
||||
//! One socket per account (so it goes by that account's route), subscribed to
|
||||
//! three topics per open channel: `raid.<id>`, `polls.<id>` and
|
||||
//! `predictions-channel-v1.<id>`. Each message is a JSON string inside the
|
||||
//! envelope; what it says is read by the plain functions below and the cards
|
||||
//! are the ones Kick's polls and predictions already use.
|
||||
//!
|
||||
//! A poll or a prediction that began before the channel was opened is asked for
|
||||
//! once, from the GraphQL, since the socket only says what changes.
|
||||
|
||||
use super::gql;
|
||||
use crate::state::AppState;
|
||||
use anyhow::{Context, Result};
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||
|
||||
pub const HERMES_URL: &str = "wss://hermes.twitch.tv/v1?clientId=kimne78kx3ncx6brgo4mv6wki5h1ko";
|
||||
|
||||
/// How long without a word (it sends a keepalive every 15 seconds) before the connection is taken for dead.
|
||||
const SILENCE: Duration = Duration::from_secs(60);
|
||||
|
||||
/// What the connection is asked to do.
|
||||
pub enum Watch {
|
||||
Add { channel: String, room_id: String },
|
||||
Remove { channel: String },
|
||||
}
|
||||
|
||||
fn registry() -> &'static Mutex<HashMap<String, mpsc::UnboundedSender<Watch>>> {
|
||||
static REGISTRY: std::sync::OnceLock<Mutex<HashMap<String, mpsc::UnboundedSender<Watch>>>> = std::sync::OnceLock::new();
|
||||
REGISTRY.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// Makes the way to tell an account's socket what to listen to. Called when
|
||||
/// the account starts; the receiver is `run`'s.
|
||||
pub fn register(account_id: &str) -> mpsc::UnboundedReceiver<Watch> {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
registry().lock().unwrap().insert(account_id.to_string(), tx);
|
||||
rx
|
||||
}
|
||||
|
||||
/// Listens for a channel, now and after any reconnection.
|
||||
pub fn watch(account_id: &str, channel: &str, room_id: &str) {
|
||||
if let Some(tx) = registry().lock().unwrap().get(account_id) {
|
||||
let _ = tx.send(Watch::Add { channel: channel.to_string(), room_id: room_id.to_string() });
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unwatch(account_id: &str, channel: &str) {
|
||||
if let Some(tx) = registry().lock().unwrap().get(account_id) {
|
||||
let _ = tx.send(Watch::Remove { channel: channel.to_string() });
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum Kind {
|
||||
Raid,
|
||||
Polls,
|
||||
Predictions,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
const ALL: [Kind; 3] = [Kind::Raid, Kind::Polls, Kind::Predictions];
|
||||
|
||||
fn topic(self, room_id: &str) -> String {
|
||||
match self {
|
||||
Kind::Raid => format!("raid.{room_id}"),
|
||||
Kind::Polls => format!("polls.{room_id}"),
|
||||
Kind::Predictions => format!("predictions-channel-v1.{room_id}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reading what was said
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// What a message means.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Event {
|
||||
/// The channel is sending its viewers to another: who, and how many.
|
||||
Raid { id: String, login: String, display: String, viewers: Option<u64> },
|
||||
RaidCalledOff { id: String },
|
||||
/// A poll or prediction card, and whether it is the last word on it.
|
||||
Card { kind: &'static str, card: Value },
|
||||
/// The poll or prediction is over and filed away: take the card down.
|
||||
CardGone { kind: &'static str },
|
||||
}
|
||||
|
||||
fn text<'a>(v: &'a Value, keys: &[&str]) -> Option<&'a str> {
|
||||
keys.iter().find_map(|k| v.get(*k).and_then(|x| x.as_str())).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn number(v: &Value, keys: &[&str]) -> Option<f64> {
|
||||
keys.iter().find_map(|k| v.get(*k).and_then(|x| x.as_f64()))
|
||||
}
|
||||
|
||||
/// Reads one pubsub message of the given kind. `now` is unix seconds, for the clock of a prediction (which carries
|
||||
/// when it began and not how long is left).
|
||||
pub fn read(kind: &str, payload: &Value, now: i64) -> Option<Event> {
|
||||
let what = payload.get("type")?.as_str()?;
|
||||
match kind {
|
||||
"raid" => {
|
||||
let raid = payload.get("raid")?;
|
||||
let id = text(raid, &["id"])?.to_string();
|
||||
match what {
|
||||
"raid_update_v2" => {
|
||||
let login = text(raid, &["target_login"])?.to_string();
|
||||
Some(Event::Raid {
|
||||
display: text(raid, &["target_display_name"]).unwrap_or(&login).to_string(),
|
||||
viewers: number(raid, &["viewer_count"]).map(|v| v as u64).filter(|v| *v > 0),
|
||||
login,
|
||||
id,
|
||||
})
|
||||
}
|
||||
"raid_cancel_v2" => Some(Event::RaidCalledOff { id }),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
"polls" => {
|
||||
let poll = payload.pointer("/data/poll")?;
|
||||
match what {
|
||||
"POLL_ARCHIVE" => Some(Event::CardGone { kind: "poll" }),
|
||||
_ => poll_card(poll, now).map(|card| Event::Card { kind: "poll", card }),
|
||||
}
|
||||
}
|
||||
"predictions" => {
|
||||
let event = payload.pointer("/data/event")?;
|
||||
let card = prediction_card(event, now)?;
|
||||
// Called off: nothing was decided, and there is nothing to show of it.
|
||||
if card["state"].as_str() == Some("canceled") {
|
||||
return Some(Event::CardGone { kind: "prediction" });
|
||||
}
|
||||
Some(Event::Card { kind: "prediction", card })
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_time(v: &Value, keys: &[&str]) -> Option<i64> {
|
||||
text(v, keys).and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok()).map(|d| d.timestamp())
|
||||
}
|
||||
|
||||
/// A poll in the shape the card reads, from either of the shapes Twitch has it in: the socket's
|
||||
/// (`poll_id`, `choice_id`, `duration_seconds`) and the GraphQL's (`id`, `durationSeconds`).
|
||||
pub fn poll_card(poll: &Value, _now: i64) -> Option<Value> {
|
||||
let id = text(poll, &["poll_id", "id"])?;
|
||||
let status = text(poll, &["status"]).unwrap_or("ACTIVE").to_uppercase();
|
||||
let open = status == "ACTIVE";
|
||||
let duration = number(poll, &["duration_seconds", "durationSeconds"]).unwrap_or(0.0).max(0.0) as u32;
|
||||
let remaining = if open { (number(poll, &["remaining_duration_milliseconds", "remainingDurationMilliseconds"]).unwrap_or(duration as f64 * 1000.0) / 1000.0).max(0.0).ceil() as u32 } else { 0 };
|
||||
let choices = poll.get("choices")?.as_array()?;
|
||||
let votes_of = |c: &Value| c.pointer("/votes/total").and_then(|t| t.as_f64()).unwrap_or(0.0);
|
||||
let top = choices.iter().map(votes_of).fold(0.0_f64, f64::max);
|
||||
let options: Vec<Value> = choices
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
let id = text(c, &["choice_id", "id"])?;
|
||||
Some(json!({
|
||||
"id": id,
|
||||
"label": text(c, &["title"]).unwrap_or(""),
|
||||
"votes": votes_of(c),
|
||||
"backers": number(c, &["total_voters", "totalVoters"]),
|
||||
// The one with the most, once it is decided.
|
||||
"winner": !open && top > 0.0 && votes_of(c) == top,
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
Some(json!({
|
||||
"kind": "poll",
|
||||
"id": format!("poll:{id}"),
|
||||
"title": text(poll, &["title"]).unwrap_or(""),
|
||||
"options": options,
|
||||
"duration": duration,
|
||||
"remaining": remaining,
|
||||
// The result stays up a while after it closes, as Twitch's own does.
|
||||
"resultDisplayDuration": 20,
|
||||
"hasVoted": false,
|
||||
"votedOptionId": null,
|
||||
"state": status.to_lowercase(),
|
||||
"open": open,
|
||||
// Twitch's polls are ended on twitch.tv by the streamer or a moderator, not from a viewer's card.
|
||||
"noEnd": true,
|
||||
}))
|
||||
}
|
||||
|
||||
/// A prediction in the shape the card reads, from the socket's event or the GraphQL's.
|
||||
pub fn prediction_card(event: &Value, now: i64) -> Option<Value> {
|
||||
let id = text(event, &["id"])?;
|
||||
let status = text(event, &["status"]).unwrap_or("ACTIVE").to_uppercase();
|
||||
let open = status == "ACTIVE";
|
||||
let window = number(event, &["prediction_window_seconds", "predictionWindowSeconds"]).unwrap_or(0.0).max(0.0) as u32;
|
||||
let started = parse_time(event, &["created_at", "createdAt"]);
|
||||
let remaining = match (open, started) {
|
||||
(true, Some(started)) => (window as i64 - (now - started)).max(0) as u32,
|
||||
(true, None) => window,
|
||||
_ => 0,
|
||||
};
|
||||
let outcomes = event.get("outcomes")?.as_array()?;
|
||||
let points_of = |o: &Value| number(o, &["total_points", "totalPoints"]).unwrap_or(0.0);
|
||||
let pot: f64 = outcomes.iter().map(points_of).sum();
|
||||
let winner = text(event, &["winning_outcome_id"]).map(str::to_string).or_else(|| event.pointer("/winningOutcome/id").and_then(|w| w.as_str()).map(str::to_string));
|
||||
let options: Vec<Value> = outcomes
|
||||
.iter()
|
||||
.filter_map(|o| {
|
||||
let oid = text(o, &["id"])?;
|
||||
let points = points_of(o);
|
||||
Some(json!({
|
||||
"id": oid,
|
||||
"label": text(o, &["title"]).unwrap_or(""),
|
||||
"votes": points,
|
||||
"backers": number(o, &["total_users", "totalUsers"]),
|
||||
// What a point put on it pays back, written the way Kick's page writes it.
|
||||
"odds": (points > 0.0 && pot > 0.0).then(|| format!("1:{:.1}", pot / points)),
|
||||
"winner": winner.as_deref() == Some(oid),
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
let state = match status.as_str() {
|
||||
"ACTIVE" => "open",
|
||||
"LOCKED" => "locked",
|
||||
"RESOLVE_PENDING" | "RESOLVED" => "resolved",
|
||||
"CANCEL_PENDING" | "CANCELED" => "canceled",
|
||||
other => return Some(json!({ "kind": "prediction", "id": format!("prediction:{id}"), "state": other.to_lowercase(), "options": options, "title": text(event, &["title"]).unwrap_or(""), "duration": window, "remaining": 0, "resultDisplayDuration": 0, "hasVoted": false, "votedOptionId": null, "total": pot, "minBet": MIN_BET, "noEnd": true })),
|
||||
};
|
||||
Some(json!({
|
||||
"kind": "prediction",
|
||||
"id": format!("prediction:{id}"),
|
||||
"title": text(event, &["title"]).unwrap_or(""),
|
||||
"options": options,
|
||||
"duration": window,
|
||||
"remaining": remaining,
|
||||
"resultDisplayDuration": 0,
|
||||
"hasVoted": false,
|
||||
"votedOptionId": null,
|
||||
"total": pot,
|
||||
"state": state,
|
||||
"minBet": MIN_BET,
|
||||
"noEnd": true,
|
||||
}))
|
||||
}
|
||||
|
||||
/// The smallest bet Twitch takes.
|
||||
pub const MIN_BET: u32 = 10;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Saying it
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Whether this account can answer a poll or a prediction: it has the twitch.tv session that Twitch takes votes
|
||||
/// and bets from.
|
||||
fn answerable(state: &AppState, account_id: &str) -> bool {
|
||||
state.accounts.get_twitch(account_id).is_some_and(|a| !a.web_token.is_empty())
|
||||
}
|
||||
|
||||
/// Publishes a card, carrying across what the socket cannot know: this account's own vote and bet and what it has to spend.
|
||||
pub fn publish(state: &AppState, account_id: &str, buffer_id: &str, kind: &str, mut card: Value) {
|
||||
card["readOnly"] = json!(!answerable(state, account_id));
|
||||
if let Some(known) = state.runtime.live_card(buffer_id, kind) {
|
||||
if known["id"] == card["id"] {
|
||||
for key in ["hasVoted", "votedOptionId", "stake", "balance"] {
|
||||
if !known[key].is_null() {
|
||||
card[key] = known[key].clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::backend::kick::cards::publish_card(state, buffer_id, kind, Some(card));
|
||||
}
|
||||
|
||||
fn gone(state: &AppState, buffer_id: &str, kind: &str) {
|
||||
crate::backend::kick::cards::publish_card(state, buffer_id, kind, None);
|
||||
}
|
||||
|
||||
/// A raid, said in the channel with a way to follow it.
|
||||
fn raid_said(state: &AppState, account_id: &str, channel: &str, event: &Event) {
|
||||
match event {
|
||||
Event::Raid { login, display, viewers, .. } => {
|
||||
let embed = crate::model::Embed { kind: Some("raid-out".into()), author: Some(display.clone()), title: viewers.map(|v| v.to_string()), description: Some(login.clone()), ..Default::default() };
|
||||
state.runtime.record_message(state, account_id, channel, "channel", channel, &format!("{channel} is raiding {display}"), false, "raid", None, None, false, None, vec![embed], Vec::new(), None);
|
||||
}
|
||||
Event::RaidCalledOff { .. } => {
|
||||
state.runtime.record_message(state, account_id, channel, "channel", channel, "The raid was called off", false, "system", None, None, false, None, Vec::new(), Vec::new(), None);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// What has to be known to act on a message: which channel a subscription was for, and what kind it was.
|
||||
type Subscriptions = HashMap<String, (String, Kind)>;
|
||||
|
||||
/// Acts on one pubsub message.
|
||||
fn act(state: &AppState, account_id: &str, channel: &str, kind: Kind, payload: &Value, raids: &mut Vec<String>) {
|
||||
let now = crate::backend::kick::cards::now_secs();
|
||||
let name = match kind {
|
||||
Kind::Raid => "raid",
|
||||
Kind::Polls => "polls",
|
||||
Kind::Predictions => "predictions",
|
||||
};
|
||||
let buffer_id = crate::model::buffer_id(account_id, channel);
|
||||
match read(name, payload, now) {
|
||||
Some(event @ Event::Raid { .. }) => {
|
||||
if let Event::Raid { id, .. } = &event {
|
||||
// The same raid is described again as its clock runs: said once.
|
||||
if raids.contains(id) {
|
||||
return;
|
||||
}
|
||||
raids.push(id.clone());
|
||||
if raids.len() > 32 {
|
||||
raids.remove(0);
|
||||
}
|
||||
}
|
||||
raid_said(state, account_id, channel, &event);
|
||||
}
|
||||
Some(event @ Event::RaidCalledOff { .. }) => raid_said(state, account_id, channel, &event),
|
||||
Some(Event::Card { kind, card }) => {
|
||||
let fresh = kind == "prediction" && state.runtime.live_card(&buffer_id, kind).is_none_or(|known| known["id"] != card["id"]);
|
||||
publish(state, account_id, &buffer_id, kind, card);
|
||||
// A new prediction asks what this account has to bet with, once.
|
||||
if fresh && answerable(state, account_id) {
|
||||
let (state, account_id, channel) = (state.clone(), account_id.to_string(), channel.to_string());
|
||||
tokio::spawn(async move { refresh_balance(&state, gql::ROOT, &account_id, &channel).await });
|
||||
}
|
||||
}
|
||||
Some(Event::CardGone { kind }) => gone(state, &buffer_id, kind),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pulls out the pubsub payload a message carries: the topic's subscription id and the JSON inside.
|
||||
pub fn notification(frame: &Value) -> Option<(String, Value)> {
|
||||
let n = frame.get("notification")?;
|
||||
let sub = n.pointer("/subscription/id")?.as_str()?.to_string();
|
||||
let payload: Value = serde_json::from_str(n.get("pubsub")?.as_str()?).ok()?;
|
||||
Some((sub, payload))
|
||||
}
|
||||
|
||||
/// Reads what this account has to bet with in a channel and puts it on the prediction card that is up, if any.
|
||||
pub async fn refresh_balance(state: &AppState, root: &str, account_id: &str, channel: &str) {
|
||||
let Some(cfg) = state.accounts.get_twitch(account_id) else { return };
|
||||
let Ok(http) = super::client() else { return };
|
||||
let Ok(points) = super::points::context(state, &http, root, channel, Some(&cfg.web_token)).await else { return };
|
||||
let (buffer_id, Some(balance)) = (crate::model::buffer_id(account_id, channel), points.balance) else { return };
|
||||
if let Some(mut card) = state.runtime.live_card(&buffer_id, "prediction") {
|
||||
card["balance"] = json!(balance);
|
||||
// Directly: `publish` would carry the old balance back over the new one.
|
||||
crate::backend::kick::cards::publish_card(state, &buffer_id, "prediction", Some(card));
|
||||
}
|
||||
state.events.emit("twitchPoints", json!({ "bufferId": buffer_id, "points": balance, "name": points.name }));
|
||||
}
|
||||
|
||||
/// A poll or prediction the channel already has going, asked for from the GraphQL when it is opened.
|
||||
pub async fn current(state: &AppState, account_id: &str, channel: &str, gql_root: &str) {
|
||||
let Ok(http) = super::client() else { return };
|
||||
const ASK: &str = "query($login:String!){ user(login:$login){ viewablePoll{ id title status durationSeconds remainingDurationMilliseconds choices{ id title votes{ total } totalVoters } } channel{ activePredictionEvents{ id title status createdAt predictionWindowSeconds winningOutcome{ id } outcomes{ id title totalPoints totalUsers } } } } }";
|
||||
let answer = match gql::call(&http, gql_root, &gql::query(ASK, json!({ "login": channel })), &gql::Who::default()).await {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
tracing::debug!("twitch[{account_id}]: {channel}'s poll and prediction: {e:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let now = crate::backend::kick::cards::now_secs();
|
||||
let buffer_id = crate::model::buffer_id(account_id, channel);
|
||||
if let Some(card) = answer.pointer("/data/user/viewablePoll").filter(|p| !p.is_null()).and_then(|p| poll_card(p, now)) {
|
||||
publish(state, account_id, &buffer_id, "poll", card);
|
||||
}
|
||||
if let Some(card) = answer.pointer("/data/user/channel/activePredictionEvents/0").filter(|p| !p.is_null()).and_then(|p| prediction_card(p, now)) {
|
||||
publish(state, account_id, &buffer_id, "prediction", card);
|
||||
if answerable(state, account_id) {
|
||||
refresh_balance(state, gql_root, account_id, channel).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The connection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Listens for as long as the account runs, reconnecting with a growing wait.
|
||||
pub async fn run(state: AppState, account_id: String, mut rx: mpsc::UnboundedReceiver<Watch>, url: String, gql_root: String) {
|
||||
let mut watched: HashMap<String, String> = HashMap::new();
|
||||
let mut wait = 3u64;
|
||||
loop {
|
||||
match session(&state, &account_id, &mut rx, &mut watched, &url, &gql_root).await {
|
||||
Ok(()) => return,
|
||||
Err(e) => {
|
||||
tracing::debug!("twitch[{account_id}]: realtime ended: {e:#}");
|
||||
// Anything asked for while it was down is kept in `watched` by the drain below.
|
||||
while let Ok(w) = rx.try_recv() {
|
||||
apply(&mut watched, w);
|
||||
}
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(wait)).await;
|
||||
wait = (wait * 2).min(120);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(watched: &mut HashMap<String, String>, w: Watch) {
|
||||
match w {
|
||||
Watch::Add { channel, room_id } => {
|
||||
watched.insert(channel, room_id);
|
||||
}
|
||||
Watch::Remove { channel } => {
|
||||
watched.remove(&channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn subscribe_frame(id: &str, topic: &str) -> String {
|
||||
json!({
|
||||
"type": "subscribe",
|
||||
"id": id,
|
||||
"subscribe": { "id": id, "type": "pubsub", "pubsub": { "topic": topic } },
|
||||
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn session(state: &AppState, account_id: &str, rx: &mut mpsc::UnboundedReceiver<Watch>, watched: &mut HashMap<String, String>, url: &str, gql_root: &str) -> Result<()> {
|
||||
let (mut socket, _) = crate::net::route::websocket(super::SHARED_KEY, url).await.context("connecting to Twitch's realtime service")?;
|
||||
let mut subs: Subscriptions = HashMap::new();
|
||||
let mut next = 0u64;
|
||||
let mut raids: Vec<String> = Vec::new();
|
||||
// Nothing to listen to is nothing to hold open: the socket waits for the first channel.
|
||||
let mut listening: Vec<String> = Vec::new();
|
||||
let mut last_heard = tokio::time::Instant::now();
|
||||
let mut check = tokio::time::interval(Duration::from_secs(15));
|
||||
check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
|
||||
macro_rules! listen {
|
||||
($channel:expr, $room:expr) => {{
|
||||
let (channel, room): (String, String) = ($channel, $room);
|
||||
if !room.is_empty() && !listening.contains(&channel) {
|
||||
listening.push(channel.clone());
|
||||
for kind in Kind::ALL {
|
||||
let id = format!("s{next}");
|
||||
next += 1;
|
||||
socket.send(WsMessage::Text(subscribe_frame(&id, &kind.topic(&room)))).await?;
|
||||
subs.insert(id, (channel.clone(), kind));
|
||||
}
|
||||
let (state, account_id, gql_root) = (state.clone(), account_id.to_string(), gql_root.to_string());
|
||||
tokio::spawn(async move { current(&state, &account_id, &channel, &gql_root).await });
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
for (channel, room) in watched.clone() {
|
||||
listen!(channel, room);
|
||||
}
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
frame = socket.next() => {
|
||||
let Some(frame) = frame else { anyhow::bail!("the connection closed") };
|
||||
match frame.context("reading from Twitch's realtime service")? {
|
||||
WsMessage::Text(text) => {
|
||||
last_heard = tokio::time::Instant::now();
|
||||
let Ok(value) = serde_json::from_str::<Value>(&text) else { continue };
|
||||
if value.get("type").and_then(|t| t.as_str()) != Some("notification") {
|
||||
continue;
|
||||
}
|
||||
let Some((sub, payload)) = notification(&value) else { continue };
|
||||
let Some((channel, kind)) = subs.get(&sub).cloned() else { continue };
|
||||
// A channel closed since is no longer listened for.
|
||||
if !watched.contains_key(&channel) {
|
||||
continue;
|
||||
}
|
||||
act(state, account_id, &channel, kind, &payload, &mut raids);
|
||||
}
|
||||
WsMessage::Ping(data) => socket.send(WsMessage::Pong(data)).await?,
|
||||
WsMessage::Close(_) => anyhow::bail!("Twitch's realtime service closed the connection"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
command = rx.recv() => {
|
||||
let Some(command) = command else { return Ok(()) };
|
||||
match command {
|
||||
Watch::Add { channel, room_id } => {
|
||||
watched.insert(channel.clone(), room_id.clone());
|
||||
listen!(channel, room_id);
|
||||
}
|
||||
Watch::Remove { channel } => {
|
||||
watched.remove(&channel);
|
||||
listening.retain(|c| *c != channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = check.tick() => {
|
||||
if !listening.is_empty() && last_heard.elapsed() > SILENCE {
|
||||
anyhow::bail!("nothing heard from Twitch's realtime service for {} seconds", SILENCE.as_secs());
|
||||
}
|
||||
if listening.is_empty() {
|
||||
last_heard = tokio::time::Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn prediction(status: &str) -> Value {
|
||||
json!({ "type": "event-updated", "data": { "event": {
|
||||
"id": "p1", "title": "Map 2", "status": status, "created_at": "2026-10-14T13:00:00Z", "prediction_window_seconds": 600,
|
||||
"winning_outcome_id": if status == "RESOLVED" { json!("b") } else { Value::Null },
|
||||
"outcomes": [{ "id": "a", "title": "Falcons", "total_points": 750, "total_users": 7 }, { "id": "b", "title": "NIP", "total_points": 250, "total_users": 3 }]
|
||||
} } })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prediction_counts_down_from_when_it_began_and_pays_by_its_pot() {
|
||||
let began = chrono::DateTime::parse_from_rfc3339("2026-10-14T13:00:00Z").unwrap().timestamp();
|
||||
let Some(Event::Card { kind, card }) = read("predictions", &prediction("ACTIVE"), began + 100) else { panic!("a card") };
|
||||
assert_eq!(kind, "prediction");
|
||||
assert_eq!(card["id"], "prediction:p1");
|
||||
assert_eq!(card["remaining"], 500);
|
||||
assert_eq!(card["state"], "open");
|
||||
assert_eq!(card["total"], 1000.0);
|
||||
assert_eq!(card["options"][0]["odds"], "1:1.3");
|
||||
assert_eq!(card["options"][1]["odds"], "1:4.0");
|
||||
assert_eq!(card["options"][1]["backers"], 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_locked_or_decided_prediction_has_no_time_left_and_names_its_winner() {
|
||||
let began = chrono::DateTime::parse_from_rfc3339("2026-10-14T13:00:00Z").unwrap().timestamp();
|
||||
let Some(Event::Card { card, .. }) = read("predictions", &prediction("LOCKED"), began + 100) else { panic!() };
|
||||
assert_eq!((card["remaining"].as_u64(), card["state"].as_str()), (Some(0), Some("locked")));
|
||||
let Some(Event::Card { card, .. }) = read("predictions", &prediction("RESOLVED"), began + 700) else { panic!() };
|
||||
assert_eq!(card["state"], "resolved");
|
||||
assert_eq!((card["options"][0]["winner"].as_bool(), card["options"][1]["winner"].as_bool()), (Some(false), Some(true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_called_off_prediction_is_taken_down() {
|
||||
assert_eq!(read("predictions", &prediction("CANCELED"), 0), Some(Event::CardGone { kind: "prediction" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_poll_is_read_in_the_sockets_shape_and_in_the_graphqls() {
|
||||
let socket = json!({ "type": "POLL_UPDATE", "data": { "poll": { "poll_id": "q1", "title": "Next?", "status": "ACTIVE", "duration_seconds": 60, "remaining_duration_milliseconds": 41_500,
|
||||
"choices": [{ "choice_id": "x", "title": "Chess", "votes": { "total": 3 }, "total_voters": 3 }, { "choice_id": "y", "title": "Go", "votes": { "total": 1 }, "total_voters": 1 }] } } });
|
||||
let Some(Event::Card { kind, card }) = read("polls", &socket, 0) else { panic!() };
|
||||
assert_eq!((kind, card["id"].as_str(), card["remaining"].as_u64(), card["open"].as_bool()), ("poll", Some("poll:q1"), Some(42), Some(true)));
|
||||
assert_eq!(card["options"][0], json!({ "id": "x", "label": "Chess", "votes": 3.0, "backers": 3.0, "winner": false }));
|
||||
|
||||
let graphql = json!({ "id": "q1", "title": "Next?", "status": "ACTIVE", "durationSeconds": 60, "remainingDurationMilliseconds": 10_000, "choices": [{ "id": "x", "title": "Chess", "votes": { "total": 3 }, "totalVoters": 3 }] });
|
||||
assert_eq!(poll_card(&graphql, 0).unwrap()["remaining"], 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finished_poll_shows_how_it_went_and_an_archived_one_goes() {
|
||||
let done = json!({ "type": "POLL_COMPLETE", "data": { "poll": { "poll_id": "q1", "title": "Next?", "status": "COMPLETED", "duration_seconds": 60,
|
||||
"choices": [{ "choice_id": "x", "title": "Chess", "votes": { "total": 3 } }, { "choice_id": "y", "title": "Go", "votes": { "total": 1 } }] } } });
|
||||
let Some(Event::Card { card, .. }) = read("polls", &done, 0) else { panic!() };
|
||||
assert_eq!((card["open"].as_bool(), card["remaining"].as_u64()), (Some(false), Some(0)));
|
||||
assert_eq!((card["options"][0]["winner"].as_bool(), card["options"][1]["winner"].as_bool()), (Some(true), Some(false)));
|
||||
let archived = json!({ "type": "POLL_ARCHIVE", "data": { "poll": { "poll_id": "q1" } } });
|
||||
assert_eq!(read("polls", &archived, 0), Some(Event::CardGone { kind: "poll" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raid_names_where_it_is_going_and_can_be_called_off() {
|
||||
let raid = json!({ "type": "raid_update_v2", "raid": { "id": "r1", "target_login": "friend", "target_display_name": "Friend", "viewer_count": 120 } });
|
||||
assert_eq!(read("raid", &raid, 0), Some(Event::Raid { id: "r1".into(), login: "friend".into(), display: "Friend".into(), viewers: Some(120) }));
|
||||
let off = json!({ "type": "raid_cancel_v2", "raid": { "id": "r1" } });
|
||||
assert_eq!(read("raid", &off, 0), Some(Event::RaidCalledOff { id: "r1".into() }));
|
||||
assert_eq!(read("raid", &json!({ "type": "raid_go_v2", "raid": { "id": "r1" } }), 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_envelope_gives_up_the_payload_inside_it() {
|
||||
let frame = json!({ "type": "notification", "notification": { "subscription": { "id": "s3" }, "type": "pubsub", "pubsub": prediction("ACTIVE").to_string() } });
|
||||
let (sub, payload) = notification(&frame).unwrap();
|
||||
assert_eq!(sub, "s3");
|
||||
assert_eq!(payload["data"]["event"]["id"], "p1");
|
||||
assert!(notification(&json!({ "type": "keepalive" })).is_none());
|
||||
}
|
||||
|
||||
use crate::backend::kick::testkit;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_tungstenite::tungstenite::Message as Ws;
|
||||
|
||||
/// A Hermes that, once a client has subscribed to its three topics, announces a prediction, a raid (twice) and a poll.
|
||||
async fn fake_hermes(subscribed: Arc<Mutex<Vec<String>>>) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let url = format!("ws://{}", listener.local_addr().unwrap());
|
||||
tokio::spawn(async move {
|
||||
let (tcp, _) = listener.accept().await.unwrap();
|
||||
let mut ws = tokio_tungstenite::accept_async(tcp).await.unwrap();
|
||||
let _ = ws.send(Ws::Text(json!({ "type": "welcome", "welcome": { "keepaliveSec": 15 } }).to_string())).await;
|
||||
let mut ids: Vec<(String, String)> = Vec::new();
|
||||
while let Some(Ok(Ws::Text(text))) = ws.next().await {
|
||||
let v: Value = serde_json::from_str(&text).unwrap();
|
||||
if v["type"] != "subscribe" {
|
||||
continue;
|
||||
}
|
||||
let (id, topic) = (v["subscribe"]["id"].as_str().unwrap().to_string(), v["subscribe"]["pubsub"]["topic"].as_str().unwrap().to_string());
|
||||
subscribed.lock().unwrap().push(topic.clone());
|
||||
let _ = ws.send(Ws::Text(json!({ "type": "subscribeResponse", "subscribeResponse": { "subscription": { "id": id }, "result": "ok" } }).to_string())).await;
|
||||
ids.push((id, topic));
|
||||
if ids.len() == 3 {
|
||||
let say = |topic: &str, payload: Value| {
|
||||
let id = ids.iter().find(|(_, t)| t.starts_with(topic)).unwrap().0.clone();
|
||||
Ws::Text(json!({ "type": "notification", "notification": { "subscription": { "id": id }, "type": "pubsub", "pubsub": payload.to_string() } }).to_string())
|
||||
};
|
||||
let began = chrono::Utc::now().to_rfc3339();
|
||||
let _ = ws.send(say("predictions-channel-v1", json!({ "type": "event-created", "data": { "event": { "id": "p1", "title": "Map 2", "status": "ACTIVE", "created_at": began, "prediction_window_seconds": 600,
|
||||
"outcomes": [{ "id": "a", "title": "Falcons", "total_points": 0, "total_users": 0 }, { "id": "b", "title": "NIP", "total_points": 0, "total_users": 0 }] } } }))).await;
|
||||
let raid = json!({ "type": "raid_update_v2", "raid": { "id": "r1", "target_login": "friend", "target_display_name": "Friend", "viewer_count": 120 } });
|
||||
let _ = ws.send(say("raid", raid.clone())).await;
|
||||
// Described again as its clock runs: said once.
|
||||
let _ = ws.send(say("raid", raid)).await;
|
||||
let _ = ws.send(say("polls", json!({ "type": "POLL_CREATE", "data": { "poll": { "poll_id": "q1", "title": "Next?", "status": "ACTIVE", "duration_seconds": 60, "choices": [{ "choice_id": "x", "title": "Chess", "votes": { "total": 0 } }] } } }))).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
url
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_channels_prediction_poll_and_raid_are_heard_from_the_realtime_service_and_shown() {
|
||||
let state = testkit::simulated_daemon("twitch-realtime");
|
||||
let account = "twitch:~anonymous";
|
||||
let buffer = crate::model::buffer_id(account, "xqc");
|
||||
state.runtime.ensure_buffer(&state, account, "xqc", "channel");
|
||||
let subscribed = Arc::new(Mutex::new(Vec::new()));
|
||||
let url = fake_hermes(subscribed.clone()).await;
|
||||
let rx = register("twitch:realtime-test");
|
||||
// Told about the channel before the socket is up, as a join is.
|
||||
watch("twitch:realtime-test", "xqc", "100");
|
||||
let task = tokio::spawn(run(state.clone(), account.to_string(), rx, url, "http://127.0.0.1:9".to_string()));
|
||||
|
||||
for _ in 0..100 {
|
||||
if state.runtime.live_card(&buffer, "prediction").is_some() && state.runtime.live_card(&buffer, "poll").is_some() && !state.store.get_backlog(&buffer, 0, 10).unwrap().is_empty() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||
}
|
||||
let mut topics = subscribed.lock().unwrap().clone();
|
||||
topics.sort();
|
||||
assert_eq!(topics, vec!["polls.100", "predictions-channel-v1.100", "raid.100"]);
|
||||
let prediction = state.runtime.live_card(&buffer, "prediction").expect("the prediction is on screen");
|
||||
assert_eq!((prediction["title"].as_str(), prediction["state"].as_str()), (Some("Map 2"), Some("open")));
|
||||
assert!(prediction["remaining"].as_u64().unwrap() > 590);
|
||||
assert_eq!(state.runtime.live_card(&buffer, "poll").unwrap()["options"][0]["label"], "Chess");
|
||||
let log = state.store.get_backlog(&buffer, 0, 10).unwrap();
|
||||
let raids: Vec<_> = log.iter().filter(|m| m.body.contains("raiding")).collect();
|
||||
assert_eq!(raids.len(), 1, "a raid is said once: {log:?}");
|
||||
assert_eq!(raids[0].embeds[0].kind.as_deref(), Some("raid-out"));
|
||||
assert_eq!(raids[0].embeds[0].description.as_deref(), Some("friend"));
|
||||
task.abort();
|
||||
}
|
||||
|
||||
/// Against the real services: `TWITCH_LIVE_CHANNEL=<login> cargo test --release live_ -- --ignored --nocapture`.
|
||||
/// Opens the channel the way a join does and prints what Twitch says about its poll and prediction.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn live_a_real_channels_prediction_is_read_from_the_graphql_and_the_socket() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let channel = std::env::var("TWITCH_LIVE_CHANNEL").expect("TWITCH_LIVE_CHANNEL=<login>");
|
||||
let state = testkit::simulated_daemon("twitch-realtime-live");
|
||||
let account = "twitch:~anonymous";
|
||||
let buffer = crate::model::buffer_id(account, &channel);
|
||||
state.runtime.ensure_buffer(&state, account, &channel, "channel");
|
||||
let http = crate::backend::twitch::client().unwrap();
|
||||
let id = crate::backend::twitch::stream::channels(&http, gql::ROOT, std::slice::from_ref(&channel)).await.unwrap()[0].clone().unwrap().id;
|
||||
let rx = register("twitch:live");
|
||||
watch("twitch:live", &channel, &id);
|
||||
let task = tokio::spawn(run(state.clone(), account.to_string(), rx, HERMES_URL.to_string(), gql::ROOT.to_string()));
|
||||
tokio::time::sleep(Duration::from_secs(20)).await;
|
||||
println!("prediction: {:?}", state.runtime.live_card(&buffer, "prediction"));
|
||||
println!("poll: {:?}", state.runtime.live_card(&buffer, "poll"));
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
//! What is on air in a channel: whether it is live, what it is called, how many
|
||||
//! are watching, and the address of its video.
|
||||
//!
|
||||
//! The answer comes from Twitch's GraphQL (see `gql.rs`) because it can be
|
||||
//! asked anonymously and in one request for many channels at once, where the
|
||||
//! official API wants a signed-in account and one call per kind of thing. It is
|
||||
//! said to the window as the same `kickStream` event Kick's streams use, with
|
||||
//! the same fields, so the header's live chip, the Watch button and the player
|
||||
//! are the code Kick already has - the event is named for the service that had
|
||||
//! it first and carries any service's stream.
|
||||
|
||||
use super::gql;
|
||||
use super::playback::{self, Playback, PlaybackError};
|
||||
use crate::state::AppState;
|
||||
use anyhow::{Context, Result};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// A channel as Twitch describes it.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Channel {
|
||||
/// Twitch's number for it, which the official API and a shared chat are keyed by.
|
||||
pub id: String,
|
||||
pub login: String,
|
||||
pub display_name: String,
|
||||
/// The streamer's own picture.
|
||||
pub avatar: Option<String>,
|
||||
pub followers: Option<u64>,
|
||||
pub live: Option<Live>,
|
||||
}
|
||||
|
||||
/// A broadcast in progress.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Live {
|
||||
/// The broadcast's own id: a different one is a different stream, even in the same channel.
|
||||
pub id: u64,
|
||||
pub title: String,
|
||||
pub category: Option<String>,
|
||||
pub viewers: Option<u64>,
|
||||
pub started_ts: Option<i64>,
|
||||
}
|
||||
|
||||
const USERS: &str = "query($logins:[String!]!){ users(logins:$logins){ id login displayName profileImageURL(width:300) followers{ totalCount } broadcastSettings{ title game{ displayName } } stream{ id title viewersCount createdAt type game{ displayName } } } }";
|
||||
|
||||
/// How many channels one question asks about. The poll covers every channel an
|
||||
/// account has open, and a list longer than this is asked in pieces.
|
||||
const BATCH: usize = 30;
|
||||
|
||||
fn read_channel(v: &Value) -> Option<Channel> {
|
||||
let login = v.get("login")?.as_str()?.to_lowercase();
|
||||
let live = v.get("stream").filter(|s| !s.is_null()).map(|s| Live {
|
||||
id: s.get("id").and_then(|i| i.as_str()).and_then(|i| i.parse().ok()).unwrap_or(0),
|
||||
title: s.get("title").and_then(|t| t.as_str()).filter(|t| !t.is_empty()).or_else(|| v.pointer("/broadcastSettings/title").and_then(|t| t.as_str())).unwrap_or("").to_string(),
|
||||
category: s.pointer("/game/displayName").and_then(|g| g.as_str()).map(str::to_string),
|
||||
viewers: s.get("viewersCount").and_then(|n| n.as_u64()),
|
||||
started_ts: s.get("createdAt").and_then(|d| d.as_str()).and_then(|d| chrono::DateTime::parse_from_rfc3339(d).ok()).map(|d| d.timestamp()),
|
||||
});
|
||||
Some(Channel {
|
||||
id: v.get("id")?.as_str()?.to_string(),
|
||||
display_name: v.get("displayName").and_then(|d| d.as_str()).filter(|d| !d.is_empty()).unwrap_or(&login).to_string(),
|
||||
avatar: v.get("profileImageURL").and_then(|a| a.as_str()).map(str::to_string),
|
||||
followers: v.pointer("/followers/totalCount").and_then(|n| n.as_u64()),
|
||||
login,
|
||||
live,
|
||||
})
|
||||
}
|
||||
|
||||
/// Asks about several channels at once. A login Twitch has no channel for comes
|
||||
/// back as `None` in its place, so the answers line up with the question.
|
||||
pub async fn channels(http: &reqwest::Client, root: &str, logins: &[String]) -> Result<Vec<Option<Channel>>> {
|
||||
let mut out = Vec::with_capacity(logins.len());
|
||||
for chunk in logins.chunks(BATCH) {
|
||||
let answer = gql::call(http, root, &gql::query(USERS, json!({ "logins": chunk })), &gql::Who::default()).await?;
|
||||
if let Some(why) = gql::error_of(&answer) {
|
||||
anyhow::bail!("Twitch would not say what is on air: {why}");
|
||||
}
|
||||
let users = answer.pointer("/data/users").and_then(|u| u.as_array()).context("Twitch's answer about channels had no list of them")?;
|
||||
for (i, login) in chunk.iter().enumerate() {
|
||||
out.push(users.get(i).filter(|u| !u.is_null()).and_then(read_channel).filter(|c| c.login == login.to_lowercase()));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
||||
/// What a stream event says, built from what is known. Public for the tests.
|
||||
pub fn stream_event(buffer_id: &str, channel: &Channel, playback: Option<&Playback>, why_not: Option<&str>) -> Value {
|
||||
json!({
|
||||
"bufferId": buffer_id,
|
||||
"service": "twitch",
|
||||
"live": channel.live.is_some(),
|
||||
"title": channel.live.as_ref().map(|l| l.title.clone()),
|
||||
"category": channel.live.as_ref().and_then(|l| l.category.clone()),
|
||||
"livestreamId": channel.live.as_ref().map(|l| l.id).filter(|id| *id != 0),
|
||||
"viewers": channel.live.as_ref().and_then(|l| l.viewers),
|
||||
"startedTs": channel.live.as_ref().and_then(|l| l.started_ts),
|
||||
"followers": channel.followers,
|
||||
"playbackUrl": playback.map(|p| p.url.clone()),
|
||||
"playbackExpires": playback.map(|p| p.expires),
|
||||
// Whether this account's own token says ads are not for it, which is
|
||||
// the one thing a client does not choose: a subscriber's or Turbo's.
|
||||
"adFree": playback.map(|p| p.hide_ads),
|
||||
// Why there is no picture, in Twitch's words or ours, for a stream that
|
||||
// is live: sub-only, blocked in this country, and so on.
|
||||
"playbackError": why_not,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
/// How long before an address stops being accepted a new one is made: the
|
||||
/// token is good for twenty minutes, and the player can be several minutes
|
||||
/// into a playlist that carries it.
|
||||
pub const RENEW_BEFORE: i64 = 300;
|
||||
|
||||
/// The address for a live channel: the one already held while it is good for
|
||||
/// a while yet, else a new one.
|
||||
async fn playback_for(state: &AppState, account_id: &str, buffer_id: &str, http: &reqwest::Client, login: &str) -> (Option<Playback>, Option<String>) {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
if let Some(have) = state.runtime.kick_stream(buffer_id) {
|
||||
if let (Some(url), Some(expires)) = (have["playbackUrl"].as_str(), have["playbackExpires"].as_i64()) {
|
||||
if expires - now > RENEW_BEFORE {
|
||||
return (Some(Playback { url: url.to_string(), expires, hide_ads: have["adFree"].as_bool().unwrap_or(false), restricted: Vec::new() }), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
let web = state.accounts.get_twitch(account_id).map(|a| a.web_token).filter(|t| !t.is_empty());
|
||||
match playback::playback(state, http, gql::ROOT, playback::USHER_ROOT, login, web.as_deref()).await {
|
||||
Ok(p) => (Some(p), None),
|
||||
Err(PlaybackError::Offline) => (None, None),
|
||||
Err(e) => {
|
||||
tracing::debug!("twitch: the address for {login}: {e}");
|
||||
(None, Some(e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a batch of channels' state into the stream events, saying going live
|
||||
/// and going off the air in the channel when that is what changed.
|
||||
///
|
||||
/// Each live channel is made sure to have a good address too. That costs a
|
||||
/// call only for a channel whose address is not yet held or is running out:
|
||||
/// one that is good for a while yet is kept as it is.
|
||||
pub async fn refresh(state: &AppState, account_id: &str, logins: &[String]) {
|
||||
let Ok(http) = super::client() else { return };
|
||||
let rows = match channels(&http, gql::ROOT, logins).await {
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
tracing::debug!("twitch[{account_id}]: asking what is on air: {e:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
for (login, row) in logins.iter().zip(rows) {
|
||||
let buffer_id = crate::model::buffer_id(account_id, login);
|
||||
let Some(channel) = row else { continue };
|
||||
let before = state.runtime.kick_stream(&buffer_id);
|
||||
let was_live = before.as_ref().map(|s| s["live"].as_bool().unwrap_or(false));
|
||||
let (playback, why_not) = if channel.live.is_some() { playback_for(state, account_id, &buffer_id, &http, login).await } else { (None, None) };
|
||||
let next = stream_event(&buffer_id, &channel, playback.as_ref(), why_not.as_deref());
|
||||
if before.as_ref() != Some(&next) {
|
||||
state.runtime.set_kick_stream(&buffer_id, next.clone());
|
||||
state.events.emit("kickStream", next);
|
||||
}
|
||||
if let Some(avatar) = channel.avatar.as_deref() {
|
||||
state.runtime.set_buffer_avatar(state, &buffer_id, avatar);
|
||||
}
|
||||
// Said in the channel when it changes, never on first sight of a
|
||||
// channel (which would announce every channel as it is joined).
|
||||
if let (Some(was), now) = (was_live, channel.live.is_some()) {
|
||||
if was != now {
|
||||
say_live_change(state, account_id, &channel, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn say_live_change(state: &AppState, account_id: &str, channel: &Channel, live: bool) {
|
||||
let what = if live { "went live" } else { "ended the stream" };
|
||||
state.runtime.record_message(
|
||||
state,
|
||||
account_id,
|
||||
&channel.login,
|
||||
"channel",
|
||||
&channel.login,
|
||||
what,
|
||||
false,
|
||||
"stream",
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
vec![crate::model::Embed {
|
||||
kind: Some("stream".into()),
|
||||
author: Some(channel.display_name.clone()),
|
||||
title: Some(what.to_string()),
|
||||
icon: Some(if live { "live" } else { "offline" }.to_string()),
|
||||
..Default::default()
|
||||
}],
|
||||
Vec::new(),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
/// One channel, now: used when it has just been joined and when the window
|
||||
/// asks on its own minute for the one on screen.
|
||||
pub async fn refresh_one(state: &AppState, buffer_id: &str) {
|
||||
let Some(buffer) = state.runtime.get_buffer(buffer_id) else { return };
|
||||
if !buffer.account_id.starts_with("twitch:") {
|
||||
return;
|
||||
}
|
||||
refresh(state, &buffer.account_id, std::slice::from_ref(&buffer.name)).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn answer() -> Value {
|
||||
json!({ "data": { "users": [
|
||||
{ "id": "71092938", "login": "xqc", "displayName": "xQc", "profileImageURL": "https://static-cdn.jtvnw.net/x.jpeg", "followers": { "totalCount": 12641353 },
|
||||
"broadcastSettings": { "title": "old title", "game": { "displayName": "Just Chatting" } },
|
||||
"stream": { "id": "321500000001", "title": "LOCK IN", "viewersCount": 25551, "createdAt": "2026-10-14T01:02:03Z", "type": "live", "game": { "displayName": "GTA V" } } },
|
||||
{ "id": "43001749", "login": "kotbarsik_twt", "displayName": "kotbarsik_twt", "profileImageURL": null, "followers": { "totalCount": 0 }, "broadcastSettings": { "title": "Afk cloaking", "game": { "displayName": "EVE Online" } }, "stream": null },
|
||||
null
|
||||
] } })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_live_channel_is_read_with_its_broadcast_and_an_offline_one_without() {
|
||||
let users = answer()["data"]["users"].as_array().unwrap().clone();
|
||||
let live = read_channel(&users[0]).unwrap();
|
||||
assert_eq!((live.login.as_str(), live.display_name.as_str(), live.followers), ("xqc", "xQc", Some(12641353)));
|
||||
let on = live.live.unwrap();
|
||||
assert_eq!((on.id, on.title.as_str(), on.category.as_deref(), on.viewers), (321500000001, "LOCK IN", Some("GTA V"), Some(25551)));
|
||||
assert_eq!(on.started_ts, Some(1791939723));
|
||||
let off = read_channel(&users[1]).unwrap();
|
||||
assert!(off.live.is_none());
|
||||
assert_eq!(off.avatar, None);
|
||||
assert!(read_channel(&users[2]).is_none(), "a login Twitch has no channel for comes back as null");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_event_is_the_shape_the_window_already_draws_for_kick() {
|
||||
let users = answer()["data"]["users"].as_array().unwrap().clone();
|
||||
let channel = read_channel(&users[0]).unwrap();
|
||||
let playback = Playback { url: "https://usher.ttvnw.net/x".into(), expires: 1800000000, hide_ads: false, restricted: vec![] };
|
||||
let e = stream_event("twitch:~anonymous|xqc", &channel, Some(&playback), None);
|
||||
assert_eq!(e["bufferId"], "twitch:~anonymous|xqc");
|
||||
assert_eq!((e["live"].clone(), e["title"].clone(), e["category"].clone(), e["viewers"].clone()), (json!(true), json!("LOCK IN"), json!("GTA V"), json!(25551)));
|
||||
assert_eq!((e["playbackUrl"].clone(), e["playbackExpires"].clone(), e["adFree"].clone()), (json!("https://usher.ttvnw.net/x"), json!(1800000000), json!(false)));
|
||||
assert_eq!(e["livestreamId"], json!(321500000001u64));
|
||||
// An offline channel has no broadcast and no address, and no `following` for a button to offer.
|
||||
let off = stream_event("b", &read_channel(&users[1]).unwrap(), None, None);
|
||||
assert_eq!((off["live"].clone(), off["playbackUrl"].clone(), off["livestreamId"].clone()), (json!(false), Value::Null, Value::Null));
|
||||
assert!(e.get("following").is_none());
|
||||
// Live but with nothing to play, and why.
|
||||
let blocked = stream_event("b", &channel, None, Some("subscribers only"));
|
||||
assert_eq!((blocked["live"].clone(), blocked["playbackError"].clone()), (json!(true), json!("subscribers only")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Telling Twitch a channel is being watched.
|
||||
//!
|
||||
//! Twitch gives a viewer channel points for time spent watching, and counts that time from what the website's
|
||||
//! player reports: once a minute, a small "minute-watched" event naming the channel, the broadcast and the viewer,
|
||||
//! sent to Twitch's telemetry address. Moho plays the video itself, so without this Twitch never hears that anyone
|
||||
//! is watching and no points build up. This sends the same report, and only while the window is really playing
|
||||
//! the channel's video: the window asks once a minute while the picture is playing and visible, and stops when it
|
||||
//! is paused, hidden, closed or minimised.
|
||||
//!
|
||||
//! What it does not do: it claims nothing (the bonus chest is out of scope), it reports no faster than once a
|
||||
//! minute for a channel however many windows ask, it reports nothing for a channel that is not live or an
|
||||
//! account with no twitch.tv session, and it can be turned off in Settings -> Twitch.
|
||||
|
||||
use super::stream;
|
||||
use crate::state::AppState;
|
||||
use base64::Engine;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Twitch's own page, which says where its player reports to.
|
||||
pub const PAGE_ROOT: &str = "https://www.twitch.tv";
|
||||
|
||||
/// Just under the minute the window asks on, so two windows on one channel cost one report.
|
||||
const MIN_GAP: Duration = Duration::from_secs(50);
|
||||
|
||||
fn last_reports() -> &'static Mutex<HashMap<String, Instant>> {
|
||||
static LAST: std::sync::OnceLock<Mutex<HashMap<String, Instant>>> = std::sync::OnceLock::new();
|
||||
LAST.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
fn known_address() -> &'static Mutex<Option<String>> {
|
||||
static ADDRESS: std::sync::OnceLock<Mutex<Option<String>>> = std::sync::OnceLock::new();
|
||||
ADDRESS.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// The address of the website's settings script, which a channel's page names.
|
||||
pub fn settings_script(page: &str) -> Option<String> {
|
||||
let found = regex::Regex::new(r#"https?://[A-Za-z0-9.:/_-]*?/config/settings\.[A-Za-z0-9]+\.js"#).ok()?.find(page)?;
|
||||
Some(found.as_str().to_string())
|
||||
}
|
||||
|
||||
/// Where the player reports to, from the settings script.
|
||||
pub fn spade_address(script: &str) -> Option<String> {
|
||||
let found = regex::Regex::new(r#""spade_url":"([^"]+)""#).ok()?.captures(script)?;
|
||||
Some(found[1].replace("\\/", "/"))
|
||||
}
|
||||
|
||||
/// The report, as the player writes it: a list with one event, as JSON, in base64.
|
||||
pub fn minute_watched(channel_id: &str, broadcast_id: u64, user_id: &str) -> String {
|
||||
let events = serde_json::json!([{
|
||||
"event": "minute-watched",
|
||||
"properties": { "channel_id": channel_id, "broadcast_id": broadcast_id.to_string(), "player": "site", "user_id": user_id }
|
||||
}]);
|
||||
base64::engine::general_purpose::STANDARD.encode(events.to_string())
|
||||
}
|
||||
|
||||
async fn address(http: &reqwest::Client, page_root: &str, channel: &str, fresh: bool) -> Result<String, String> {
|
||||
if !fresh {
|
||||
if let Some(known) = known_address().lock().unwrap().clone() {
|
||||
return Ok(known);
|
||||
}
|
||||
}
|
||||
let ask = |url: String| async move { http.get(url).send().await.map_err(|e| format!("Twitch's page would not load: {e}"))?.text().await.map_err(|e| format!("Twitch's page would not load: {e}")) };
|
||||
let page = ask(format!("{page_root}/{channel}")).await?;
|
||||
let script_url = settings_script(&page).ok_or("Twitch's page does not say where its player reports")?;
|
||||
let script = ask(script_url).await?;
|
||||
let found = spade_address(&script).ok_or("Twitch's settings do not say where its player reports")?;
|
||||
*known_address().lock().unwrap() = Some(found.clone());
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// Reports that the account is watching the channel of this conversation, if it is live and the account has a
|
||||
/// twitch.tv session and this channel was not reported in the last minute. True when a report was sent.
|
||||
pub async fn report(state: &AppState, gql_root: &str, page_root: &str, buffer_id: &str) -> Result<bool, String> {
|
||||
let (cfg, channel) = super::points::session_of(state, buffer_id)?;
|
||||
if cfg.web_token.is_empty() || cfg.user_id.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
{
|
||||
let mut last = last_reports().lock().unwrap();
|
||||
let key = format!("{}|{channel}", cfg.user_id);
|
||||
if last.get(&key).is_some_and(|at| at.elapsed() < MIN_GAP) {
|
||||
return Ok(false);
|
||||
}
|
||||
last.insert(key, Instant::now());
|
||||
}
|
||||
let http = super::client().map_err(|e| format!("{e:#}"))?;
|
||||
let found = stream::channels(&http, gql_root, std::slice::from_ref(&channel)).await.map_err(|e| format!("{e:#}"))?;
|
||||
let Some((id, live)) = found.into_iter().next().flatten().and_then(|c| c.live.map(|l| (c.id, l))) else { return Ok(false) };
|
||||
let body = format!("data={}", minute_watched(&id, live.id, &cfg.user_id));
|
||||
for fresh in [false, true] {
|
||||
let to = address(&http, page_root, &channel, fresh).await?;
|
||||
let sent = http.post(&to).header("Content-Type", "application/x-www-form-urlencoded").body(body.clone()).send().await;
|
||||
match sent {
|
||||
Ok(res) if res.status().is_success() => return Ok(true),
|
||||
// The address moved with a new script: look again once.
|
||||
Ok(res) if fresh => return Err(format!("Twitch did not take the report ({})", res.status())),
|
||||
Err(e) if fresh => return Err(format!("Twitch's reports are not answering: {e}")),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_report_is_the_players_own_shape() {
|
||||
let encoded = minute_watched("71", 1234, "9");
|
||||
let decoded: serde_json::Value = serde_json::from_slice(&base64::engine::general_purpose::STANDARD.decode(encoded).unwrap()).unwrap();
|
||||
assert_eq!(decoded[0]["event"], "minute-watched");
|
||||
assert_eq!(decoded[0]["properties"]["channel_id"], "71");
|
||||
assert_eq!(decoded[0]["properties"]["broadcast_id"], "1234");
|
||||
assert_eq!(decoded[0]["properties"]["user_id"], "9");
|
||||
assert_eq!(decoded[0]["properties"]["player"], "site");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_live_channel_is_reported_once_a_minute() {
|
||||
use super::super::points::tests::{channel, fake_gql};
|
||||
*known_address().lock().unwrap() = None;
|
||||
last_reports().lock().unwrap().clear();
|
||||
let (state, buffer) = channel("twitch-watch", "websession");
|
||||
let (root, seen) = fake_gql(|req| {
|
||||
let line = req.lines().next().unwrap_or("").to_string();
|
||||
if line.starts_with("POST /gql") || req.contains("users(logins") {
|
||||
r#"{"data":{"users":[{"id":"71","login":"xqc","displayName":"xQc","stream":{"id":"555","title":"t","viewersCount":5,"createdAt":"2026-10-14T10:00:00Z","type":"live"}}]}}"#.to_string()
|
||||
} else if line.starts_with("GET /xqc ") {
|
||||
// The page names the script on the same fake server.
|
||||
let host = req.lines().find_map(|l| l.strip_prefix("host: ").or_else(|| l.strip_prefix("Host: "))).unwrap_or("").trim().to_string();
|
||||
format!(r#"<script src="http://{host}/config/settings.abc.js"></script>"#)
|
||||
} else if line.starts_with("GET /config/") {
|
||||
let host = req.lines().find_map(|l| l.strip_prefix("host: ").or_else(|| l.strip_prefix("Host: "))).unwrap_or("").trim().to_string();
|
||||
format!(r#"x={{"spade_url":"http://{host}/track"}}"#)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(report(&state, &format!("{root}/gql"), &root, &buffer).await.unwrap());
|
||||
let requests = seen.lock().unwrap().clone();
|
||||
let posted = requests.iter().find(|r| r.starts_with("POST /track")).expect("a report was sent");
|
||||
assert!(posted.contains("data="), "{posted}");
|
||||
// Asked again at once: nothing more goes out.
|
||||
assert!(!report(&state, &format!("{root}/gql"), &root, &buffer).await.unwrap());
|
||||
assert_eq!(seen.lock().unwrap().iter().filter(|r| r.starts_with("POST /track")).count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn where_to_report_is_read_from_the_page_and_its_script() {
|
||||
let page = r#"<script src="https://assets.twitch.tv/config/settings.abc123.js"></script>"#;
|
||||
assert_eq!(settings_script(page).as_deref(), Some("https://assets.twitch.tv/config/settings.abc123.js"));
|
||||
assert_eq!(spade_address(r#"x={"spade_url":"https:\/\/spade.example\/track","a":1}"#).as_deref(), Some("https://spade.example/track"));
|
||||
assert_eq!(settings_script("<html></html>"), None);
|
||||
assert_eq!(spade_address("nothing"), None);
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -25,11 +25,12 @@ pub struct Builtin {
|
||||
}
|
||||
|
||||
const IRC: &[&str] = &["irc"];
|
||||
const IRC_AND_MATRIX: &[&str] = &["irc", "matrix"];
|
||||
/// `/me` is the one command Twitch's chat itself understands, so it is sent as typed.
|
||||
const ME: &[&str] = &["irc", "matrix", "twitch"];
|
||||
const MATRIX: &[&str] = &["matrix"];
|
||||
/// Everywhere, including the services with no list of their own - moho keeps
|
||||
/// one for those.
|
||||
const EVERY: &[&str] = &["irc", "matrix", "discord", "kick", "sneedchat"];
|
||||
const EVERY: &[&str] = &["irc", "matrix", "discord", "kick", "sneedchat", "twitch"];
|
||||
/// The ones that are only text, and so work wherever this daemon does the
|
||||
/// sending itself.
|
||||
///
|
||||
@@ -37,7 +38,7 @@ const EVERY: &[&str] = &["irc", "matrix", "discord", "kick", "sneedchat"];
|
||||
/// anybody else sees it, and inventing four more that only this client knows
|
||||
/// about would mean the same typing doing different things depending on which
|
||||
/// client you were at.
|
||||
const TEXT_ONLY: &[&str] = &["irc", "matrix", "discord", "kick"];
|
||||
const TEXT_ONLY: &[&str] = &["irc", "matrix", "discord", "kick", "twitch"];
|
||||
/// Spoilers exist on the two services that have a syntax for them.
|
||||
const SPOILERS: &[&str] = &["discord", "matrix"];
|
||||
const SNEEDCHAT: &[&str] = &["sneedchat"];
|
||||
@@ -45,7 +46,7 @@ const SNEEDCHAT: &[&str] = &["sneedchat"];
|
||||
/// Ordered roughly by how often each is wanted, since a fuzzy match that ties
|
||||
/// keeps this order.
|
||||
pub const BUILTINS: &[Builtin] = &[
|
||||
Builtin { name: "me", usage: "<action>", description: "Say something as an action", services: IRC_AND_MATRIX },
|
||||
Builtin { name: "me", usage: "<action>", description: "Say something as an action", services: ME },
|
||||
Builtin { name: "join", usage: "<#channel>", description: "Join a channel", services: IRC },
|
||||
Builtin { name: "part", usage: "", description: "Leave this channel", services: IRC },
|
||||
Builtin { name: "msg", usage: "<nick> <message>", description: "Send somebody a private message", services: IRC },
|
||||
|
||||
@@ -337,6 +337,12 @@ async fn run() -> Result<()> {
|
||||
for cfg in state.accounts.all_kick() {
|
||||
backend::kick::spawn(state.clone(), cfg);
|
||||
}
|
||||
for cfg in state.accounts.all_steam() {
|
||||
backend::steam::spawn(state.clone(), cfg);
|
||||
}
|
||||
for cfg in state.accounts.all_twitch() {
|
||||
backend::twitch::spawn(state.clone(), cfg);
|
||||
}
|
||||
|
||||
// What was going on last time, before anything is served: a window that
|
||||
// connects immediately should see the same list it was looking at.
|
||||
|
||||
+72
-6
@@ -188,6 +188,15 @@ pub struct Account {
|
||||
/// Matrix only: whether this account keeps a dehydrated device.
|
||||
#[serde(rename = "dehydration", default)]
|
||||
pub dehydration: bool,
|
||||
/// Twitch only: whether this account holds a twitch.tv website session for
|
||||
/// playing video as the person (their Turbo, their subscriptions). Said as
|
||||
/// a yes or no and never the session itself.
|
||||
#[serde(rename = "webSession", default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub web_session: bool,
|
||||
/// Twitch only: whether this account was signed in with the rights to
|
||||
/// moderate, whisper and change its colour.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub moderation: bool,
|
||||
}
|
||||
|
||||
/// One room in a Sneedchat account's currently-enabled list, as exposed on
|
||||
@@ -242,6 +251,17 @@ impl BufferLink {
|
||||
}
|
||||
}
|
||||
|
||||
/// A warning the service has put on a conversation, drawn above it until the
|
||||
/// person has seen it. Steam's "this person may not be who they appear to be".
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||
pub struct BufferNotice {
|
||||
/// Which warning it is, for a client that draws them differently:
|
||||
/// "suspicious".
|
||||
pub kind: String,
|
||||
/// What a person reads.
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct Buffer {
|
||||
pub id: String,
|
||||
@@ -286,6 +306,9 @@ pub struct Buffer {
|
||||
/// Set while this buffer's own connection is not up - see BufferLink.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub link: Option<BufferLink>,
|
||||
/// A warning on this conversation, until it is dismissed - see BufferNotice.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub notice: Option<BufferNotice>,
|
||||
/// Whether this room is end-to-end encrypted (Matrix only - absent,
|
||||
/// not `false`, for every other protocol, since "encrypted" isn't a
|
||||
/// meaningful concept for them at all). Drives the lock/unlock
|
||||
@@ -449,6 +472,32 @@ pub struct ReplyPreview {
|
||||
/// something else entirely.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub forwarded: bool,
|
||||
/// The message a whole chain of replies grew from, where the service
|
||||
/// groups them into one conversation.
|
||||
///
|
||||
/// Kick does: every reply carries the id of the message at the top of its
|
||||
/// chain, however deep it is, while `id` stays the message it answered. A
|
||||
/// reply therefore points two ways - back at what it quoted, and up at the
|
||||
/// thread it belongs to - and a client wants both: the first for the quote
|
||||
/// above it, the second for a thread to open.
|
||||
#[serde(rename = "threadRoot", default, skip_serializing_if = "Option::is_none")]
|
||||
pub thread_root: Option<String>,
|
||||
}
|
||||
|
||||
/// Why a message the service took down is gone, kept with the message.
|
||||
///
|
||||
/// Kick tells everyone in a channel when a message is removed, and says by
|
||||
/// whom only as far as "a machine did it, and for breaking these rules". The
|
||||
/// text stays here rather than leaving the log, so that what it said is still
|
||||
/// readable - how it is drawn is up to the client.
|
||||
#[derive(Serialize, serde::Deserialize, Clone, Debug, Default, PartialEq)]
|
||||
pub struct Moderated {
|
||||
/// Whether Kick's automatic moderation did it, rather than a person.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub ai: bool,
|
||||
/// The rules it was said to break: `hate`, `harassment`, and so on.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub rules: Vec<String>,
|
||||
}
|
||||
|
||||
/// How a service says a sender should look.
|
||||
@@ -467,7 +516,7 @@ pub struct SenderStyle {
|
||||
/// account is among the reactors - Discord's REACTION_ADD/REMOVE events
|
||||
/// are per-user, so this is accumulated incrementally rather than
|
||||
/// snapshotted (see backend/discord/messages.rs's reaction handling).
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||
pub struct Reaction {
|
||||
pub emoji: String,
|
||||
pub count: i64,
|
||||
@@ -489,7 +538,7 @@ pub struct Reaction {
|
||||
/// Discord's own client does, and re-renders it live if a later edit
|
||||
/// changes it (a webhook can restyle an existing embed - status-bridge
|
||||
/// bots commonly do this to signal connected/degraded/down).
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
|
||||
pub struct Embed {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
@@ -556,7 +605,7 @@ pub struct Embed {
|
||||
}
|
||||
|
||||
/// A link with its words.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
|
||||
pub struct EmbedLink {
|
||||
pub label: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -564,7 +613,7 @@ pub struct EmbedLink {
|
||||
}
|
||||
|
||||
/// One of an embed's labelled lines.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
|
||||
pub struct EmbedField {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
@@ -777,6 +826,10 @@ pub struct Message {
|
||||
/// Buttons and menus on the message, where the service has them.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub components: Vec<Component>,
|
||||
/// Set once the service has taken this message down: it is still here, and
|
||||
/// this says it should be drawn as removed.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub moderated: Option<Moderated>,
|
||||
}
|
||||
|
||||
pub fn buffer_id(account_id: &str, name: &str) -> String {
|
||||
@@ -855,6 +908,8 @@ pub fn service_of(account_id: &str) -> &'static str {
|
||||
Some("discord") => "discord",
|
||||
Some("kick") => "kick",
|
||||
Some("sneedchat") => "sneedchat",
|
||||
Some("steam") => "steam",
|
||||
Some("twitch") => "twitch",
|
||||
Some("jabber") => "jabber",
|
||||
Some("slack") => "slack",
|
||||
_ => "irc",
|
||||
@@ -871,6 +926,7 @@ mod service_tests {
|
||||
assert_eq!(service_of("discord:167790743988076545"), "discord");
|
||||
assert_eq!(service_of("kick:someone"), "kick");
|
||||
assert_eq!(service_of("sneedchat:Ancient Pioneer"), "sneedchat");
|
||||
assert_eq!(service_of("steam:someone"), "steam");
|
||||
// IRC is the one without a prefix, and a host with a port in it must
|
||||
// not be read as one.
|
||||
assert_eq!(service_of("[email protected]"), "irc");
|
||||
@@ -967,6 +1023,7 @@ mod tests {
|
||||
position: 0,
|
||||
syncing: false,
|
||||
link: None,
|
||||
notice: None,
|
||||
encrypted: None,
|
||||
channel_modes: None,
|
||||
group_id: None,
|
||||
@@ -1003,7 +1060,8 @@ mod tests {
|
||||
/// Whether a service has anything to make of a status.
|
||||
///
|
||||
/// Offered by what each can actually express, and not by what could be faked
|
||||
/// out of something nearby: Discord has all four; IRC has away and back, which
|
||||
/// out of something nearby: Discord has all four; Steam has online, away
|
||||
/// (idle) and invisible; IRC has away and back, which
|
||||
/// are idle and online; Matrix has
|
||||
/// online and offline presence, so invisible is real and idle is not; Kick and
|
||||
/// Sneedchat have no presence at all.
|
||||
@@ -1016,7 +1074,7 @@ pub fn status_supported(account_id: &str, status: &str) -> bool {
|
||||
match status {
|
||||
"online" | "dnd" => true,
|
||||
"idle" => account_id.starts_with("discord:") || !(account_id.starts_with("matrix:") || account_id.starts_with("kick:") || account_id.starts_with("sneedchat:")),
|
||||
"invisible" => account_id.starts_with("discord:") || account_id.starts_with("matrix:"),
|
||||
"invisible" => account_id.starts_with("discord:") || account_id.starts_with("matrix:") || account_id.starts_with("steam:"),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -1045,6 +1103,14 @@ mod status_tests {
|
||||
assert!(!status_supported("matrix:@me:poa.st", "idle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steam_has_online_idle_and_invisible() {
|
||||
for s in ["online", "idle", "invisible", "dnd"] {
|
||||
assert!(status_supported("steam:someone", s), "{s}");
|
||||
}
|
||||
assert!(!status_supported("steam:someone", "nonsense"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kick_and_sneedchat_have_nothing_but_do_not_disturb_is_still_a_switch() {
|
||||
for account in ["kick:me", "sneedchat:me"] {
|
||||
|
||||
+906
-4
File diff suppressed because it is too large
Load Diff
+261
-8
@@ -449,6 +449,13 @@ pub struct Runtime {
|
||||
/// snapshot here lets subscribe() replay it immediately - see
|
||||
/// rpc/methods.rs's subscribe handler.
|
||||
presence: Mutex<HashMap<String, serde_json::Value>>,
|
||||
/// How many people a roster stands for, where that is more than it lists.
|
||||
///
|
||||
/// Kick's roster is the most recent speakers and is cut off to something
|
||||
/// a window can draw, while the count of everybody who has spoken keeps
|
||||
/// going. Beside the snapshot above so that a window opened later is told
|
||||
/// it as well.
|
||||
presence_total: Mutex<HashMap<String, usize>>,
|
||||
/// Generic (protocol-agnostic) "what's my own display name on this
|
||||
/// account" - record_message's highlight check needs this for every
|
||||
/// backend, not just IRC (which already tracked it per-connection via
|
||||
@@ -530,6 +537,16 @@ pub struct Runtime {
|
||||
/// One per connected Kick account, since one websocket carries every
|
||||
/// channel that account watches - see backend::kick's module doc.
|
||||
kick_senders: Mutex<HashMap<String, tokio::sync::mpsc::UnboundedSender<crate::backend::kick::Command>>>,
|
||||
twitch_senders: Mutex<HashMap<String, tokio::sync::mpsc::UnboundedSender<crate::backend::twitch::Command>>>,
|
||||
/// Whether this account may moderate each Twitch channel it has open, as Twitch last said.
|
||||
twitch_rights: Mutex<HashMap<String, bool>>,
|
||||
/// Each connected Steam account's connection, by account id: how a message
|
||||
/// or a status change reaches Steam from outside the connection's own task.
|
||||
steam_links: Mutex<HashMap<String, crate::backend::steam::link::Link>>,
|
||||
/// Who has asked to be a Steam account's friend, and who it has asked:
|
||||
/// what the window is shown on the account's page, kept so a window opened
|
||||
/// after the request arrived can still be told.
|
||||
steam_requests: Mutex<HashMap<String, serde_json::Value>>,
|
||||
/// What is known about each watched Kick channel, by buffer id.
|
||||
kick_channels: Mutex<HashMap<String, KickChannel>>,
|
||||
/// buffer id -> what that Kick channel is broadcasting, as last asked.
|
||||
@@ -833,7 +850,8 @@ pub struct Runtime {
|
||||
///
|
||||
/// Per account rather than per channel: away is a property of the person,
|
||||
/// and the same nick in three channels is away in all of them.
|
||||
irc_away: Mutex<HashMap<String, std::collections::HashSet<String>>>,
|
||||
/// Who is away, by lowercased nick, and what they said when they went.
|
||||
irc_away: Mutex<HashMap<String, HashMap<String, Option<String>>>>,
|
||||
/// (account, guild id or "@me" for direct messages) -> what that account
|
||||
/// has silenced on Discord itself.
|
||||
///
|
||||
@@ -1036,6 +1054,13 @@ fn cap_cache<K: std::hash::Hash + Eq, V>(map: &mut HashMap<K, V>, limit: usize)
|
||||
});
|
||||
}
|
||||
|
||||
/// The heading a conversation is always filed under, for a service whose list has fixed ones: Twitch's is
|
||||
/// "Channels" and "Direct Messages" (whispers), the second drawn only while there is a whisper to put under it.
|
||||
/// They are the service's own headings, so they cannot be renamed, removed or filled by hand.
|
||||
fn fixed_category(account_id: &str, kind: &str) -> Option<String> {
|
||||
account_id.starts_with("twitch:").then(|| if kind == "dm" { "Direct Messages".to_string() } else { "Channels".to_string() })
|
||||
}
|
||||
|
||||
impl Runtime {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
@@ -1057,6 +1082,7 @@ impl Runtime {
|
||||
discord_member_windows: Mutex::new(HashMap::new()),
|
||||
buffers: Mutex::new(HashMap::new()),
|
||||
presence: Mutex::new(HashMap::new()),
|
||||
presence_total: Mutex::new(HashMap::new()),
|
||||
own_identity: Mutex::new(HashMap::new()),
|
||||
discord_channels: Mutex::new(HashMap::new()),
|
||||
discord_gateway_sessions: Mutex::new(HashMap::new()),
|
||||
@@ -1077,6 +1103,10 @@ impl Runtime {
|
||||
discord_friends: Mutex::new(HashMap::new()),
|
||||
sneedchat_senders: Mutex::new(HashMap::new()),
|
||||
kick_senders: Mutex::new(HashMap::new()),
|
||||
twitch_senders: Mutex::new(HashMap::new()),
|
||||
twitch_rights: Mutex::new(HashMap::new()),
|
||||
steam_links: Mutex::new(HashMap::new()),
|
||||
steam_requests: Mutex::new(HashMap::new()),
|
||||
kick_channels: Mutex::new(HashMap::new()),
|
||||
kick_streams: Mutex::new(HashMap::new()),
|
||||
kick_stream_fetched: Mutex::new(HashMap::new()),
|
||||
@@ -1216,6 +1246,16 @@ impl Runtime {
|
||||
let conn = conn_states.get(&id).map(|s| s.as_str()).unwrap_or("disconnected");
|
||||
crate::accounts::kick_account_to_json(cfg, conn)
|
||||
}));
|
||||
out.extend(state.accounts.all_steam().iter().map(|cfg| {
|
||||
let id = cfg.account_id();
|
||||
let conn = conn_states.get(&id).map(|s| s.as_str()).unwrap_or("disconnected");
|
||||
crate::accounts::steam_account_to_json(cfg, conn, self.own_identity(&id).as_deref())
|
||||
}));
|
||||
out.extend(state.accounts.all_twitch().iter().map(|cfg| {
|
||||
let id = cfg.account_id();
|
||||
let conn = conn_states.get(&id).map(|s| s.as_str()).unwrap_or("disconnected");
|
||||
crate::accounts::twitch_account_to_json(cfg, conn)
|
||||
}));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -1615,7 +1655,12 @@ impl Runtime {
|
||||
let id = model::buffer_id(account_id, name);
|
||||
self.confirm_restored(account_id, &id, false);
|
||||
let mut buffers = self.buffers.lock().unwrap();
|
||||
if let Some(existing) = buffers.get(&id) {
|
||||
if let Some(existing) = buffers.get_mut(&id) {
|
||||
// A Twitch account's list is always headed the same two ways (see `fixed_category`), including
|
||||
// for what was open before it was.
|
||||
if existing.category.is_none() {
|
||||
existing.category = fixed_category(account_id, kind);
|
||||
}
|
||||
return existing.clone();
|
||||
}
|
||||
let last_activity_ts = state.store.last_activity(&id).unwrap_or(0);
|
||||
@@ -1631,10 +1676,11 @@ impl Runtime {
|
||||
// Filled in by whichever backend knows: a Discord guild files its
|
||||
// channels under the server's own headings, and everything else
|
||||
// has none.
|
||||
category: None,
|
||||
category: fixed_category(account_id, kind),
|
||||
position: 0,
|
||||
syncing: false,
|
||||
link: None,
|
||||
notice: None,
|
||||
encrypted: None,
|
||||
channel_modes: None,
|
||||
group_id: Some(model::account_group_id(account_id)),
|
||||
@@ -2004,6 +2050,14 @@ impl Runtime {
|
||||
self.presence.lock().unwrap().insert(buffer_id.to_string(), members);
|
||||
}
|
||||
|
||||
pub fn set_presence_total(&self, buffer_id: &str, total: usize) {
|
||||
self.presence_total.lock().unwrap().insert(buffer_id.to_string(), total);
|
||||
}
|
||||
|
||||
pub fn get_presence_total(&self, buffer_id: &str) -> Option<usize> {
|
||||
self.presence_total.lock().unwrap().get(buffer_id).copied()
|
||||
}
|
||||
|
||||
pub fn get_presence(&self, buffer_id: &str) -> Option<serde_json::Value> {
|
||||
self.presence.lock().unwrap().get(buffer_id).cloned()
|
||||
}
|
||||
@@ -2022,6 +2076,14 @@ impl Runtime {
|
||||
.insert(source.id.clone(), source);
|
||||
}
|
||||
|
||||
/// Forgets a source and the ones named under it (`id:...`), for a set that is rebuilt whole.
|
||||
pub fn clear_emoji_sources(&self, account_id: &str, id: &str) {
|
||||
if let Some(sources) = self.emoji_catalogue.lock().unwrap().get_mut(account_id) {
|
||||
let under = format!("{id}:");
|
||||
sources.retain(|have, _| have != id && !have.starts_with(&under));
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything this account can reach, newest knowledge of each source.
|
||||
pub fn emoji_catalogue(&self, account_id: &str) -> Vec<crate::model::EmojiSource> {
|
||||
self.emoji_catalogue
|
||||
@@ -2569,6 +2631,21 @@ impl Runtime {
|
||||
state.events.emit("bufferListChange", serde_json::to_value(&updated).unwrap());
|
||||
}
|
||||
|
||||
/// Puts a warning on a conversation, or takes it off. Broadcast only when it
|
||||
/// changes, so a notice Steam says again is not announced again.
|
||||
pub fn set_buffer_notice(&self, state: &AppState, buffer_id: &str, notice: Option<model::BufferNotice>) {
|
||||
let updated = {
|
||||
let mut buffers = self.buffers.lock().unwrap();
|
||||
let Some(buffer) = buffers.get_mut(buffer_id) else { return };
|
||||
if buffer.notice == notice {
|
||||
return;
|
||||
}
|
||||
buffer.notice = notice;
|
||||
buffer.clone()
|
||||
};
|
||||
state.events.emit("bufferListChange", serde_json::to_value(&updated).unwrap());
|
||||
}
|
||||
|
||||
pub fn set_buffer_syncing(&self, state: &AppState, buffer_id: &str, syncing: bool) {
|
||||
let updated = {
|
||||
let mut buffers = self.buffers.lock().unwrap();
|
||||
@@ -3442,13 +3519,37 @@ impl Runtime {
|
||||
/// Records that somebody is away, or is back. Returns whether this
|
||||
/// changed anything, so a caller only redraws rosters that moved.
|
||||
pub fn set_irc_away(&self, account_id: &str, nick: &str, away: bool) -> bool {
|
||||
self.set_irc_away_because(account_id, nick, away.then_some(None))
|
||||
}
|
||||
|
||||
/// As `set_irc_away`, with what they said when they went: `None` is back,
|
||||
/// `Some(None)` is away without a word, `Some(Some(words))` away with some.
|
||||
/// A change of words alone counts as a change, since it is drawn.
|
||||
pub fn set_irc_away_because(&self, account_id: &str, nick: &str, away: Option<Option<String>>) -> bool {
|
||||
let mut all = self.irc_away.lock().unwrap();
|
||||
let set = all.entry(account_id.to_string()).or_default();
|
||||
if away { set.insert(nick.to_lowercase()) } else { set.remove(&nick.to_lowercase()) }
|
||||
let held = all.entry(account_id.to_string()).or_default();
|
||||
let key = nick.to_lowercase();
|
||||
match away {
|
||||
Some(reason) => {
|
||||
let reason = reason.map(|r| r.trim().to_string()).filter(|r| !r.is_empty());
|
||||
// A reason is only known from an AWAY; learning somebody is away
|
||||
// some other way (a WHOIS) must not forget what they said.
|
||||
if reason.is_none() && held.contains_key(&key) {
|
||||
return false;
|
||||
}
|
||||
held.insert(key, reason.clone()) != Some(reason)
|
||||
}
|
||||
None => held.remove(&key).is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_irc_away(&self, account_id: &str, nick: &str) -> bool {
|
||||
self.irc_away.lock().unwrap().get(account_id).is_some_and(|set| set.contains(&nick.to_lowercase()))
|
||||
self.irc_away.lock().unwrap().get(account_id).is_some_and(|held| held.contains_key(&nick.to_lowercase()))
|
||||
}
|
||||
|
||||
/// What somebody who is away said when they went.
|
||||
pub fn irc_away_reason(&self, account_id: &str, nick: &str) -> Option<String> {
|
||||
self.irc_away.lock().unwrap().get(account_id).and_then(|held| held.get(&nick.to_lowercase()).cloned().flatten())
|
||||
}
|
||||
|
||||
pub fn sneedchat_room_catalogue(&self, account_id: &str) -> Option<Vec<crate::accounts::SneedChatRoom>> {
|
||||
@@ -3742,6 +3843,18 @@ impl Runtime {
|
||||
self.kick_streams.lock().unwrap().get(buffer_id).cloned()
|
||||
}
|
||||
|
||||
/// The conversation an account has open for a Kick channel, by the channel's
|
||||
/// own number - which is how the points event names it.
|
||||
pub fn kick_buffer_of_channel(&self, account_id: &str, channel_id: u64) -> Option<String> {
|
||||
let prefix = format!("{account_id}|");
|
||||
self.kick_channels
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|(buffer_id, channel)| buffer_id.starts_with(&prefix) && channel.channel_id == channel_id)
|
||||
.map(|(buffer_id, _)| buffer_id.clone())
|
||||
}
|
||||
|
||||
pub fn kick_channel(&self, buffer_id: &str) -> Option<KickChannel> {
|
||||
self.kick_channels.lock().unwrap().get(buffer_id).cloned()
|
||||
}
|
||||
@@ -3820,6 +3933,22 @@ impl Runtime {
|
||||
self.kick_senders.lock().unwrap().insert(account_id.to_string(), sender);
|
||||
}
|
||||
|
||||
pub fn set_twitch_sender(&self, account_id: &str, sender: tokio::sync::mpsc::UnboundedSender<crate::backend::twitch::Command>) {
|
||||
self.twitch_senders.lock().unwrap().insert(account_id.to_string(), sender);
|
||||
}
|
||||
|
||||
pub fn set_twitch_rights(&self, buffer_id: &str, moderator: bool) {
|
||||
self.twitch_rights.lock().unwrap().insert(buffer_id.to_string(), moderator);
|
||||
}
|
||||
|
||||
pub fn twitch_rights(&self, buffer_id: &str) -> Option<bool> {
|
||||
self.twitch_rights.lock().unwrap().get(buffer_id).copied()
|
||||
}
|
||||
|
||||
pub fn twitch_sender(&self, account_id: &str) -> Option<tokio::sync::mpsc::UnboundedSender<crate::backend::twitch::Command>> {
|
||||
self.twitch_senders.lock().unwrap().get(account_id).cloned()
|
||||
}
|
||||
|
||||
pub fn kick_sender(&self, account_id: &str) -> Option<tokio::sync::mpsc::UnboundedSender<crate::backend::kick::Command>> {
|
||||
self.kick_senders.lock().unwrap().get(account_id).cloned()
|
||||
}
|
||||
@@ -4215,6 +4344,56 @@ impl Runtime {
|
||||
self.kick_senders.lock().unwrap().remove(account_id);
|
||||
}
|
||||
|
||||
/// Registers a signed-in Steam connection.
|
||||
pub fn set_steam_link(&self, account_id: &str, link: crate::backend::steam::link::Link) {
|
||||
self.steam_links.lock().unwrap().insert(account_id.to_string(), link);
|
||||
}
|
||||
|
||||
/// The account's connection, if it is signed in right now.
|
||||
pub fn steam_link(&self, account_id: &str) -> Option<crate::backend::steam::link::Link> {
|
||||
self.steam_links.lock().unwrap().get(account_id).filter(|l| l.is_open()).cloned()
|
||||
}
|
||||
|
||||
/// Remembers the friend requests an account has, as the window shows them.
|
||||
pub fn set_steam_requests(&self, account_id: &str, requests: serde_json::Value) {
|
||||
self.steam_requests.lock().unwrap().insert(account_id.to_string(), requests);
|
||||
}
|
||||
|
||||
/// The friend requests last remembered for the account.
|
||||
pub fn steam_requests(&self, account_id: &str) -> serde_json::Value {
|
||||
self.steam_requests
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(account_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_json::json!({ "incoming": [], "outgoing": [] }))
|
||||
}
|
||||
|
||||
/// Takes the connection out, leaving none: ending a session means
|
||||
/// nothing else can reach it.
|
||||
pub fn take_steam_link(&self, account_id: &str) -> Option<crate::backend::steam::link::Link> {
|
||||
self.steam_links.lock().unwrap().remove(account_id)
|
||||
}
|
||||
|
||||
/// Records which person a conversation is with, as the service names them,
|
||||
/// and says so when it changed. The conversation's own name can change
|
||||
/// under it - a person renames themselves - and this does not.
|
||||
pub fn set_buffer_remote_id(&self, state: &AppState, buffer_id: &str, remote_id: &str) {
|
||||
let updated = {
|
||||
let mut buffers = self.buffers.lock().unwrap();
|
||||
match buffers.get_mut(buffer_id) {
|
||||
Some(b) if b.remote_id.as_deref() != Some(remote_id) => {
|
||||
b.remote_id = Some(remote_id.to_string());
|
||||
Some(b.clone())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
if let Some(b) = updated {
|
||||
state.events.emit("bufferListChange", serde_json::to_value(&b).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_sneedchat_senders(&self, account_id: &str) {
|
||||
self.sneedchat_senders.lock().unwrap().remove(account_id);
|
||||
}
|
||||
@@ -4250,6 +4429,11 @@ impl Runtime {
|
||||
for (sender, message) in farewells {
|
||||
let _ = sender.send_quit(if message.is_empty() { fallback } else { &message });
|
||||
}
|
||||
// Steam is told it is being left rather than dropped. The links stay
|
||||
// held, so the goodbye has the rest of the shutdown to be written.
|
||||
for link in self.steam_links.lock().unwrap().values() {
|
||||
crate::backend::steam::say_goodbye(link);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops a connection: gracefully QUITs if it already reached the
|
||||
@@ -4266,6 +4450,12 @@ impl Runtime {
|
||||
let _ = sender.send_quit(&quit_message);
|
||||
return true;
|
||||
}
|
||||
// A signed-in Steam session says goodbye first: the abort below stops
|
||||
// the task without letting it, and a session that is dropped instead
|
||||
// of left stays listed as online for a while.
|
||||
if let Some(link) = self.take_steam_link(account_id) {
|
||||
crate::backend::steam::sign_off(link);
|
||||
}
|
||||
let handle = self.task_handles.lock().unwrap().remove(account_id);
|
||||
match handle {
|
||||
Some(handle) => {
|
||||
@@ -4492,6 +4682,7 @@ impl Runtime {
|
||||
// Filled in by the backend that has them, immediately after this
|
||||
// returns - only Discord has any, and it is the one that knows.
|
||||
components: Vec::new(),
|
||||
moderated: None,
|
||||
id: msg_id,
|
||||
buffer_id: buffer.id.clone(),
|
||||
from: from.to_string(),
|
||||
@@ -4641,6 +4832,45 @@ impl Runtime {
|
||||
|
||||
/// Discord's MESSAGE_DELETE - removes the row and tells clients to
|
||||
/// drop it from view, same as Discord's own clients (no tombstone).
|
||||
/// A message the service took down, kept and marked as taken down. Quiet
|
||||
/// about one that is not stored: a removal of something older than this
|
||||
/// client's scrollback is not news here.
|
||||
pub fn moderate_message(&self, state: &AppState, buffer_id: &str, msg_id: &str, why: &model::Moderated) {
|
||||
match state.store.mark_moderated(buffer_id, msg_id, why) {
|
||||
Ok(true) => state.events.emit("messageModerated", json!({ "bufferId": buffer_id, "id": msg_id, "moderated": why })),
|
||||
Ok(false) => {}
|
||||
Err(e) => tracing::warn!("failed to mark a message as removed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything a person said in a conversation, kept and marked as removed:
|
||||
/// what a timeout or a ban does to their messages.
|
||||
pub fn moderate_sender(&self, state: &AppState, buffer_id: &str, sender_id: &str, why: &model::Moderated) {
|
||||
match state.store.mark_moderated_by_sender(buffer_id, sender_id, why) {
|
||||
Ok(ids) => {
|
||||
for id in ids {
|
||||
state.events.emit("messageModerated", json!({ "bufferId": buffer_id, "id": id, "moderated": why }));
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("failed to mark a person's messages as removed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Says how a channel's chat is restricted, in the words the header shows,
|
||||
/// where the service has its own modes (Twitch's room state).
|
||||
pub fn set_channel_modes(&self, state: &AppState, buffer_id: &str, modes: Option<String>) {
|
||||
let updated = {
|
||||
let mut buffers = self.buffers.lock().unwrap();
|
||||
let Some(buffer) = buffers.get_mut(buffer_id) else { return };
|
||||
if buffer.channel_modes == modes {
|
||||
return;
|
||||
}
|
||||
buffer.channel_modes = modes;
|
||||
buffer.clone()
|
||||
};
|
||||
state.events.emit("bufferListChange", serde_json::to_value(&updated).unwrap());
|
||||
}
|
||||
|
||||
pub fn delete_message(&self, state: &AppState, buffer_id: &str, msg_id: &str) {
|
||||
match state.store.delete_message(buffer_id, msg_id) {
|
||||
Ok(true) => state.events.emit("messageDeleted", json!({ "bufferId": buffer_id, "id": msg_id })),
|
||||
@@ -5385,11 +5615,11 @@ impl Runtime {
|
||||
restored_buffers, restored_groups, layout_confirmed,
|
||||
connect_generation, matrix_invites, discord_resync,
|
||||
discord_own_roles, discord_guild_roles, discord_guild_owners,
|
||||
discord_member_windows, presence, own_identity,
|
||||
discord_member_windows, presence, presence_total, own_identity,
|
||||
discord_channels, discord_gateway_sessions, ringing_calls,
|
||||
discord_guild_id, discord_buffer_emojis, emoji_catalogue,
|
||||
kick_watching, matrix_emoticons, discord_friends,
|
||||
sneedchat_senders, kick_senders, kick_channels,
|
||||
sneedchat_senders, kick_senders, twitch_senders, twitch_rights, steam_links, steam_requests, kick_channels,
|
||||
kick_streams, kick_stream_fetched, live_cards,
|
||||
irc_channel_lists, irc_channel_directory, irc_caps, irc_metadata,
|
||||
discord_last_interaction, matrix_room_creators, matrix_room_versions,
|
||||
@@ -5480,6 +5710,24 @@ mod forget_tests {
|
||||
/// The sweep itself, run: the other test reads the list, this one checks
|
||||
/// that what it names is actually emptied - for this account and its
|
||||
/// buffers, and for nobody else's.
|
||||
#[test]
|
||||
fn away_remembers_what_was_said_and_a_change_of_words_is_a_change() {
|
||||
let rt = super::Runtime::new();
|
||||
assert!(rt.set_irc_away_because("irc:a", "Bob", Some(Some("gone fishing".into()))));
|
||||
assert_eq!(rt.irc_away_reason("irc:a", "bob").as_deref(), Some("gone fishing"));
|
||||
assert!(!rt.set_irc_away_because("irc:a", "bob", Some(Some("gone fishing".into()))), "the same again is nothing");
|
||||
assert!(rt.set_irc_away_because("irc:a", "bob", Some(Some("lunch".into()))), "new words are drawn");
|
||||
// Learning they are away some other way must not forget the words.
|
||||
assert!(!rt.set_irc_away("irc:a", "bob", true));
|
||||
assert_eq!(rt.irc_away_reason("irc:a", "bob").as_deref(), Some("lunch"));
|
||||
// Away with nothing said, and back.
|
||||
assert!(rt.set_irc_away_because("irc:a", "amy", Some(None)));
|
||||
assert!(rt.is_irc_away("irc:a", "amy") && rt.irc_away_reason("irc:a", "amy").is_none());
|
||||
assert!(rt.set_irc_away("irc:a", "bob", false));
|
||||
assert!(!rt.is_irc_away("irc:a", "bob"));
|
||||
assert!(!rt.set_irc_away("irc:a", "bob", false), "back when already back is nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forgetting_an_account_empties_it_and_only_it() {
|
||||
let rt = super::Runtime::new();
|
||||
@@ -5492,9 +5740,14 @@ mod forget_tests {
|
||||
rt.silenced.lock().unwrap().insert("irc:a|#one".into());
|
||||
rt.silenced.lock().unwrap().insert("irc:ab|#one".into());
|
||||
rt.discord_members.lock().unwrap().insert(("irc:a|#one".into(), "u".into()), serde_json::json!({}));
|
||||
rt.set_steam_requests("irc:a", serde_json::json!({ "incoming": [1], "outgoing": [] }));
|
||||
rt.set_steam_requests("irc:ab", serde_json::json!({ "incoming": [2], "outgoing": [] }));
|
||||
|
||||
rt.forget_account("irc:a");
|
||||
|
||||
assert_eq!(rt.steam_requests("irc:a")["incoming"], serde_json::json!([]), "a forgotten account has no requests");
|
||||
assert_eq!(rt.steam_requests("irc:ab")["incoming"], serde_json::json!([2]));
|
||||
|
||||
assert!(rt.own_identity.lock().unwrap().get("irc:a").is_none());
|
||||
assert!(rt.own_identity.lock().unwrap().get("irc:ab").is_some(), "a longer id that starts the same is another account");
|
||||
assert_eq!(rt.matrix_rooms.lock().unwrap().keys().collect::<Vec<_>>(), ["irc:ab|#one"]);
|
||||
|
||||
+135
-13
@@ -187,6 +187,12 @@ impl Store {
|
||||
// Whether that reference is a message brought here rather than
|
||||
// one answered - see model::ReplyPreview::forwarded.
|
||||
"ALTER TABLE messages ADD COLUMN reply_forwarded INTEGER NOT NULL DEFAULT 0",
|
||||
// The message a chain of replies grew from - see
|
||||
// model::ReplyPreview::thread_root.
|
||||
"ALTER TABLE messages ADD COLUMN reply_thread_root TEXT",
|
||||
// A message the service took down and this client kept - see
|
||||
// model::Moderated. JSON, and null for a message nobody removed.
|
||||
"ALTER TABLE messages ADD COLUMN moderated TEXT",
|
||||
] {
|
||||
let _ = conn.execute(stmt, []);
|
||||
}
|
||||
@@ -279,6 +285,29 @@ impl Store {
|
||||
CREATE TABLE IF NOT EXISTS known_groups (id TEXT PRIMARY KEY, data TEXT NOT NULL);",
|
||||
);
|
||||
|
||||
// A warning the service put on a conversation, and whether it has been
|
||||
// dismissed: what keeps a banner over the conversation until it is, across
|
||||
// a restart - see backend/steam/notices.rs.
|
||||
let _ = conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS buffer_notices (
|
||||
buffer_id TEXT NOT NULL,
|
||||
notice_id TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
PRIMARY KEY (buffer_id, notice_id)
|
||||
);",
|
||||
);
|
||||
|
||||
// What each person on a Steam account was last called, by their number: so a start shows
|
||||
// names, and not numbers until Steam has said who they are - see backend/steam/friends.rs.
|
||||
let _ = conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS steam_names (
|
||||
account_id TEXT NOT NULL,
|
||||
steamid INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
PRIMARY KEY (account_id, steamid)
|
||||
);",
|
||||
);
|
||||
|
||||
let _ = conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS media_sources (
|
||||
path TEXT PRIMARY KEY,
|
||||
@@ -420,8 +449,8 @@ impl Store {
|
||||
// OR IGNORE against the (buffer_id, msg_id) index: recording the
|
||||
// same message twice is a backend replaying history it already
|
||||
// has, and the right answer is to keep the copy already stored.
|
||||
"INSERT OR IGNORE INTO messages (msg_id, buffer_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, buffer_kind, sender_color, badges, reply_is_thread, reply_forwarded)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, COALESCE(?12, '[]'), ?13, ?14, COALESCE(?15, '[]'), ?16, COALESCE(?17, '[]'), ?18, ?19, ?20, COALESCE(?21, '[]'), ?22, ?23)",
|
||||
"INSERT OR IGNORE INTO messages (msg_id, buffer_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, buffer_kind, sender_color, badges, reply_is_thread, reply_forwarded, reply_thread_root)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, COALESCE(?12, '[]'), ?13, ?14, COALESCE(?15, '[]'), ?16, COALESCE(?17, '[]'), ?18, ?19, ?20, COALESCE(?21, '[]'), ?22, ?23, ?24)",
|
||||
params![
|
||||
msg_id,
|
||||
buffer_id,
|
||||
@@ -446,6 +475,7 @@ impl Store {
|
||||
serde_json::to_string(badges).ok(),
|
||||
reply_to.is_some_and(|r| r.thread) as i32,
|
||||
reply_to.is_some_and(|r| r.forwarded) as i32,
|
||||
reply_to.and_then(|r| r.thread_root.as_deref()),
|
||||
],
|
||||
)?;
|
||||
// Whether this was actually new. The insert has always ignored a
|
||||
@@ -556,6 +586,30 @@ impl Store {
|
||||
Ok(bodies)
|
||||
}
|
||||
|
||||
/// Keeps a message the service has removed, marked as removed. Answers
|
||||
/// whether it was here to mark.
|
||||
pub fn mark_moderated(&self, buffer_id: &str, msg_id: &str, why: &crate::model::Moderated) -> Result<bool> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let json = serde_json::to_string(why)?;
|
||||
let rows = conn.execute("UPDATE messages SET moderated = ?3 WHERE buffer_id = ?1 AND msg_id = ?2", params![buffer_id, msg_id, json])?;
|
||||
Ok(rows > 0)
|
||||
}
|
||||
|
||||
/// Marks everything one person said in a conversation as removed, and answers
|
||||
/// which messages that was. Twitch does this to a person's messages when it
|
||||
/// times them out or bans them: no message is named, the person is.
|
||||
pub fn mark_moderated_by_sender(&self, buffer_id: &str, sender_id: &str, why: &crate::model::Moderated) -> Result<Vec<String>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let json = serde_json::to_string(why)?;
|
||||
let mut stmt = conn.prepare("SELECT msg_id FROM messages WHERE buffer_id = ?1 AND sender_id = ?2 AND kind = 'chat' AND moderated IS NULL")?;
|
||||
let ids: Vec<String> = stmt.query_map(params![buffer_id, sender_id], |row| row.get::<_, String>(0))?.filter_map(|r| r.ok()).collect();
|
||||
drop(stmt);
|
||||
for id in &ids {
|
||||
conn.execute("UPDATE messages SET moderated = ?3 WHERE buffer_id = ?1 AND msg_id = ?2", params![buffer_id, id, json])?;
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
pub fn delete_message(&self, buffer_id: &str, msg_id: &str) -> Result<bool> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let rows = conn.execute("DELETE FROM messages WHERE buffer_id = ?1 AND msg_id = ?2", params![buffer_id, msg_id])?;
|
||||
@@ -638,6 +692,8 @@ impl Store {
|
||||
"UPDATE OR IGNORE messages SET buffer_id = ?2 WHERE buffer_id = ?1",
|
||||
params![old_id, new_id],
|
||||
)?;
|
||||
// A warning on the conversation goes with it.
|
||||
conn.execute("UPDATE OR IGNORE buffer_notices SET buffer_id = ?2 WHERE buffer_id = ?1", params![old_id, new_id])?;
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
@@ -723,7 +779,8 @@ impl Store {
|
||||
from: row.get::<_, Option<String>>("reply_to_from").ok().flatten().unwrap_or_default(),
|
||||
body: row.get::<_, Option<String>>("reply_to_body").ok().flatten().unwrap_or_default(),
|
||||
thread: row.get::<_, Option<i64>>("reply_is_thread").ok().flatten().unwrap_or(0) != 0,
|
||||
forwarded: row.get::<_, Option<i64>>("reply_forwarded").ok().flatten().unwrap_or(0) != 0 });
|
||||
forwarded: row.get::<_, Option<i64>>("reply_forwarded").ok().flatten().unwrap_or(0) != 0,
|
||||
thread_root: row.get::<_, Option<String>>("reply_thread_root").ok().flatten() });
|
||||
let json_column = |name: &str| -> String {
|
||||
row.get::<_, Option<String>>(name).ok().flatten().unwrap_or_else(|| "[]".to_string())
|
||||
};
|
||||
@@ -733,6 +790,7 @@ impl Store {
|
||||
drop_missing_local_copies(&mut attachments);
|
||||
let badges = serde_json::from_str(&json_column("badges")).unwrap_or_default();
|
||||
let components = serde_json::from_str(&json_column("components")).unwrap_or_default();
|
||||
let moderated = row.get::<_, Option<String>>("moderated").ok().flatten().and_then(|json| serde_json::from_str(&json).ok());
|
||||
Ok(Message {
|
||||
id: row.get::<_, Option<String>>("msg_id")?.unwrap_or_default(),
|
||||
buffer_id: buffer_id.to_string(),
|
||||
@@ -757,6 +815,7 @@ impl Store {
|
||||
badges,
|
||||
html: row.get("html")?,
|
||||
components,
|
||||
moderated,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -764,7 +823,7 @@ impl Store {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let limit = if limit > 0 { limit } else { 200 };
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, reply_thread_root, moderated
|
||||
FROM messages
|
||||
WHERE buffer_id = ?1 AND (?2 <= 0 OR ts < ?2)
|
||||
ORDER BY ts DESC LIMIT ?3",
|
||||
@@ -840,7 +899,7 @@ impl Store {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let limit = if limit > 0 { limit } else { 200 };
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, reply_thread_root, moderated
|
||||
FROM messages
|
||||
WHERE buffer_id = ?1 AND ts > ?2
|
||||
ORDER BY ts ASC LIMIT ?3",
|
||||
@@ -863,7 +922,7 @@ impl Store {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let limit = if limit > 0 { limit } else { 500 };
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, reply_thread_root, moderated
|
||||
FROM messages
|
||||
WHERE buffer_id = ?1 AND ts > ?2 AND ts <= ?3
|
||||
ORDER BY ts ASC LIMIT ?4",
|
||||
@@ -904,7 +963,7 @@ impl Store {
|
||||
pub fn messages_around(&self, buffer_id: &str, ts: i64, span: i64) -> Result<Vec<Message>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let span = if span > 0 { span } else { 50 };
|
||||
const COLUMNS: &str = "msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components";
|
||||
const COLUMNS: &str = "msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, reply_thread_root, moderated";
|
||||
|
||||
let mut older = conn.prepare(&format!(
|
||||
"SELECT {COLUMNS} FROM messages WHERE buffer_id = ?1 AND ts <= ?2 ORDER BY ts DESC LIMIT ?3"
|
||||
@@ -950,7 +1009,7 @@ impl Store {
|
||||
format!(" OR (buffer_kind IS NULL AND buffer_id IN ({places}))")
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, buffer_id
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, reply_thread_root, moderated, buffer_id
|
||||
FROM messages
|
||||
WHERE is_highlight = 1 AND is_own = 0 AND (buffer_kind = 'channel'{legacy})
|
||||
ORDER BY ts DESC LIMIT ?"
|
||||
@@ -1026,6 +1085,31 @@ impl Store {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_notice_state(&self, buffer_id: &str, notice_id: &str, state: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO buffer_notices (buffer_id, notice_id, state) VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(buffer_id, notice_id) DO UPDATE SET state = ?3",
|
||||
params![buffer_id, notice_id, state],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The warnings of one account's conversations: each with its state, or
|
||||
/// none for a warning that was said in the log before anything recorded it.
|
||||
/// Found by the log line a warning leaves, which has an id of its own.
|
||||
pub fn account_notices(&self, account_id: &str) -> Result<Vec<(String, String, Option<String>)>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let like = format!("{account_id}|%");
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT m.buffer_id, m.msg_id, n.state FROM messages m
|
||||
LEFT JOIN buffer_notices n ON n.buffer_id = m.buffer_id AND n.notice_id = m.msg_id
|
||||
WHERE m.buffer_id LIKE ?1 AND m.msg_id LIKE 'notice:%'",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![like], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?;
|
||||
rows.collect::<rusqlite::Result<_>>().map_err(Into::into)
|
||||
}
|
||||
|
||||
/// The polls or predictions this conversation has seen, newest first.
|
||||
pub fn live_cards(&self, buffer_id: &str, kind: &str, limit: i64) -> Result<Vec<(String, i64)>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
@@ -1098,6 +1182,31 @@ impl Store {
|
||||
Ok((buffers, groups))
|
||||
}
|
||||
|
||||
/// What the people on a Steam account were last called, by account number.
|
||||
pub fn steam_names(&self, account_id: &str) -> Result<Vec<(u32, String)>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT steamid, name FROM steam_names WHERE account_id = ?1")?;
|
||||
let rows = stmt.query_map(params![account_id], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?;
|
||||
Ok(rows.filter_map(|r| r.ok()).filter_map(|(id, name)| Some((u32::try_from(id).ok()?, name))).collect())
|
||||
}
|
||||
|
||||
/// Keeps what these people are called now, in place of what they were.
|
||||
pub fn remember_steam_names(&self, account_id: &str, names: &[(u32, String)]) -> Result<()> {
|
||||
let mut conn = self.conn.lock().unwrap();
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut put = tx.prepare(
|
||||
"INSERT INTO steam_names (account_id, steamid, name) VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(account_id, steamid) DO UPDATE SET name = excluded.name",
|
||||
)?;
|
||||
for (id, name) in names {
|
||||
put.execute(params![account_id, i64::from(*id), name])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Records where a cached media file came from - see media_cache.rs.
|
||||
pub fn record_media_source(&self, path: &str, source: &serde_json::Value) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
@@ -1161,7 +1270,7 @@ impl Store {
|
||||
let escaped = query.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
|
||||
let pattern = format!("%{escaped}%");
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, reply_thread_root, moderated
|
||||
FROM messages
|
||||
WHERE buffer_id = ?1 AND body LIKE ?2 ESCAPE '\\'
|
||||
ORDER BY ts DESC LIMIT ?3",
|
||||
@@ -1226,9 +1335,9 @@ impl Store {
|
||||
pub fn thread_messages(&self, buffer_id: &str, root_id: &str) -> Result<Vec<Message>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, reply_thread_root, moderated
|
||||
FROM messages
|
||||
WHERE buffer_id = ?1 AND ((reply_to_id = ?2 AND reply_is_thread = 1) OR msg_id = ?2)
|
||||
WHERE buffer_id = ?1 AND ((reply_to_id = ?2 AND reply_is_thread = 1) OR reply_thread_root = ?2 OR msg_id = ?2)
|
||||
ORDER BY ts ASC, rowid ASC",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![buffer_id, root_id], |row| Self::row_to_message(buffer_id, row))?;
|
||||
@@ -1238,7 +1347,7 @@ impl Store {
|
||||
pub fn get_message(&self, buffer_id: &str, msg_id: &str) -> Result<Option<Message>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, reply_thread_root, moderated
|
||||
FROM messages
|
||||
WHERE buffer_id = ?1 AND msg_id = ?2",
|
||||
)?;
|
||||
@@ -1288,7 +1397,7 @@ impl Store {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let like = format!("{account_id}|%");
|
||||
let mut gone = 0;
|
||||
for table in ["messages", "live_cards", "matrix_reactions"] {
|
||||
for table in ["messages", "live_cards", "matrix_reactions", "buffer_notices"] {
|
||||
gone += conn.execute(
|
||||
&format!("DELETE FROM {table} WHERE buffer_id = ?1 OR buffer_id LIKE ?2"),
|
||||
params![account_id, like],
|
||||
@@ -1946,6 +2055,19 @@ mod dedupe_tests {
|
||||
Store::open(std::path::Path::new(":memory:")).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_steam_people_were_called_is_kept_per_account_and_replaced_when_they_rename() {
|
||||
let s = store();
|
||||
assert!(s.steam_names("steam:a").unwrap().is_empty());
|
||||
s.remember_steam_names("steam:a", &[(7, "Gabe".into()), (8, "Alyx".into())]).unwrap();
|
||||
s.remember_steam_names("steam:b", &[(7, "Somebody else".into())]).unwrap();
|
||||
s.remember_steam_names("steam:a", &[(7, "Gabe N.".into())]).unwrap();
|
||||
let mut a = s.steam_names("steam:a").unwrap();
|
||||
a.sort();
|
||||
assert_eq!(a, vec![(7, "Gabe N.".to_string()), (8, "Alyx".to_string())]);
|
||||
assert_eq!(s.steam_names("steam:b").unwrap(), vec![(7, "Somebody else".to_string())]);
|
||||
}
|
||||
|
||||
fn put(s: &Store, buffer: &str, id: &str, body: &str) {
|
||||
let _ = s.append_message(buffer, id, "nick", body, 1, false, false, "chat", None, &[], false, None, &[], &[], None, None, "channel", None, &[]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user