33 Commits
Author SHA1 Message Date
SalastilandClaude Sonnet 5.5 e9b4470128 1.2.0
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 21:54:08 -04:00
SalastilandClaude Sonnet 5.5 6d246264dd Twitch: report watching once a minute while the stage plays, so channel points build
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 21:04:28 -04:00
SalastilandClaude Sonnet 5.5 7d726839d3 Twitch emotes: one section per owner with their pictures, no duplicate cells, a changed emote listed with its channel
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 20:44:14 -04:00
SalastilandClaude Sonnet 5.5 25a0c07770 Twitch emote picker: usable emotes first; the channel's picture or Twitch's mark on the rail
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 19:58:35 -04:00
SalastilandClaude Sonnet 5.5 d348ccfb59 Twitch: drop BetterTTV, FrankerFaceZ and 7TV; the picker shows the channel's whole emote set with the unusable ones greyed out
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 19:52:53 -04:00
SalastilandClaude Sonnet 5.5 1bb6e3bfd4 Twitch: the picker reads the account's emotes again when stale, and your own line shows each emote as a picture
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 19:42:44 -04:00
SalastilandClaude Sonnet 5.5 81f3aa0f98 Twitch emote rewards: offer the channel's unlockable emotes the account lacks, change only emotes it has; warm the integrity token when rewards open
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 19:25:19 -04:00
SalastilandClaude Sonnet 5.5 84c15e8bf8 Twitch: integrity tokens are made as the account for the account's own calls; emote choices as pictures
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 18:58:26 -04:00
SalastilandClaude Sonnet 5.5 c4f52e5216 Twitch: Highlight My Message and the emote unlocks as rewards; the account's emotes are read again so unlocks appear and lapse
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 18:47:32 -04:00
SalastilandClaude Sonnet 5.5 f4533ff9a7 Twitch: list a channel's rewards and redeem them, as the twitch.tv session
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 18:23:00 -04:00
SalastilandClaude Sonnet 5.5 34d15e9315 Twitch: no bonus claiming (out of scope); the points plaque shows the balance only
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 18:08:31 -04:00
SalastilandClaude Sonnet 5.5 765f6d07ea Twitch: the live points test can bet on a real prediction with nothing at stake
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 18:03:59 -04:00
SalastilandClaude Sonnet 5.5 f1aec36f97 Twitch: a message highlighted with channel points is not a mention and notifies nobody
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 17:17:54 -04:00
SalastilandClaude Sonnet 5.5 71d18c8cb4 Twitch: vote in polls, bet on predictions, read the points balance and claim the bonus, as the twitch.tv session
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 17:00:03 -04:00
SalastilandClaude Sonnet 5.5 bd031001ab Twitch: a refusal that is about the account itself does not tell the person to sign in again
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 16:46:44 -04:00
SalastilandClaude Sonnet 5.5 28481f39e3 IRC: a channel's modes are explained in words when it is joined and when one changes
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 16:20:17 -04:00
SalastilandClaude Sonnet 5.5 26a2339ee6 Twitch: Channels and Direct Messages as fixed headings; room mode changes, removals and acts as cards
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 15:03:49 -04:00
SalastilandClaude Sonnet 5.5 a286f45ee8 Twitch: a whisper conversation is named @login so it cannot be the same buffer as that person's channel; a timeout or ban is not said twice
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 14:48:38 -04:00
SalastilandClaude Sonnet 5.5 6089672200 Twitch: moderation, whispers, shoutouts and blocking are asked for in the one sign-in
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 14:02:36 -04:00
SalastilandClaude Sonnet 5.5 c9b21e960b Twitch: a picked emote is sent as its word; its picker cell is named by the token the box draws
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 12:05:00 -04:00
SalastilandClaude Sonnet 5.5 278f0dd323 Twitch: a sign-in can bring the twitch.tv session with it
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 10:56:28 -04:00
SalastilandClaude Sonnet 5.5 f0cac1df03 Twitch: a signed-in account opens the channels it follows the first time it connects
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 10:23:07 -04:00
SalastilandClaude Sonnet 5.5 3c26bc9bfe Twitch: raids, polls and predictions as they happen, live 7TV and BetterTTV emote changes, block and shoutout
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 09:56:47 -04:00
SalastilandClaude Sonnet 5.5 4c30be62de Twitch: emotes and badges as pictures, third-party emote sets, cheers, moderation by menu and slash command, whispers, rights per channel
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 09:15:00 -04:00
SalastilandClaude Sonnet 5.5 6635af75cf Twitch: what is on air, and the address of its video
A channel's state (live, title, category, viewers, avatar) is read from Twitch's GraphQL in one batch for
every open channel each minute and said as the same stream event Kick's use, so the header chip, the Watch
button and the player are the code Kick already has. The playlist address is built from the player's own
token query, renewed before its twenty minutes are up; what the token says (ads, restricted renditions,
subscribers only, blocked here) is carried. An integrity token, if Twitch ever demands one, is asked for from
the window. A Twitch website session can be attached to an account for video as the person, and followed
channels can be opened by Helix.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 08:53:24 -04:00
SalastilandClaude Sonnet 5.5 cd8a44cbf9 Twitch: Helix sending with IRC as the fallback, recent messages on request
A message goes by Helix when the account agreed to user:write:chat (it says whether Twitch sent it and why
not, and gives its id, which the shown copy then carries) and falls back to IRC when Helix cannot take it.
A reply names its parent to Twitch even when that message has left the scrollback. A channel's recent
past can be read from the third-party recent-messages service, only when asked, never making the roster.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 00:43:21 -04:00
SalastilandClaude Sonnet 5.5 7bd80939a8 Twitch: chat is read and sent over Twitch's IRC, channels opened and remembered
irc.rs reads a line (tags, prefix, command, parameters); chat.rs turns lines into messages (colour, badges,
emote tokens by code point, replies with their thread), cards (subs, gifts told once per batch, raids,
announcements), removed messages (CLEARMSG, CLEARCHAT marks a person's messages), room modes in the header,
a roster counted without a cap; connect.rs is the connection: greeting, paced joins, a channel Twitch never
answers for is said not to exist, say with the 500-character and rate limits, local echo, reconnect.
Rounds 2 and 3 of #55, without history, Helix sending or blocking yet.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 00:32:27 -04:00
SalastilandClaude Sonnet 5.5 33dfca4183 Twitch: an account, signed in by the device code flow, kept good
Accounts (signed in, or an anonymous reader), their tokens in accounts.toml, the device flow against
id.twitch.tv with moho's public client (no secret), renewal with the single-use refresh token written
before anything else, validation, revocation on removal, and a connection that keeps the sign-in good and
stops in AuthFailed when Twitch will not take it. Round 1 of #55.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 00:13:32 -04:00
SalastilandClaude Sonnet 5.5 4c17be082b Kick: the points balance is pushed as it changes, and badges_v2 arrive as pictures
The watch session also follows private-channelpoints-<user id>, where Kick says
ChannelPointsUpdatedEvent with the channel and the new balance, and tells the window.
badges_v2 are read - the ones the person chose to wear, ranked highest first as Kick's own
apps rank them - and carried beside the role badges as pictures.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 23:11:25 -04:00
SalastilandClaude Sonnet 5.5 43f5e94c18 Kick: fix the order of the roster test's assertions
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 21:54:55 -04:00
SalastilandClaude Sonnet 5.5 41e93b8f49 Kick: the count of chatters keeps rising past the list's length
The roster is the most recent 500 speakers (it was 200) and is sent whole each time somebody new joins it;
the number of different people who have spoken is counted apart, never cut off, and sent beside it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 21:51:34 -04:00
SalastilandClaude Sonnet 5.5 bb318cf7de Kick: replies carry their thread, removed messages are kept and marked, the silent chatrooms.<id> subscription is gone
A reply names the message its chain began with (thread_parent_id, or the quoted message's own place in a
chain when it comes out of history, whose metadata is a string of JSON and was never read for its quote).
A MessageDeletedEvent now marks the message instead of deleting it, with aiModerated and violatedRules.
Six minutes on a 60,000-viewer channel delivered nothing on chatrooms.<room>; it is no longer asked for,
and predictions-channel-<id> is now cancelled with the channel like the rest.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 21:17:29 -04:00
SalastilandClaude Sonnet 5.5 9f0ca86fdb Kick: the stream address comes from the playback-url endpoint, with its expiry, renewed before it runs out
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 19:57:02 -04:00
40 changed files with 9013 additions and 95 deletions
Generated
+1 -1
View File
@@ -3686,7 +3686,7 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nobilis"
version = "1.1.0"
version = "1.2.0"
dependencies = [
"aes-gcm",
"anyhow",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nobilis"
version = "1.1.0"
version = "1.2.0"
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"
+286 -4
View File
@@ -411,6 +411,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 +508,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 +527,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 +539,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 +550,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 +585,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 +774,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 +961,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 +992,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 +1075,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 +1243,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 +1279,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 +1401,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 +1442,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 +1482,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 +1531,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 +1579,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 +1666,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 +1817,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());
}
}
+1
View File
@@ -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(),
+2 -1
View File
@@ -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
+27 -6
View File
@@ -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 {
+1
View File
@@ -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;
+137
View File
@@ -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, &params.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
View File
@@ -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));
}
}
}
+2 -2
View File
@@ -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
View File
@@ -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]
+39 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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)));
+69 -1
View File
@@ -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");
+2 -2
View File
@@ -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,
+1
View File
@@ -16,3 +16,4 @@ pub mod kick;
pub mod matrix;
pub mod sneedchat;
pub mod steam;
pub mod twitch;
+436
View File
@@ -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");
}
}
+118
View File
@@ -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
+207
View File
@@ -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);
}
}
+82
View File
@@ -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 })
}
+538
View File
@@ -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());
}
}
+129
View File
@@ -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());
}
}
+202
View File
@@ -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 "));
}
}
+277
View File
@@ -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");
}
}
+64
View File
@@ -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))))
}
+433
View File
@@ -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();
}
}
+471
View File
@@ -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();
}
}
+746
View File
@@ -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"));
}
}
+695
View File
@@ -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();
}
}
+258
View File
@@ -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")));
}
}
+160
View File
@@ -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
View File
@@ -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 },
+3
View File
@@ -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.
+40
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
)?;