Compare commits
Generated
+1
-1
@@ -3686,7 +3686,7 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nobilis"
|
||||
version = "1.1.0"
|
||||
version = "1.2.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "nobilis"
|
||||
version = "1.1.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"
|
||||
|
||||
+288
-7
@@ -390,9 +390,8 @@ pub struct SteamAccountConfig {
|
||||
/// 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. Always `chat`, the
|
||||
/// mobile app's, which lives beside the Steam launcher; kept so files
|
||||
/// written when the desktop kind could still be chosen read the same.
|
||||
/// 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
|
||||
@@ -411,6 +410,85 @@ impl SteamAccountConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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")]
|
||||
@@ -429,6 +507,8 @@ struct AccountsFile {
|
||||
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,
|
||||
@@ -446,6 +526,8 @@ pub struct AccountStore {
|
||||
/// 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 {
|
||||
@@ -456,7 +538,7 @@ impl AccountStore {
|
||||
}
|
||||
|
||||
pub fn open(path: PathBuf) -> Result<Self> {
|
||||
let (irc, discord, sneedchat, matrix, kick, steam) = 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)
|
||||
@@ -467,11 +549,21 @@ impl AccountStore {
|
||||
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();
|
||||
let steam = file.steam.into_iter().map(|a| (a.account_id(), a)).collect();
|
||||
(irc, discord, sneedchat, matrix, kick, steam)
|
||||
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(), 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), steam: Mutex::new(steam) })
|
||||
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
|
||||
@@ -492,6 +584,7 @@ impl AccountStore {
|
||||
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
|
||||
@@ -680,6 +773,55 @@ impl AccountStore {
|
||||
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> {
|
||||
@@ -818,6 +960,10 @@ impl AccountStore {
|
||||
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
|
||||
};
|
||||
@@ -845,6 +991,9 @@ impl AccountStore {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -925,6 +1074,13 @@ impl AccountStore {
|
||||
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)?;
|
||||
@@ -1086,6 +1242,9 @@ impl AccountStore {
|
||||
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),
|
||||
@@ -1119,6 +1278,9 @@ impl AccountStore {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -1238,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1277,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1315,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1362,6 +1530,8 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1408,6 +1578,51 @@ pub fn steam_account_to_json(a: &SteamAccountConfig, state: &str, persona_name:
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1450,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1599,3 +1816,67 @@ mod steam_account_tests {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+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()
|
||||
|
||||
+259
-39
@@ -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)]
|
||||
@@ -173,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.
|
||||
@@ -188,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,
|
||||
@@ -257,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,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,
|
||||
@@ -456,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.
|
||||
@@ -483,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
|
||||
@@ -908,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"));
|
||||
@@ -946,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");
|
||||
@@ -978,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");
|
||||
}
|
||||
@@ -990,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]
|
||||
@@ -1004,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]
|
||||
@@ -1256,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");
|
||||
@@ -1266,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]
|
||||
@@ -1337,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]
|
||||
@@ -1356,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),
|
||||
}
|
||||
|
||||
+10
-1
@@ -68,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;
|
||||
@@ -79,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)
|
||||
@@ -111,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
|
||||
}
|
||||
|
||||
+12
-10
@@ -551,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
|
||||
@@ -659,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.
|
||||
///
|
||||
@@ -681,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
|
||||
@@ -827,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}");
|
||||
}
|
||||
}
|
||||
@@ -887,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)));
|
||||
|
||||
@@ -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}")
|
||||
@@ -161,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
|
||||
@@ -211,6 +248,18 @@ async fn session(state: &crate::state::AppState, account_id: &str, user_id: &str
|
||||
// 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() || begun.elapsed() > std::time::Duration::from_secs(30 * 60) {
|
||||
@@ -256,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) {
|
||||
@@ -296,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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -16,3 +16,4 @@ pub mod kick;
|
||||
pub mod matrix;
|
||||
pub mod sneedchat;
|
||||
pub mod steam;
|
||||
pub mod twitch;
|
||||
|
||||
+94
-14
@@ -27,19 +27,75 @@ use base64::Engine;
|
||||
use rsa::{BigUint, Pkcs1v15Encrypt, RsaPublicKey};
|
||||
use std::time::Duration;
|
||||
|
||||
/// What moho signs in as: the Steam mobile app's chat session.
|
||||
/// 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. The desktop client's session is one per account,
|
||||
/// so signing in as it would sign the Steam launcher out (and the reverse).
|
||||
/// The mobile chat session is chat and friends only, and lives beside the
|
||||
/// launcher. It is the only kind moho offers.
|
||||
/// 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` 3 is the mobile app.
|
||||
const PLATFORM_TYPE: i32 = 3;
|
||||
/// `EAuthTokenAppType` 2 is the mobile chat app.
|
||||
const APP_TYPE: i32 = 2;
|
||||
const WEBSITE_ID: &str = "Mobile";
|
||||
/// `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.
|
||||
@@ -155,7 +211,9 @@ fn device_details() -> DeviceDetails {
|
||||
device_friendly_name: Some(device_name()),
|
||||
platform_type: Some(PLATFORM_TYPE),
|
||||
os_type: Some(os_type() as i32),
|
||||
app_type: Some(APP_TYPE),
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -433,8 +491,11 @@ mod tests {
|
||||
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(3), "it signs in as the mobile app, never as the desktop client");
|
||||
assert_eq!(begin_req.device_details.unwrap().app_type, Some(2));
|
||||
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();
|
||||
@@ -519,4 +580,23 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,8 +39,8 @@ pub const PROTOCOL_VERSION: u32 = 65581;
|
||||
/// How long Steam has to answer a sign-in.
|
||||
const LOGON_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
const RECONNECT_INITIAL_DELAY: Duration = Duration::from_secs(3);
|
||||
const RECONNECT_MAX_DELAY: Duration = Duration::from_secs(60);
|
||||
const RECONNECT_INITIAL_DELAY: Duration = Duration::from_secs(10);
|
||||
const RECONNECT_MAX_DELAY: Duration = Duration::from_secs(300);
|
||||
|
||||
/// How long to leave an account alone after Steam says it is being asked too
|
||||
/// often. Seconds would be asking again; this is the length of time that the
|
||||
@@ -124,10 +124,11 @@ pub fn logon_message(config: &SteamAccountConfig, language: &str) -> ClientLogon
|
||||
should_remember_password: Some(true),
|
||||
// Where the machine is on its own network is nothing Steam needs to
|
||||
// be told; the field exists and asks to be filled.
|
||||
obfuscated_private_ip: Some(IpAddress { ip: Some(ip_address::Ip::V4(0)) }),
|
||||
obfuscated_private_ip: Some(IpAddress { ip: Some(ip_address::Ip::V4(auth::logon_id())) }),
|
||||
chat_mode: Some(2),
|
||||
account_name: Some(config.username.clone()),
|
||||
machine_name: Some(auth::device_name()),
|
||||
machine_id: Some(auth::machine_id()),
|
||||
supports_rate_limit_response: Some(true),
|
||||
// The refresh token, which is what this field takes whatever its name.
|
||||
access_token: Some(config.refresh_token.clone()),
|
||||
|
||||
@@ -305,7 +305,7 @@ pub async fn flow(
|
||||
display_name: None,
|
||||
refresh_token: tokens.refresh_token,
|
||||
guard_data: tokens.guard_data,
|
||||
// Always the mobile chat session; kept so older files still read.
|
||||
// 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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -340,6 +340,9 @@ async fn run() -> Result<()> {
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
@@ -463,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.
|
||||
@@ -791,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 {
|
||||
@@ -870,6 +909,7 @@ pub fn service_of(account_id: &str) -> &'static str {
|
||||
Some("kick") => "kick",
|
||||
Some("sneedchat") => "sneedchat",
|
||||
Some("steam") => "steam",
|
||||
Some("twitch") => "twitch",
|
||||
Some("jabber") => "jabber",
|
||||
Some("slack") => "slack",
|
||||
_ => "irc",
|
||||
|
||||
+399
-3
@@ -82,6 +82,10 @@ fn respawn_account(state: &AppState, id: &str) {
|
||||
if let Some(cfg) = state.accounts.get_steam(id) {
|
||||
backend::steam::spawn(state.clone(), cfg);
|
||||
}
|
||||
} else if id.starts_with("twitch:") {
|
||||
if let Some(cfg) = state.accounts.get_twitch(id) {
|
||||
backend::twitch::spawn(state.clone(), cfg);
|
||||
}
|
||||
} else if let Some(cfg) = state.accounts.get_irc(id) {
|
||||
backend::irc::spawn(state.clone(), cfg);
|
||||
}
|
||||
@@ -107,6 +111,7 @@ fn adding_service(method: &str) -> Option<&'static str> {
|
||||
| "matrixRegistrationFlows" | "matrixLoginFlows" | "addMatrixAccountSso" | "addMatrixAccountQr" => Some("matrix"),
|
||||
"addKickAccount" => Some("kick"),
|
||||
"addSteamAccount" | "addSteamAccountQr" => Some("steam"),
|
||||
"addTwitchAccount" | "startTwitchLogin" => Some("twitch"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -178,6 +183,15 @@ pub async fn dispatch(
|
||||
if account_id.starts_with("steam:") {
|
||||
backend::steam::emoticons::refresh(state, &account_id).await;
|
||||
}
|
||||
// Twitch's: what the account can use in a channel changes under it (a reward unlocks one, a 24-hour
|
||||
// unlock lapses), so the picker reads it again when what it holds is a couple of minutes old.
|
||||
if account_id.starts_with("twitch:") {
|
||||
if let Some(buffer) = state.runtime.get_buffer(buffer_id) {
|
||||
if let (Some(room), true) = backend::twitch::emotes::user_emotes_state(&buffer.account_id, &buffer.name, std::time::Duration::from_secs(120)) {
|
||||
backend::twitch::connect::refresh_user_emotes(state, &buffer.account_id, &buffer.name, &room).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut sources = state.runtime.emoji_catalogue(&account_id);
|
||||
// Steam's stickers are sent, not typed: they have their own tab, asked for
|
||||
// by `listSteamStickers`, and are not a section of emoji.
|
||||
@@ -209,9 +223,19 @@ pub async fn dispatch(
|
||||
|
||||
// Whether each source reaches this conversation. Worked out here
|
||||
// rather than stored, because it is a fact about the pair.
|
||||
// A Twitch channel's section is marked by the channel's own picture, which is known to the
|
||||
// conversation and not to the source.
|
||||
let channel_picture = state.runtime.get_buffer(buffer_id).filter(|_| account_id.starts_with("twitch:")).and_then(|b| b.avatar_url.map(|a| (format!("twitch:{}", b.name), a)));
|
||||
let answer: Vec<serde_json::Value> = sources
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
.map(|mut s| {
|
||||
if s.icon_url.is_none() {
|
||||
if let Some((id, picture)) = &channel_picture {
|
||||
if s.id == *id {
|
||||
s.icon_url = Some(picture.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let here = s.buffers.is_empty() || s.buffers.iter().any(|b| b == buffer_id);
|
||||
let usable = here || s.sendable_anywhere;
|
||||
let mut v = serde_json::to_value(&s).unwrap_or_default();
|
||||
@@ -265,6 +289,25 @@ pub async fn dispatch(
|
||||
None,
|
||||
),
|
||||
},
|
||||
// Twitch's: what the account can use there, and the other services'.
|
||||
Some(buffer_id) if buffer_id.starts_with("twitch:") => {
|
||||
let Some(buffer) = state.runtime.get_buffer(buffer_id) else { return (Some(serde_json::json!([])), None) };
|
||||
// What this account can use there changes under it (a 24-hour unlock lapses, a reward unlocks one):
|
||||
// read again when the picker asks and what it was told is a couple of minutes old.
|
||||
if let (Some(room), true) = backend::twitch::emotes::user_emotes_state(&buffer.account_id, &buffer.name, std::time::Duration::from_secs(120)) {
|
||||
backend::twitch::connect::refresh_user_emotes(state, &buffer.account_id, &buffer.name, &room).await;
|
||||
}
|
||||
let mut sources = state.runtime.emoji_catalogue(&buffer.account_id);
|
||||
sources.retain(|s| s.buffers.iter().any(|b| b == buffer_id));
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let emoji: Vec<serde_json::Value> = sources
|
||||
.into_iter()
|
||||
.flat_map(|s| s.emoji)
|
||||
.filter(|e| !e.locked && seen.insert(e.name.clone()))
|
||||
.map(|e| serde_json::json!({ "id": e.id, "name": e.name, "animated": e.animated, "url": e.url, "locked": false }))
|
||||
.collect();
|
||||
(Some(serde_json::json!(emoji)), None)
|
||||
}
|
||||
// Steam's emoticons are the account's, the same in every conversation.
|
||||
Some(buffer_id) if buffer_id.starts_with("steam:") => {
|
||||
let emoji: Vec<serde_json::Value> = state
|
||||
@@ -382,6 +425,7 @@ pub async fn dispatch(
|
||||
{ "id": "sneedchat", "name": "Sneedchat", "available": true },
|
||||
{ "id": "kick", "name": "Kick", "available": true },
|
||||
{ "id": "steam", "name": "Steam", "available": true },
|
||||
{ "id": "twitch", "name": "Twitch", "available": true },
|
||||
])),
|
||||
None,
|
||||
),
|
||||
@@ -539,7 +583,11 @@ pub async fn dispatch(
|
||||
// until the next incremental change (see Runtime::
|
||||
// set_presence's doc comment).
|
||||
if let Some(members) = state.runtime.get_presence(buffer_id) {
|
||||
state.events.emit("presenceChange", serde_json::json!({ "bufferId": buffer_id, "members": members }));
|
||||
let mut change = serde_json::json!({ "bufferId": buffer_id, "members": members });
|
||||
if let Some(total) = state.runtime.get_presence_total(buffer_id) {
|
||||
change["total"] = serde_json::json!(total);
|
||||
}
|
||||
state.events.emit("presenceChange", change);
|
||||
}
|
||||
// Read markers are replayed for the same reason: they arrive
|
||||
// on sync and are not repeated once they stop moving, so a
|
||||
@@ -569,6 +617,11 @@ pub async fn dispatch(
|
||||
}
|
||||
}
|
||||
|
||||
// Whether this account may moderate a Twitch channel, which Twitch
|
||||
// says once on joining and a window opened later missed.
|
||||
if let Some(moderator) = state.runtime.twitch_rights(buffer_id) {
|
||||
state.events.emit("twitchRights", serde_json::json!({ "bufferId": buffer_id, "moderator": moderator }));
|
||||
}
|
||||
// And what a Kick channel is broadcasting, which changes while
|
||||
// nobody is looking: a viewer count from an hour ago is worse
|
||||
// than none, so opening the channel asks again.
|
||||
@@ -723,8 +776,23 @@ pub async fn dispatch(
|
||||
if id.starts_with("discord:") {
|
||||
backend::discord::golive::forget(id);
|
||||
}
|
||||
// A Twitch sign-in is ended at Twitch as well, so removing the
|
||||
// account here does not leave a token good for four more
|
||||
// hours and a refresh token good for thirty days. Best effort:
|
||||
// the account is removed either way.
|
||||
let twitch_tokens = state.accounts.get_twitch(id).filter(|a| a.is_signed_in()).map(|a| (a.access_token, a.refresh_token));
|
||||
match state.accounts.remove(id) {
|
||||
Ok(true) => (Some(ok_node()), None),
|
||||
Ok(true) => {
|
||||
if let Some((access, refresh)) = twitch_tokens {
|
||||
tokio::spawn(async move {
|
||||
if let Ok(http) = backend::twitch::client() {
|
||||
let _ = backend::twitch::auth::revoke(&http, backend::twitch::auth::ID_ROOT, &access).await;
|
||||
let _ = backend::twitch::auth::revoke(&http, backend::twitch::auth::ID_ROOT, &refresh).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
(Some(ok_node()), None)
|
||||
}
|
||||
Ok(false) => (None, Some("no such account".to_string())),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
@@ -776,6 +844,14 @@ pub async fn dispatch(
|
||||
}
|
||||
None => (None, Some("no such account".to_string())),
|
||||
}
|
||||
} else if id.starts_with("twitch:") {
|
||||
match state.accounts.get_twitch(id) {
|
||||
Some(cfg) => {
|
||||
backend::twitch::spawn(state.clone(), cfg);
|
||||
(Some(ok_node()), None)
|
||||
}
|
||||
None => (None, Some("no such account".to_string())),
|
||||
}
|
||||
} else {
|
||||
match state.accounts.get_irc(id) {
|
||||
Some(cfg) => {
|
||||
@@ -933,6 +1009,27 @@ pub async fn dispatch(
|
||||
Err(_) => (None, Some("Kick took too long to answer for that channel".to_string())),
|
||||
};
|
||||
}
|
||||
// Twitch is the same shape: a channel is a streamer, naming one is
|
||||
// the whole of joining it, and the connection remembers it once
|
||||
// Twitch has said it is there.
|
||||
if id.starts_with("twitch:") {
|
||||
let Some(channel) = backend::twitch::connect::normalise_login(name) else {
|
||||
return (None, Some(format!("\"{name}\" is not a Twitch channel")));
|
||||
};
|
||||
let Some(sender) = state.runtime.twitch_sender(id) else {
|
||||
return (None, Some("that Twitch account is not connected".to_string()));
|
||||
};
|
||||
let (reply, answer) = tokio::sync::oneshot::channel();
|
||||
if sender.send(backend::twitch::Command::Join(channel, Some(reply))).is_err() {
|
||||
return (None, Some("that Twitch account is not connected".to_string()));
|
||||
}
|
||||
return match tokio::time::timeout(std::time::Duration::from_secs(60), answer).await {
|
||||
Ok(Ok(Ok(()))) => (Some(ok_node()), None),
|
||||
Ok(Ok(Err(e))) => (None, Some(e)),
|
||||
Ok(Err(_)) => (None, Some("the Twitch connection dropped before the channel opened".to_string())),
|
||||
Err(_) => (None, Some("Twitch took too long to answer for that channel".to_string())),
|
||||
};
|
||||
}
|
||||
match state.runtime.irc_sender(id) {
|
||||
None => (None, Some("join failed (account not connected?)".to_string())),
|
||||
Some(sender) => match sender.send_join(name) {
|
||||
@@ -3118,6 +3215,13 @@ pub async fn dispatch(
|
||||
let left: Vec<String> = cfg.channels.into_iter().filter(|c| *c != buffer.name).collect();
|
||||
let _ = state.accounts.set_kick_channels(&buffer.account_id, left);
|
||||
}
|
||||
} else if buffer.account_id.starts_with("twitch:") {
|
||||
// As Kick's: nothing to leave, so it parts the
|
||||
// connection's channel and forgets the handle.
|
||||
if let Some(sender) = state.runtime.twitch_sender(&buffer.account_id) {
|
||||
let _ = sender.send(backend::twitch::Command::Part(buffer.name.clone()));
|
||||
}
|
||||
let _ = state.accounts.mutate_twitch(&buffer.account_id, |a| a.channels.retain(|c| *c != buffer.name));
|
||||
} else if buffer.account_id.starts_with("matrix:") {
|
||||
if let Some(room_id) = state.runtime.get_matrix_room(buffer_id) {
|
||||
if let Err(e) = backend::matrix::leave_room(state, &buffer.account_id, &room_id).await {
|
||||
@@ -3380,6 +3484,50 @@ pub async fn dispatch(
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
}
|
||||
Some(buffer) if buffer.account_id.starts_with("twitch:") => {
|
||||
// Twitch's chat is one connection with the account's
|
||||
// channels on it: the message goes down it, and the
|
||||
// answer says whether Twitch can have it. No attachments:
|
||||
// Twitch chat has none, so one is ignored as Kick's is.
|
||||
let Some(sender) = state.runtime.twitch_sender(&buffer.account_id) else {
|
||||
return (None, Some("that Twitch account is not connected".to_string()));
|
||||
};
|
||||
// A whisper is a conversation of its own and is sent by the official API.
|
||||
if buffer.kind == "dm" {
|
||||
let (respond, answer) = tokio::sync::oneshot::channel();
|
||||
let command = backend::twitch::Command::Act {
|
||||
channel: String::new(),
|
||||
action: backend::twitch::moderation::Action::Whisper { to: buffer.name.trim_start_matches('@').to_string(), text: body.to_string() },
|
||||
target_id: None,
|
||||
respond,
|
||||
};
|
||||
if sender.send(command).is_err() {
|
||||
return (None, Some("that Twitch account is not connected".to_string()));
|
||||
}
|
||||
return match tokio::time::timeout(std::time::Duration::from_secs(20), answer).await {
|
||||
Ok(Ok(Ok(_))) => (Some(ok_node()), None),
|
||||
Ok(Ok(Err(e))) => (None, Some(e)),
|
||||
Ok(Err(_)) => (None, Some("the Twitch connection dropped before the whisper was sent".to_string())),
|
||||
Err(_) => (None, Some("Twitch took too long to take that whisper".to_string())),
|
||||
};
|
||||
}
|
||||
let (respond, answer) = tokio::sync::oneshot::channel();
|
||||
let command = backend::twitch::Command::Say {
|
||||
channel: buffer.name.clone(),
|
||||
text: body.to_string(),
|
||||
reply_to: reply_to_id.map(str::to_string),
|
||||
respond,
|
||||
};
|
||||
if sender.send(command).is_err() {
|
||||
return (None, Some("that Twitch account is not connected".to_string()));
|
||||
}
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(20), answer).await {
|
||||
Ok(Ok(Ok(()))) => (Some(ok_node()), None),
|
||||
Ok(Ok(Err(e))) => (None, Some(e)),
|
||||
Ok(Err(_)) => (None, Some("the Twitch connection dropped before the message was sent".to_string())),
|
||||
Err(_) => (None, Some("Twitch took too long to take that message".to_string())),
|
||||
}
|
||||
}
|
||||
Some(buffer) if buffer.account_id.starts_with("sneedchat:") => {
|
||||
// Sneedchat answers somebody by name rather than by
|
||||
// message id, so a reply needs whoever wrote the message
|
||||
@@ -4095,6 +4243,171 @@ pub async fn dispatch(
|
||||
(Some(serde_json::json!({ "loginId": login_id })), None)
|
||||
}
|
||||
|
||||
// Adding a Twitch account that only reads: no sign-in, nothing to
|
||||
// wait for. Twitch's chat is public, so an anonymous reader sees every
|
||||
// channel it is pointed at, and one that signs in later gets to send.
|
||||
"addTwitchAccount" => {
|
||||
let config = crate::accounts::TwitchAccountConfig {
|
||||
anonymous: true,
|
||||
use_tor: crate::net::route::router().wanted(&crate::net::route::pending_key("twitch")),
|
||||
..Default::default()
|
||||
};
|
||||
match state.accounts.add_twitch(config) {
|
||||
Ok(saved) => {
|
||||
backend::twitch::spawn(state.clone(), saved.clone());
|
||||
(Some(serde_json::json!({ "accountId": saved.account_id() })), None)
|
||||
}
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
}
|
||||
|
||||
// Signing in to Twitch takes place over time - the person approves a
|
||||
// code on twitch.tv - so this answers with an id and the rest is
|
||||
// events: see backend/twitch/login.rs.
|
||||
"startTwitchLogin" => {
|
||||
let login_id = format!("twitch-{}", crate::model::next_message_id());
|
||||
backend::twitch::login::start(state.clone(), login_id.clone());
|
||||
(Some(serde_json::json!({ "loginId": login_id })), None)
|
||||
}
|
||||
|
||||
// The form was closed with a sign-in still waiting.
|
||||
"cancelTwitchLogin" => {
|
||||
if let Some(login_id) = p_str_opt(params, "loginId") {
|
||||
backend::twitch::login::cancel(login_id);
|
||||
}
|
||||
(Some(ok_node()), None)
|
||||
}
|
||||
|
||||
// The window's answer to a request for an integrity token, which
|
||||
// Twitch sometimes demands before it will give a stream's address and
|
||||
// which only a real page on twitch.tv can make. See playback.rs.
|
||||
"provideTwitchIntegrity" => {
|
||||
let Some(request_id) = p_str_opt(params, "requestId") else {
|
||||
return (None, Some("provideTwitchIntegrity requires \"requestId\"".to_string()));
|
||||
};
|
||||
let outcome = match (p_str_opt(params, "token"), params.get("expiration").and_then(|v| v.as_i64())) {
|
||||
(Some(token), Some(expiration)) if !token.is_empty() => Ok((token.to_string(), expiration)),
|
||||
_ => Err(p_str_opt(params, "error").unwrap_or("no token").to_string()),
|
||||
};
|
||||
(Some(serde_json::json!({ "accepted": backend::twitch::playback::provide_integrity(request_id, outcome) })), None)
|
||||
}
|
||||
|
||||
// The `auth-token` of a twitch.tv session, for playing a stream as
|
||||
// the person who signed in (their Turbo, their subscriptions). Sent
|
||||
// here by the window's sign-in page and by nothing else; an empty one
|
||||
// forgets it.
|
||||
// The twitch.tv session the sign-in window saw while the person signed in there, handed to the sign-in
|
||||
// that is waiting for them to approve its code, so one action gives the account both.
|
||||
"setTwitchLoginWebToken" => {
|
||||
let (Some(login_id), Some(token)) = (p_str_opt(params, "loginId"), p_str_opt(params, "token")) else {
|
||||
return (None, Some("setTwitchLoginWebToken requires \"loginId\" and \"token\"".to_string()));
|
||||
};
|
||||
if backend::twitch::login::set_web_token(login_id, token) {
|
||||
(Some(ok_node()), None)
|
||||
} else {
|
||||
(None, Some("no Twitch sign-in is waiting".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
"setTwitchWebToken" => {
|
||||
let Some(account_id) = p_str_opt(params, "accountId") else {
|
||||
return (None, Some("setTwitchWebToken requires \"accountId\"".to_string()));
|
||||
};
|
||||
let token = p_str_opt(params, "token").unwrap_or("").trim().to_string();
|
||||
match state.accounts.mutate_twitch(account_id, |a| a.web_token = token.clone()) {
|
||||
Ok(true) => (Some(ok_node()), None),
|
||||
Ok(false) => (None, Some("no such Twitch account".to_string())),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
}
|
||||
|
||||
// Opens the channels a Twitch account follows: the ones on air now, or
|
||||
// all of them up to two hundred. Joined one at a time by the
|
||||
// connection, which paces them to what Twitch allows; this answers
|
||||
// with how many were asked for.
|
||||
"syncTwitchFollows" => {
|
||||
let Some(account_id) = p_str_opt(params, "accountId") else {
|
||||
return (None, Some("syncTwitchFollows requires \"accountId\"".to_string()));
|
||||
};
|
||||
let Some(cfg) = state.accounts.get_twitch(account_id).filter(|a| a.is_signed_in()) else {
|
||||
return (None, Some("sign in to Twitch from Accounts to see the channels you follow".to_string()));
|
||||
};
|
||||
let live_only = params.get("liveOnly").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
let Some(sender) = state.runtime.twitch_sender(account_id) else {
|
||||
return (None, Some("that Twitch account is not connected".to_string()));
|
||||
};
|
||||
let http = match backend::twitch::client() {
|
||||
Ok(c) => c,
|
||||
Err(e) => return (None, Some(format!("{e:#}"))),
|
||||
};
|
||||
let who = backend::twitch::helix::Caller { root: backend::twitch::helix::API_ROOT, client_id: backend::twitch::auth::CLIENT_ID, token: &cfg.access_token };
|
||||
match backend::twitch::helix::followed(&http, &who, &cfg.user_id, live_only, 200).await {
|
||||
Ok(logins) => {
|
||||
let count = logins.len();
|
||||
for login in logins {
|
||||
let _ = sender.send(backend::twitch::Command::Join(login, None));
|
||||
}
|
||||
(Some(serde_json::json!({ "count": count })), None)
|
||||
}
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
}
|
||||
|
||||
// Something a moderator does to a Twitch channel from a menu: remove a
|
||||
// message, time somebody out, ban them, let them back. The slash
|
||||
// commands do the same through the composer (backend/twitch/moderation.rs).
|
||||
"twitchModerate" => {
|
||||
use backend::twitch::moderation::Action;
|
||||
let (Some(buffer_id), Some(kind)) = (p_str_opt(params, "bufferId"), p_str_opt(params, "action")) else {
|
||||
return (None, Some("twitchModerate requires \"bufferId\" and \"action\"".to_string()));
|
||||
};
|
||||
let Some(buffer) = state.runtime.get_buffer(buffer_id).filter(|b| b.account_id.starts_with("twitch:")) else {
|
||||
return (None, Some("that is not a Twitch channel".to_string()));
|
||||
};
|
||||
let login = p_str_opt(params, "login").unwrap_or("").trim().trim_start_matches('@').to_lowercase();
|
||||
let reason = p_str_opt(params, "reason").unwrap_or("").to_string();
|
||||
let action = match kind {
|
||||
"delete" => Action::Delete { message_id: p_str_opt(params, "messageId").map(str::to_string) },
|
||||
"timeout" => Action::Timeout { user: login, seconds: params.get("seconds").and_then(|v| v.as_u64()).unwrap_or(600), reason },
|
||||
"ban" => Action::Ban { user: login, reason },
|
||||
"unban" => Action::Unban { user: login },
|
||||
"block" => Action::Block { user: login },
|
||||
"unblock" => Action::Unblock { user: login },
|
||||
"shoutout" => Action::Shoutout { user: login },
|
||||
other => return (None, Some(format!("no Twitch moderation action called {other}"))),
|
||||
};
|
||||
let Some(sender) = state.runtime.twitch_sender(&buffer.account_id) else {
|
||||
return (None, Some("that Twitch account is not connected".to_string()));
|
||||
};
|
||||
let (respond, answer) = tokio::sync::oneshot::channel();
|
||||
let command = backend::twitch::Command::Act { channel: buffer.name.clone(), action, target_id: p_str_opt(params, "userId").map(str::to_string), respond };
|
||||
if sender.send(command).is_err() {
|
||||
return (None, Some("that Twitch account is not connected".to_string()));
|
||||
}
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(30), answer).await {
|
||||
Ok(Ok(Ok(done))) => (Some(serde_json::json!({ "done": done })), None),
|
||||
Ok(Ok(Err(e))) => (None, Some(e)),
|
||||
Ok(Err(_)) => (None, Some("the Twitch connection dropped".to_string())),
|
||||
Err(_) => (None, Some("Twitch took too long to answer".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
// A Twitch channel's recent past, from the third-party service the
|
||||
// window was told it may ask (Settings, Twitch). Twitch keeps no
|
||||
// history of its own; see backend/twitch/history.rs.
|
||||
"twitchRecentMessages" => {
|
||||
let Some(buffer_id) = p_str_opt(params, "bufferId") else {
|
||||
return (None, Some("twitchRecentMessages requires \"bufferId\"".to_string()));
|
||||
};
|
||||
let Some(buffer) = state.runtime.get_buffer(buffer_id).filter(|b| b.account_id.starts_with("twitch:")) else {
|
||||
return (None, Some("that is not a Twitch channel".to_string()));
|
||||
};
|
||||
match backend::twitch::history::load(state, &buffer.account_id, &buffer.name, backend::twitch::history::ROOT).await {
|
||||
Ok(read) => (Some(serde_json::json!({ "read": read })), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
}
|
||||
|
||||
// The code Steam asked for, for a sign-in that is waiting on one.
|
||||
"submitSteamGuardCode" => {
|
||||
let (Some(login_id), Some(code)) = (p_str_opt(params, "loginId"), p_str_opt(params, "code")) else {
|
||||
@@ -4594,6 +4907,12 @@ pub async fn dispatch(
|
||||
let Some(buffer_id) = p_str_opt(params, "bufferId") else {
|
||||
return (None, Some("refreshKickStream requires \"bufferId\"".to_string()));
|
||||
};
|
||||
// Twitch's channels answer the same question, in the same event.
|
||||
if buffer_id.starts_with("twitch:") {
|
||||
let (state, buffer_id) = (state.clone(), buffer_id.to_string());
|
||||
tokio::spawn(async move { backend::twitch::stream::refresh_one(&state, &buffer_id).await });
|
||||
return (Some(ok_node()), None);
|
||||
}
|
||||
if state.runtime.kick_channel(buffer_id).is_none() {
|
||||
return (None, Some("that is not a Kick channel".to_string()));
|
||||
}
|
||||
@@ -4682,6 +5001,16 @@ pub async fn dispatch(
|
||||
};
|
||||
}
|
||||
}
|
||||
if channel_account(state, buffer_id).starts_with("twitch:") {
|
||||
// Twitch's choices are named by ids of their own, which the window hands back as text.
|
||||
let Some(choice) = p_str_opt(params, "answerId") else {
|
||||
return (None, Some("votePoll requires \"answerId\"".to_string()));
|
||||
};
|
||||
return match backend::twitch::points::vote_in_buffer(state, backend::twitch::gql::ROOT, buffer_id, choice).await {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(e)),
|
||||
};
|
||||
}
|
||||
let Some(channel) = state.runtime.kick_channel(buffer_id) else {
|
||||
return (None, Some("that is not a Kick channel".to_string()));
|
||||
};
|
||||
@@ -4709,6 +5038,12 @@ pub async fn dispatch(
|
||||
return (None, Some("betPrediction requires \"optionId\"".to_string()));
|
||||
};
|
||||
let amount = params.get("amount").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
if channel_account(state, buffer_id).starts_with("twitch:") {
|
||||
return match backend::twitch::points::bet_in_buffer(state, backend::twitch::gql::ROOT, buffer_id, option_id, amount.max(0) as u64).await {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(e)),
|
||||
};
|
||||
}
|
||||
let Some(channel) = state.runtime.kick_channel(buffer_id) else {
|
||||
return (None, Some("that is not a Kick channel".to_string()));
|
||||
};
|
||||
@@ -4785,6 +5120,58 @@ pub async fn dispatch(
|
||||
(Some(serde_json::json!({ "watching": live_id.is_some() })), None)
|
||||
}
|
||||
|
||||
// A Twitch channel's points balance, as the person's twitch.tv session; only when asked for (see
|
||||
// backend/twitch/points.rs).
|
||||
"twitchPoints" => {
|
||||
let Some(buffer_id) = p_str_opt(params, "bufferId") else {
|
||||
return (None, Some("twitchPoints requires \"bufferId\"".to_string()));
|
||||
};
|
||||
match backend::twitch::points::points_in_buffer(state, backend::twitch::gql::ROOT, buffer_id).await {
|
||||
Ok(p) => (Some(serde_json::json!({ "points": p.balance, "name": p.name })), None),
|
||||
Err(e) => (None, Some(e)),
|
||||
}
|
||||
}
|
||||
|
||||
// The window is playing a Twitch channel's video: told once a minute, and passed on to Twitch as the
|
||||
// player on twitch.tv does it (see backend/twitch/watch.rs).
|
||||
"reportTwitchWatching" => {
|
||||
let Some(buffer_id) = p_str_opt(params, "bufferId") else {
|
||||
return (None, Some("reportTwitchWatching requires \"bufferId\"".to_string()));
|
||||
};
|
||||
match backend::twitch::watch::report(state, backend::twitch::gql::ROOT, backend::twitch::watch::PAGE_ROOT, buffer_id).await {
|
||||
Ok(sent) => (Some(serde_json::json!({ "sent": sent })), None),
|
||||
Err(e) => (None, Some(e)),
|
||||
}
|
||||
}
|
||||
|
||||
// What a Twitch channel trades its points for, and spending them on one: the panel over the chat lists
|
||||
// them and a button redeems (see backend/twitch/points.rs).
|
||||
"listTwitchRewards" => {
|
||||
let Some(buffer_id) = p_str_opt(params, "bufferId") else {
|
||||
return (None, Some("listTwitchRewards requires \"bufferId\"".to_string()));
|
||||
};
|
||||
match backend::twitch::points::rewards_in_buffer(state, backend::twitch::gql::ROOT, buffer_id).await {
|
||||
Ok((rewards, points)) => {
|
||||
let list: Vec<serde_json::Value> = rewards
|
||||
.into_iter()
|
||||
.map(|r| serde_json::json!({ "id": r.id, "title": r.title, "cost": r.cost, "description": r.prompt, "enabled": r.enabled, "paused": r.paused, "needsInput": r.needs_input, "color": r.color, "image": r.image, "unavailable": r.unavailable, "choices": r.choices.iter().map(|c| serde_json::json!({ "id": c.id, "label": c.label, "image": c.image, "group": c.group, "groupImage": c.group_image })).collect::<Vec<_>>() }))
|
||||
.collect();
|
||||
(Some(serde_json::json!({ "rewards": list, "points": points.balance, "name": points.name })), None)
|
||||
}
|
||||
Err(e) => (None, Some(e)),
|
||||
}
|
||||
}
|
||||
|
||||
"redeemTwitchReward" => {
|
||||
let (Some(buffer_id), Some(reward_id)) = (p_str_opt(params, "bufferId"), p_str_opt(params, "rewardId")) else {
|
||||
return (None, Some("redeemTwitchReward requires \"bufferId\" and \"rewardId\"".to_string()));
|
||||
};
|
||||
match backend::twitch::points::redeem_in_buffer(state, backend::twitch::gql::ROOT, buffer_id, reward_id, p_str_opt(params, "input"), p_str_opt(params, "choice")).await {
|
||||
Ok(p) => (Some(serde_json::json!({ "points": p.balance })), None),
|
||||
Err(e) => (None, Some(e)),
|
||||
}
|
||||
}
|
||||
|
||||
"kickPoints" => {
|
||||
let Some(buffer_id) = p_str_opt(params, "bufferId") else {
|
||||
return (None, Some("kickPoints requires \"bufferId\"".to_string()));
|
||||
@@ -5275,6 +5662,15 @@ pub async fn dispatch(
|
||||
backend::matrix::open_dm(state, account_id, user_id, nick).await
|
||||
}
|
||||
}
|
||||
// A Twitch whisper is a conversation with a login: it exists as soon as it is named.
|
||||
"twitch" => {
|
||||
let who = if nick.is_empty() { user_id } else { nick }.trim().trim_start_matches('@').to_lowercase();
|
||||
if who.is_empty() || !who.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
|
||||
Err(anyhow::anyhow!("no login to whisper to"))
|
||||
} else {
|
||||
Ok(state.runtime.ensure_buffer(state, account_id, &backend::twitch::chat::whisper_buffer(&who), "dm").id)
|
||||
}
|
||||
}
|
||||
other => Err(anyhow::anyhow!("{other} has no direct messages")),
|
||||
};
|
||||
match opened {
|
||||
|
||||
+118
-4
@@ -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,9 @@ 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>>,
|
||||
@@ -1044,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 {
|
||||
@@ -1065,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()),
|
||||
@@ -1085,6 +1103,8 @@ 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()),
|
||||
@@ -1231,6 +1251,11 @@ impl Runtime {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1630,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);
|
||||
@@ -1646,7 +1676,7 @@ 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,
|
||||
@@ -2020,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()
|
||||
}
|
||||
@@ -2038,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
|
||||
@@ -3797,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()
|
||||
}
|
||||
@@ -3875,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()
|
||||
}
|
||||
@@ -4608,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(),
|
||||
@@ -4757,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 })),
|
||||
@@ -5501,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, steam_links, steam_requests, 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,
|
||||
|
||||
+46
-12
@@ -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, []);
|
||||
}
|
||||
@@ -443,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,
|
||||
@@ -469,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
|
||||
@@ -579,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])?;
|
||||
@@ -748,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())
|
||||
};
|
||||
@@ -758,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(),
|
||||
@@ -782,6 +815,7 @@ impl Store {
|
||||
badges,
|
||||
html: row.get("html")?,
|
||||
components,
|
||||
moderated,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -789,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",
|
||||
@@ -865,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",
|
||||
@@ -888,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",
|
||||
@@ -929,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"
|
||||
@@ -975,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 ?"
|
||||
@@ -1236,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",
|
||||
@@ -1301,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))?;
|
||||
@@ -1313,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",
|
||||
)?;
|
||||
|
||||
Reference in New Issue
Block a user