Read Kick
A channel is a streamer, not a room you are a member of, and that shapes the whole backend: there is no join to perform, and naming a handle is all there is to it. Kick's chat is also public, so an account with no credential is a working reader - signing in buys sending and knowing what you are subscribed to, and nothing else. The token is optional throughout rather than a precondition. One websocket carries every channel. Pusher multiplexes, so watching twenty streamers costs one socket and joining one more is a subscribe frame rather than a reconnect. Emotes come in three sets - the streamer's own and Kick's two global ones - and the streamer's are half subscriber-only. Seeing and using those are different permissions and are treated as such: everybody sees every emote, because the id is in the message and a chat that showed newcomers half raw tokens would be unreadable, while the picker only offers what this account may actually send. "We could not ask" resolves to locked, because the cost of being wrong that way is a greyed-out emote. The doing-things events needed measuring rather than guessing, against a live subathon. Four subscriptions, not one: `.v2` carries speech, the bare room carries rewards, gifted subs arrive on an older `chatroom_<id>` name entirely, and going live is on `channel.<id>`. Kick then reports one subscription three times across three of them, so events are matched by exact name - a suffix test called ChannelSubscriptionEvent a SubscriptionEvent and announced the same person twice. Two id spaces, so two maps. A chatroom id and a channel id are unrelated numbers that happen to be equal for the oldest few channels, which is exactly the coincidence that makes one map look correct. Verified against live channels throughout: 734 chat lines and 2 action lines with no duplicates, 488 emotes rendering, 13-of-160 locked signed out against 0 signed in, and a real redemption arriving as "KotBarsikFT redeemed CAT PATS". Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
+130
-22
@@ -174,6 +174,33 @@ impl MatrixAccountConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Kick account shape.
|
||||
///
|
||||
/// The token is optional, which is the unusual part and is deliberate: Kick's
|
||||
/// chat is public, so an account with no credential is a working reader of
|
||||
/// every channel it is pointed at. Signing in adds sending and the
|
||||
/// subscription check, and nothing else - see backend/kick's module doc.
|
||||
///
|
||||
/// `channels` holds handles (the part after kick.com/), not ids. A streamer's
|
||||
/// numeric ids are Kick's business and are looked up fresh on every connect;
|
||||
/// the handle is what somebody typed and what they would recognise if they
|
||||
/// ever opened this file.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||
pub struct KickAccountConfig {
|
||||
/// Who this is on Kick, or a placeholder for a signed-out reader.
|
||||
pub username: String,
|
||||
#[serde(default)]
|
||||
pub token: Option<String>,
|
||||
#[serde(default)]
|
||||
pub channels: Vec<String>,
|
||||
}
|
||||
|
||||
impl KickAccountConfig {
|
||||
pub fn account_id(&self) -> String {
|
||||
format!("kick:{}", self.username)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||
struct AccountsFile {
|
||||
#[serde(default, rename = "account")]
|
||||
@@ -184,6 +211,8 @@ struct AccountsFile {
|
||||
sockchat: Vec<SockChatAccountConfig>,
|
||||
#[serde(default, rename = "matrix_account")]
|
||||
matrix: Vec<MatrixAccountConfig>,
|
||||
#[serde(default, rename = "kick_account")]
|
||||
kick: Vec<KickAccountConfig>,
|
||||
}
|
||||
|
||||
/// Account/credential persistence at ~/.config/nobilis/accounts.toml,
|
||||
@@ -196,11 +225,12 @@ pub struct AccountStore {
|
||||
discord: Mutex<HashMap<String, DiscordAccountConfig>>,
|
||||
sockchat: Mutex<HashMap<String, SockChatAccountConfig>>,
|
||||
matrix: Mutex<HashMap<String, MatrixAccountConfig>>,
|
||||
kick: Mutex<HashMap<String, KickAccountConfig>>,
|
||||
}
|
||||
|
||||
impl AccountStore {
|
||||
pub fn open(path: PathBuf) -> Result<Self> {
|
||||
let (irc, discord, sockchat, matrix) = if path.exists() {
|
||||
let (irc, discord, sockchat, matrix, kick) = if path.exists() {
|
||||
let text = std::fs::read_to_string(&path)
|
||||
.with_context(|| format!("reading {}", path.display()))?;
|
||||
let file: AccountsFile = toml::from_str(&text)
|
||||
@@ -209,11 +239,12 @@ impl AccountStore {
|
||||
let discord = file.discord.into_iter().map(|a| (a.account_id(), a)).collect();
|
||||
let sockchat = file.sockchat.into_iter().map(|a| (a.account_id(), a)).collect();
|
||||
let matrix = file.matrix.into_iter().map(|a| (a.account_id(), a)).collect();
|
||||
(irc, discord, sockchat, matrix)
|
||||
let kick = file.kick.into_iter().map(|a| (a.account_id(), a)).collect();
|
||||
(irc, discord, sockchat, matrix, kick)
|
||||
} else {
|
||||
(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), sockchat: Mutex::new(sockchat), matrix: Mutex::new(matrix) })
|
||||
Ok(Self { path, irc: Mutex::new(irc), discord: Mutex::new(discord), sockchat: Mutex::new(sockchat), matrix: Mutex::new(matrix), kick: Mutex::new(kick) })
|
||||
}
|
||||
|
||||
/// Callers already hold whichever map they just mutated - the others
|
||||
@@ -225,12 +256,14 @@ impl AccountStore {
|
||||
discord: &HashMap<String, DiscordAccountConfig>,
|
||||
sockchat: &HashMap<String, SockChatAccountConfig>,
|
||||
matrix: &HashMap<String, MatrixAccountConfig>,
|
||||
kick: &HashMap<String, KickAccountConfig>,
|
||||
) -> Result<()> {
|
||||
let file = AccountsFile {
|
||||
irc: irc.values().cloned().collect(),
|
||||
discord: discord.values().cloned().collect(),
|
||||
sockchat: sockchat.values().cloned().collect(),
|
||||
matrix: matrix.values().cloned().collect(),
|
||||
kick: kick.values().cloned().collect(),
|
||||
};
|
||||
let text = toml::to_string_pretty(&file)?;
|
||||
// Atomic write-temp-then-rename, same spirit as libpurple's periodic
|
||||
@@ -268,7 +301,7 @@ impl AccountStore {
|
||||
return Ok(None);
|
||||
}
|
||||
irc.insert(id, config.clone());
|
||||
self.persist(&irc, &self.discord.lock().unwrap(), &self.sockchat.lock().unwrap(), &self.matrix.lock().unwrap())?;
|
||||
self.persist(&irc, &self.discord.lock().unwrap(), &self.sockchat.lock().unwrap(), &self.matrix.lock().unwrap(), &self.kick.lock().unwrap())?;
|
||||
Ok(Some(config))
|
||||
}
|
||||
|
||||
@@ -289,7 +322,7 @@ impl AccountStore {
|
||||
let id = config.account_id();
|
||||
let mut discord = self.discord.lock().unwrap();
|
||||
discord.insert(id, config.clone());
|
||||
self.persist(&self.irc.lock().unwrap(), &discord, &self.sockchat.lock().unwrap(), &self.matrix.lock().unwrap())?;
|
||||
self.persist(&self.irc.lock().unwrap(), &discord, &self.sockchat.lock().unwrap(), &self.matrix.lock().unwrap(), &self.kick.lock().unwrap())?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
@@ -307,7 +340,7 @@ impl AccountStore {
|
||||
let id = config.account_id();
|
||||
let mut sockchat = self.sockchat.lock().unwrap();
|
||||
sockchat.insert(id, config.clone());
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &sockchat, &self.matrix.lock().unwrap())?;
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &sockchat, &self.matrix.lock().unwrap(), &self.kick.lock().unwrap())?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
@@ -326,10 +359,44 @@ impl AccountStore {
|
||||
let id = config.account_id();
|
||||
let mut matrix = self.matrix.lock().unwrap();
|
||||
matrix.insert(id, config.clone());
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sockchat.lock().unwrap(), &matrix)?;
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sockchat.lock().unwrap(), &matrix, &self.kick.lock().unwrap())?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn get_kick(&self, account_id: &str) -> Option<KickAccountConfig> {
|
||||
self.kick.lock().unwrap().get(account_id).cloned()
|
||||
}
|
||||
|
||||
pub fn all_kick(&self) -> Vec<KickAccountConfig> {
|
||||
self.kick.lock().unwrap().values().cloned().collect()
|
||||
}
|
||||
|
||||
/// 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, config: KickAccountConfig) -> Result<KickAccountConfig> {
|
||||
let id = config.account_id();
|
||||
let mut kick = self.kick.lock().unwrap();
|
||||
kick.insert(id, config.clone());
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sockchat.lock().unwrap(), &self.matrix.lock().unwrap(), &kick)?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Remembers which streamers this account watches.
|
||||
///
|
||||
/// Persisted rather than rebuilt from open buffers, for the same reason
|
||||
/// an IRC autojoin list is: a channel you went and found should still be
|
||||
/// there tomorrow, and nothing else in the daemon knows you wanted it.
|
||||
pub fn set_kick_channels(&self, account_id: &str, channels: Vec<String>) -> Result<bool> {
|
||||
let mut kick = self.kick.lock().unwrap();
|
||||
let Some(config) = kick.get_mut(account_id) else { return Ok(false) };
|
||||
if config.channels == channels {
|
||||
return Ok(false);
|
||||
}
|
||||
config.channels = channels;
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sockchat.lock().unwrap(), &self.matrix.lock().unwrap(), &kick)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Called after a re-login (expired/rejected access_token) refreshes
|
||||
/// the session - device_id should normally be unchanged (the whole
|
||||
/// point of passing it back into backend/matrix/auth.rs::login is
|
||||
@@ -342,7 +409,7 @@ impl AccountStore {
|
||||
Some(a) => {
|
||||
a.access_token = access_token.to_string();
|
||||
a.device_id = device_id.to_string();
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sockchat.lock().unwrap(), &matrix)?;
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sockchat.lock().unwrap(), &matrix, &self.kick.lock().unwrap())?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -356,7 +423,7 @@ impl AccountStore {
|
||||
None => Ok(false),
|
||||
Some(a) => {
|
||||
a.next_batch = Some(next_batch.to_string());
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sockchat.lock().unwrap(), &matrix)?;
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sockchat.lock().unwrap(), &matrix, &self.kick.lock().unwrap())?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -372,7 +439,7 @@ impl AccountStore {
|
||||
None => Ok(false),
|
||||
Some(a) => {
|
||||
a.rooms = rooms;
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &sockchat, &self.matrix.lock().unwrap())?;
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &sockchat, &self.matrix.lock().unwrap(), &self.kick.lock().unwrap())?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -390,7 +457,7 @@ impl AccountStore {
|
||||
Some(a) => {
|
||||
a.tor_mode = tor_mode;
|
||||
a.proxy = proxy;
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &sockchat, &self.matrix.lock().unwrap())?;
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &sockchat, &self.matrix.lock().unwrap(), &self.kick.lock().unwrap())?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -405,7 +472,7 @@ impl AccountStore {
|
||||
None => Ok(false),
|
||||
Some(a) => {
|
||||
a.user_id = Some(user_id);
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &sockchat, &self.matrix.lock().unwrap())?;
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &sockchat, &self.matrix.lock().unwrap(), &self.kick.lock().unwrap())?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -415,27 +482,34 @@ impl AccountStore {
|
||||
{
|
||||
let mut irc = self.irc.lock().unwrap();
|
||||
if irc.remove(account_id).is_some() {
|
||||
self.persist(&irc, &self.discord.lock().unwrap(), &self.sockchat.lock().unwrap(), &self.matrix.lock().unwrap())?;
|
||||
self.persist(&irc, &self.discord.lock().unwrap(), &self.sockchat.lock().unwrap(), &self.matrix.lock().unwrap(), &self.kick.lock().unwrap())?;
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut discord = self.discord.lock().unwrap();
|
||||
if discord.remove(account_id).is_some() {
|
||||
self.persist(&self.irc.lock().unwrap(), &discord, &self.sockchat.lock().unwrap(), &self.matrix.lock().unwrap())?;
|
||||
self.persist(&self.irc.lock().unwrap(), &discord, &self.sockchat.lock().unwrap(), &self.matrix.lock().unwrap(), &self.kick.lock().unwrap())?;
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut sockchat = self.sockchat.lock().unwrap();
|
||||
if sockchat.remove(account_id).is_some() {
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &sockchat, &self.matrix.lock().unwrap())?;
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &sockchat, &self.matrix.lock().unwrap(), &self.kick.lock().unwrap())?;
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
let mut matrix = self.matrix.lock().unwrap();
|
||||
if matrix.remove(account_id).is_some() {
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sockchat.lock().unwrap(), &matrix)?;
|
||||
{
|
||||
let mut matrix = self.matrix.lock().unwrap();
|
||||
if matrix.remove(account_id).is_some() {
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sockchat.lock().unwrap(), &matrix, &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.sockchat.lock().unwrap(), &self.matrix.lock().unwrap(), &kick)?;
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(false)
|
||||
@@ -478,7 +552,7 @@ impl AccountStore {
|
||||
None => Ok(false),
|
||||
Some(a) => {
|
||||
a.avatar_url = Some(url.to_string());
|
||||
self.persist(&self.irc.lock().unwrap(), &discord, &self.sockchat.lock().unwrap(), &self.matrix.lock().unwrap())?;
|
||||
self.persist(&self.irc.lock().unwrap(), &discord, &self.sockchat.lock().unwrap(), &self.matrix.lock().unwrap(), &self.kick.lock().unwrap())?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -503,7 +577,7 @@ impl AccountStore {
|
||||
None => Ok(false),
|
||||
Some(a) => {
|
||||
a.display_name = if name.is_empty() { None } else { Some(name.to_string()) };
|
||||
self.persist(&self.irc.lock().unwrap(), &discord, &self.sockchat.lock().unwrap(), &self.matrix.lock().unwrap())?;
|
||||
self.persist(&self.irc.lock().unwrap(), &discord, &self.sockchat.lock().unwrap(), &self.matrix.lock().unwrap(), &self.kick.lock().unwrap())?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -536,7 +610,7 @@ impl AccountStore {
|
||||
None => Ok(false),
|
||||
Some(a) => {
|
||||
f(a);
|
||||
self.persist(&irc, &self.discord.lock().unwrap(), &self.sockchat.lock().unwrap(), &self.matrix.lock().unwrap())?;
|
||||
self.persist(&irc, &self.discord.lock().unwrap(), &self.sockchat.lock().unwrap(), &self.matrix.lock().unwrap(), &self.kick.lock().unwrap())?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -621,6 +695,40 @@ pub fn sockchat_account_to_json(a: &SockChatAccountConfig, state: &str) -> Accou
|
||||
}
|
||||
}
|
||||
|
||||
/// Kick, like the others, has none of IRC's autojoin/SASL/NickServ concepts.
|
||||
///
|
||||
/// `has_password` is the one field carrying real information here, and it is
|
||||
/// the answer to a question a Kick account genuinely has both answers to:
|
||||
/// whether this one is signed in. A signed-out account is not broken or
|
||||
/// half-configured - it reads every channel it watches - so the client shows
|
||||
/// it as an ordinary connected account and only sending says otherwise.
|
||||
pub fn kick_account_to_json(a: &KickAccountConfig, state: &str) -> Account {
|
||||
Account {
|
||||
id: a.account_id(),
|
||||
service: "kick".to_string(),
|
||||
status: "online".to_string(),
|
||||
display_name: a.username.clone(),
|
||||
state: state.to_string(),
|
||||
// The watched channels are deliberately not reported as `autojoin`.
|
||||
// That field is IRC's comma-separated string with IRC's meaning, and
|
||||
// the client offers an edit box for it; a Kick channel list is added
|
||||
// to by watching a streamer and taken from by closing the buffer.
|
||||
autojoin: String::new(),
|
||||
has_nickserv_password: false,
|
||||
sasl_enabled: false,
|
||||
sasl_username: String::new(),
|
||||
allow_plaintext_sasl: false,
|
||||
ssl: true,
|
||||
has_password: a.token.as_deref().is_some_and(|t| !t.is_empty()),
|
||||
avatar_url: None,
|
||||
sockchat_rooms: Vec::new(),
|
||||
tor_mode: None,
|
||||
tor_proxy: None,
|
||||
use_tor: false,
|
||||
has_key_backup: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Matrix has none of IRC's autojoin/SASL/NickServ concepts either - same
|
||||
/// zero-value convention as discord_account_to_json/sockchat_account_to_json.
|
||||
/// `has_key_backup` comes from the caller (Runtime::list_accounts) rather
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
//! Kick's HTTP surface: everything that is a request rather than a stream.
|
||||
//!
|
||||
//! Kick has two APIs, and this uses the older one on purpose.
|
||||
//!
|
||||
//! The newer `api.kick.com/public/v1` is OAuth 2.1 with PKCE, which is the
|
||||
//! right shape for a service integration and the wrong shape for a chat
|
||||
//! client: it requires a client id registered in Kick's developer dashboard,
|
||||
//! so every person running moho would have to go and create an application
|
||||
//! before they could read a chat that is already public to anybody with a
|
||||
//! browser. The endpoints under `kick.com/api/v2` are what kick.com's own
|
||||
//! pages call, need no registration, and answer the four questions this
|
||||
//! backend actually has - who is this streamer, what emotes do they have, am
|
||||
//! I subscribed, and please post this line.
|
||||
//!
|
||||
//! Two of those four need no credential at all, which is worth stating as a
|
||||
//! property rather than an accident: a channel's chat and its emote table are
|
||||
//! public, so an account with no token still reads. Only sending and the
|
||||
//! subscription check are authenticated, and both fail with the server's own
|
||||
//! message rather than being papered over.
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use serde::Deserialize;
|
||||
use std::time::Duration;
|
||||
|
||||
pub const API_ROOT: &str = "https://kick.com";
|
||||
|
||||
/// Presented on every request.
|
||||
///
|
||||
/// Kick sits behind Cloudflare, which answers a request with no plausible
|
||||
/// browser identity with a challenge page rather than JSON. This is not an
|
||||
/// attempt to hide what moho is - it identifies itself in the same string -
|
||||
/// only to look like the HTTP client Cloudflare expects rather than the
|
||||
/// default `reqwest/0.12`, which is filtered on sight.
|
||||
pub const USER_AGENT: &str =
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 moho";
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
pub fn client() -> Result<reqwest::Client> {
|
||||
reqwest::Client::builder()
|
||||
.user_agent(USER_AGENT)
|
||||
.timeout(TIMEOUT)
|
||||
.build()
|
||||
.context("building the Kick HTTP client")
|
||||
}
|
||||
|
||||
/// A streamer's channel, as much of it as this backend needs.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Channel {
|
||||
pub id: u64,
|
||||
/// The room the websocket subscribes to. Not the same number as `id`
|
||||
/// in general, even though it often is for older channels.
|
||||
pub chatroom_id: u64,
|
||||
pub slug: String,
|
||||
pub username: String,
|
||||
pub avatar_url: Option<String>,
|
||||
/// Chat is restricted to subscribers right now.
|
||||
pub subscribers_only: bool,
|
||||
/// Chat is restricted to followers right now.
|
||||
pub followers_only: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ChannelJson {
|
||||
id: u64,
|
||||
slug: String,
|
||||
chatroom: ChatroomJson,
|
||||
user: Option<UserJson>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ChatroomJson {
|
||||
id: u64,
|
||||
#[serde(default)]
|
||||
subscribers_mode: bool,
|
||||
#[serde(default)]
|
||||
followers_mode: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UserJson {
|
||||
#[serde(default)]
|
||||
username: Option<String>,
|
||||
#[serde(default)]
|
||||
profile_pic: Option<String>,
|
||||
}
|
||||
|
||||
/// Looks a streamer up by the handle somebody typed.
|
||||
///
|
||||
/// The handle is what appears after kick.com/, and people paste it in every
|
||||
/// form they have seen it: with the URL still attached, with an @ in front
|
||||
/// because that is how handles work everywhere else, in the wrong case. All of
|
||||
/// those name the same channel, so all of them are accepted; see `normalise_slug`.
|
||||
pub async fn channel(http: &reqwest::Client, slug: &str) -> Result<Channel> {
|
||||
let slug = normalise_slug(slug);
|
||||
if slug.is_empty() {
|
||||
bail!("that is not a Kick handle");
|
||||
}
|
||||
let url = format!("{API_ROOT}/api/v2/channels/{slug}");
|
||||
let res = http.get(&url).header("Accept", "application/json").send().await.context("asking Kick about that channel")?;
|
||||
if res.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
bail!("there is no Kick channel called \"{slug}\"");
|
||||
}
|
||||
if !res.status().is_success() {
|
||||
bail!("Kick answered {} for {slug}", res.status());
|
||||
}
|
||||
let json: ChannelJson = res.json().await.context("reading Kick's answer about that channel")?;
|
||||
Ok(Channel {
|
||||
id: json.id,
|
||||
chatroom_id: json.chatroom.id,
|
||||
username: json.user.as_ref().and_then(|u| u.username.clone()).unwrap_or_else(|| json.slug.clone()),
|
||||
avatar_url: json.user.and_then(|u| u.profile_pic),
|
||||
subscribers_only: json.chatroom.subscribers_mode,
|
||||
followers_only: json.chatroom.followers_mode,
|
||||
slug,
|
||||
})
|
||||
}
|
||||
|
||||
/// The handle out of whatever was typed, or empty if there was none in there.
|
||||
///
|
||||
/// Deliberately permissive about the wrapping and strict about the result: a
|
||||
/// Kick handle is letters, digits, underscores and hyphens, so anything else
|
||||
/// surviving this is not one and is better refused here than sent.
|
||||
pub fn normalise_slug(raw: &str) -> String {
|
||||
let mut s = raw.trim();
|
||||
for prefix in ["https://", "http://"] {
|
||||
s = s.strip_prefix(prefix).unwrap_or(s);
|
||||
}
|
||||
s = s.strip_prefix("www.").unwrap_or(s);
|
||||
s = s.strip_prefix("kick.com/").unwrap_or(s);
|
||||
// A channel URL can carry a tab or a query - kick.com/name/videos.
|
||||
s = s.split(['/', '?', '#']).next().unwrap_or("");
|
||||
s = s.strip_prefix('@').unwrap_or(s);
|
||||
let s = s.trim().to_lowercase();
|
||||
if s.is_empty() || !s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
|
||||
return String::new();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// One emote somebody can put in a message.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Emote {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// Only subscribers to this channel may use it. Everybody can still *see*
|
||||
/// it - see the module doc on `super::emotes`.
|
||||
#[serde(rename = "subscribersOnly")]
|
||||
pub subscribers_only: bool,
|
||||
/// Which set it came from: the channel's own, or one of Kick's global
|
||||
/// ones. Shown as a heading in the picker, so an unfamiliar name is
|
||||
/// attributable to whoever supplied it.
|
||||
pub set: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EmoteSetJson {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
slug: Option<String>,
|
||||
#[serde(default)]
|
||||
emotes: Vec<EmoteJson>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EmoteJson {
|
||||
id: serde_json::Value,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
subscribers_only: bool,
|
||||
}
|
||||
|
||||
/// The picture for an emote, by id.
|
||||
///
|
||||
/// Only "fullsize" exists: the obvious `/default` and `/small` variants that
|
||||
/// other emote hosts serve are a 403 here, which would be a broken image in
|
||||
/// every message rather than a smaller one.
|
||||
pub fn emote_url(id: &str) -> String {
|
||||
format!("https://files.kick.com/emotes/{id}/fullsize")
|
||||
}
|
||||
|
||||
/// Every emote usable in a channel: the streamer's own, plus Kick's global
|
||||
/// sets.
|
||||
///
|
||||
/// One request for all three, because that is how Kick serves it and because
|
||||
/// they are wanted together - the picker shows them in one list under
|
||||
/// headings, and a message can carry any of them.
|
||||
pub async fn emotes(http: &reqwest::Client, slug: &str) -> Result<Vec<Emote>> {
|
||||
let url = format!("{API_ROOT}/emotes/{slug}");
|
||||
let res = http.get(&url).header("Accept", "application/json").send().await.context("fetching the channel's emotes")?;
|
||||
if !res.status().is_success() {
|
||||
bail!("Kick answered {} for {slug}'s emotes", res.status());
|
||||
}
|
||||
let sets: Vec<EmoteSetJson> = res.json().await.context("reading the channel's emotes")?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
for set in sets {
|
||||
// The channel's own set is the one identified by slug rather than by
|
||||
// name; Kick's global sets are named ("Global", "Emojis") and carry
|
||||
// no slug. Naming it for the streamer is what makes the picker's
|
||||
// heading useful.
|
||||
let label = match (&set.name, &set.slug) {
|
||||
(_, Some(slug)) => slug.clone(),
|
||||
(Some(name), None) => name.clone(),
|
||||
(None, None) => "Kick".to_string(),
|
||||
};
|
||||
for e in set.emotes {
|
||||
// Kick sends ids as numbers in some sets and strings in others.
|
||||
let id = match &e.id {
|
||||
serde_json::Value::Number(n) => n.to_string(),
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
_ => continue,
|
||||
};
|
||||
out.push(Emote {
|
||||
url: emote_url(&id),
|
||||
id,
|
||||
name: e.name,
|
||||
subscribers_only: e.subscribers_only,
|
||||
set: label.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The credential itself, out of however it was captured.
|
||||
///
|
||||
/// A sign-in window reads Kick's session off a request header, where it is
|
||||
/// written the way HTTP writes credentials - `Bearer <token>`. Everything here
|
||||
/// adds that scheme itself, so a token still carrying one would be sent as
|
||||
/// `Bearer Bearer ...` and rejected with a message about nothing in
|
||||
/// particular. Stripped once, at the boundary, rather than guarded at each of
|
||||
/// the four call sites.
|
||||
pub fn bare_token(raw: &str) -> String {
|
||||
let t = raw.trim();
|
||||
let t = if t.len() > 7 && t[..7].eq_ignore_ascii_case("bearer ") { &t[7..] } else { t };
|
||||
t.trim().to_string()
|
||||
}
|
||||
|
||||
/// Who the token belongs to.
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct Identity {
|
||||
#[serde(default)]
|
||||
pub username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub id: Option<u64>,
|
||||
}
|
||||
|
||||
pub async fn identity(http: &reqwest::Client, token: &str) -> Result<Identity> {
|
||||
let res = http
|
||||
.get(format!("{API_ROOT}/api/v1/user"))
|
||||
.header("Accept", "application/json")
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.context("asking Kick who this account is")?;
|
||||
if !res.status().is_success() {
|
||||
bail!("Kick did not accept that sign-in ({})", res.status());
|
||||
}
|
||||
let who: Identity = res.json().await.context("reading Kick's answer")?;
|
||||
// An unauthenticated request to this endpoint is answered `{}` with a 200
|
||||
// rather than a 401, so "did it parse" is not the same question as "is
|
||||
// this signed in" and the empty answer has to be caught here.
|
||||
if who.username.as_deref().unwrap_or("").is_empty() {
|
||||
bail!("Kick did not accept that sign-in");
|
||||
}
|
||||
Ok(who)
|
||||
}
|
||||
|
||||
/// What this account is to one channel.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct Standing {
|
||||
pub subscribed: bool,
|
||||
pub following: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MeJson {
|
||||
#[serde(default)]
|
||||
subscription: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
is_following: bool,
|
||||
}
|
||||
|
||||
/// Whether this account is subscribed to a channel.
|
||||
///
|
||||
/// The answer decides only which emotes the picker will let you *send*. It is
|
||||
/// asked once when a channel is opened rather than per message: a subscription
|
||||
/// starting mid-session is worth a reconnect to notice, and asking Kick on
|
||||
/// every keystroke to gate a picker would be absurd.
|
||||
///
|
||||
/// An error here is not fatal anywhere it is called. Not knowing means being
|
||||
/// treated as not subscribed, which costs a locked emote rather than a
|
||||
/// conversation.
|
||||
pub async fn standing(http: &reqwest::Client, token: &str, slug: &str) -> Result<Standing> {
|
||||
let res = http
|
||||
.get(format!("{API_ROOT}/api/v2/channels/{slug}/me"))
|
||||
.header("Accept", "application/json")
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.context("asking Kick about this account's standing in the channel")?;
|
||||
if res.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
bail!("Kick no longer accepts this sign-in - sign in again from Accounts");
|
||||
}
|
||||
if !res.status().is_success() {
|
||||
bail!("Kick answered {} about this account's standing", res.status());
|
||||
}
|
||||
let me: MeJson = res.json().await.context("reading Kick's answer")?;
|
||||
Ok(Standing {
|
||||
// Present-and-not-null rather than truthy: Kick sends the whole
|
||||
// subscription object, and its fields are none of this client's
|
||||
// business beyond whether there is one.
|
||||
subscribed: !matches!(me.subscription, None | Some(serde_json::Value::Null)),
|
||||
following: me.is_following,
|
||||
})
|
||||
}
|
||||
|
||||
/// Posts a line to a channel's chat.
|
||||
///
|
||||
/// The CSRF token is asked for at send time rather than stored. Kick's API is
|
||||
/// Laravel, which wants its `XSRF-TOKEN` cookie echoed back in a header on a
|
||||
/// POST; fetching it immediately before means it cannot be stale, and means
|
||||
/// the sign-in window has only one thing to capture rather than two that could
|
||||
/// expire independently.
|
||||
pub async fn send_message(http: &reqwest::Client, token: &str, chatroom_id: u64, body: &str) -> Result<()> {
|
||||
let mut req = http
|
||||
.post(format!("{API_ROOT}/api/v2/messages/send/{chatroom_id}"))
|
||||
.header("Accept", "application/json")
|
||||
.bearer_auth(token)
|
||||
.json(&serde_json::json!({ "content": body, "type": "message" }));
|
||||
if let Some(xsrf) = xsrf_token(http, token).await {
|
||||
req = req.header("X-XSRF-TOKEN", xsrf);
|
||||
}
|
||||
let res = req.send().await.context("sending the message to Kick")?;
|
||||
let status = res.status();
|
||||
if status.is_success() {
|
||||
return Ok(());
|
||||
}
|
||||
// Kick explains refusals properly - slow mode, followers-only, a timeout,
|
||||
// an expired session - and its sentence is worth far more than "HTTP 403".
|
||||
let detail = res
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|v| v.get("message").and_then(|m| m.as_str()).map(str::to_string))
|
||||
.filter(|s| !s.is_empty());
|
||||
match detail {
|
||||
Some(msg) => Err(anyhow!("{msg}")),
|
||||
None if status == reqwest::StatusCode::UNAUTHORIZED => {
|
||||
Err(anyhow!("Kick no longer accepts this sign-in - sign in again from Accounts"))
|
||||
}
|
||||
None => Err(anyhow!("Kick refused the message ({status})")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The CSRF token Kick last handed this client, if it has handed one over.
|
||||
///
|
||||
/// Best-effort by design: a missing token is not worth refusing to send over,
|
||||
/// because the request without it fails with Kick's own explanation, which is
|
||||
/// more useful than a guess made here about why.
|
||||
async fn xsrf_token(http: &reqwest::Client, token: &str) -> Option<String> {
|
||||
// Any authenticated GET hands one over; this is the cheapest one. Read
|
||||
// straight off the response rather than through a cookie jar - the session
|
||||
// itself travels as a bearer header, so a jar would exist for this one
|
||||
// value and hold a credential for the rest of the process's life.
|
||||
let res = http.get(format!("{API_ROOT}/api/v1/user")).bearer_auth(token).send().await.ok()?;
|
||||
let cookie = res
|
||||
.headers()
|
||||
.get_all(reqwest::header::SET_COOKIE)
|
||||
.iter()
|
||||
.filter_map(|v| v.to_str().ok())
|
||||
.find_map(|v| v.strip_prefix("XSRF-TOKEN="))?
|
||||
.split(';')
|
||||
.next()?
|
||||
.to_string();
|
||||
// It arrives percent-encoded and must be sent decoded.
|
||||
Some(percent_decode(&cookie))
|
||||
}
|
||||
|
||||
fn percent_decode(s: &str) -> String {
|
||||
let bytes = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||
if let Ok(byte) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
|
||||
out.push(byte);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn takes_the_handle_out_of_whatever_was_typed() {
|
||||
for input in [
|
||||
"xqc",
|
||||
"XQC",
|
||||
" xqc ",
|
||||
"@xqc",
|
||||
"kick.com/xqc",
|
||||
"https://kick.com/xqc",
|
||||
"http://www.kick.com/xqc",
|
||||
"https://kick.com/xqc/videos",
|
||||
"https://kick.com/xqc?foo=1",
|
||||
] {
|
||||
assert_eq!(normalise_slug(input), "xqc", "for {input:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_the_punctuation_a_handle_may_contain() {
|
||||
assert_eq!(normalise_slug("some_streamer-2"), "some_streamer-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_what_is_not_a_handle() {
|
||||
// Nothing here should ever reach a URL, so each is empty rather than
|
||||
// escaped: a "handle" containing a slash or a space is somebody
|
||||
// pasting the wrong thing, and guessing at what they meant is how a
|
||||
// client ends up requesting a path it was never given.
|
||||
for input in ["", " ", "@", "https://kick.com/", "not a handle", "x;y", "../admin", "a b"] {
|
||||
assert_eq!(normalise_slug(input), "", "for {input:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_is_read_as_a_handle_and_a_tail() {
|
||||
// The same rule that turns kick.com/xqc/videos into "xqc", applied to
|
||||
// a bare path. Taking the first segment rather than refusing is the
|
||||
// useful reading of somebody's paste, and it is safe for the reason
|
||||
// that matters: whatever comes out is checked character by character
|
||||
// afterwards, so a segment that is not a handle still becomes nothing.
|
||||
assert_eq!(normalise_slug("xqc/videos"), "xqc");
|
||||
assert_eq!(normalise_slug("../admin"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn takes_the_scheme_off_a_captured_credential() {
|
||||
assert_eq!(bare_token("Bearer abc123"), "abc123");
|
||||
assert_eq!(bare_token("bearer abc123"), "abc123");
|
||||
assert_eq!(bare_token(" Bearer abc123 "), "abc123");
|
||||
assert_eq!(bare_token("abc123"), "abc123");
|
||||
// A token that merely starts with those letters is not a scheme.
|
||||
assert_eq!(bare_token("bearerish"), "bearerish");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_a_percent_encoded_csrf_token() {
|
||||
assert_eq!(percent_decode("abc%3D%3D"), "abc==");
|
||||
assert_eq!(percent_decode("plain"), "plain");
|
||||
// A stray percent is left alone rather than eating the next two bytes.
|
||||
assert_eq!(percent_decode("100%"), "100%");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Which of a channel's emotes this account may send, and how one is written.
|
||||
//!
|
||||
//! The distinction this module exists to hold is between *seeing* an emote and
|
||||
//! *using* one, because they are not the same permission and treating them as
|
||||
//! one would be wrong in both directions.
|
||||
//!
|
||||
//! Everybody sees everything. A subscriber emote in somebody else's message
|
||||
//! renders for every reader, subscribed or not - the picture is public, the id
|
||||
//! is right there in the message, and a chat where half the lines showed as
|
||||
//! `[emote:1082364:xqcAM]` to non-subscribers would be unreadable for exactly
|
||||
//! the people most likely to be new. Nothing here gates rendering; the client's
|
||||
//! formatter turns the token into a picture with no lookup at all.
|
||||
//!
|
||||
//! Only sending is gated, and only as a courtesy. Kick decides for itself
|
||||
//! whether a message is allowed, and it is the one with the authority - what
|
||||
//! this does is stop the picker from offering something that would come back
|
||||
//! refused, which is a better experience than finding out after pressing send.
|
||||
//! That is why "we could not ask" resolves to locked rather than to an error:
|
||||
//! the cost of being wrong is a greyed-out emote, and the message still sends
|
||||
//! if it is typed out.
|
||||
|
||||
use super::api::Emote;
|
||||
|
||||
/// One emote as the client sees it, with the verdict already reached.
|
||||
#[derive(serde::Serialize, Debug, Clone)]
|
||||
pub struct Offered {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
/// The set it came from, as a heading.
|
||||
pub set: String,
|
||||
/// Subscriber-only *and* this account is not subscribed. Shown, not
|
||||
/// hidden: knowing what subscribing would get you is most of the reason
|
||||
/// the tier exists.
|
||||
pub locked: bool,
|
||||
/// Kick emotes are static PNGs. Present so the field means the same thing
|
||||
/// here as it does for Discord's, whose client does need it.
|
||||
pub animated: bool,
|
||||
}
|
||||
|
||||
/// The channel's emotes, each marked with whether this account may use it.
|
||||
pub fn offer(emotes: &[Emote], subscribed: bool) -> Vec<Offered> {
|
||||
emotes
|
||||
.iter()
|
||||
.map(|e| Offered {
|
||||
id: e.id.clone(),
|
||||
name: e.name.clone(),
|
||||
url: e.url.clone(),
|
||||
set: e.set.clone(),
|
||||
locked: e.subscribers_only && !subscribed,
|
||||
animated: false,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// How an emote is written inside a message.
|
||||
///
|
||||
/// Kick's own format, which every other Kick client reads and writes: the id
|
||||
/// carries the picture and the name is there so the line still says something
|
||||
/// where the picture cannot be drawn.
|
||||
pub fn token(id: &str, name: &str) -> String {
|
||||
format!("[emote:{id}:{name}]")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn emote(name: &str, subs_only: bool) -> Emote {
|
||||
Emote {
|
||||
id: "1".into(),
|
||||
name: name.into(),
|
||||
subscribers_only: subs_only,
|
||||
set: "xqc".into(),
|
||||
url: "https://files.kick.com/emotes/1/fullsize".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_subscriber_may_use_everything() {
|
||||
let offered = offer(&[emote("free", false), emote("paid", true)], true);
|
||||
assert!(offered.iter().all(|o| !o.locked));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn everybody_else_may_use_the_free_ones() {
|
||||
let offered = offer(&[emote("free", false), emote("paid", true)], false);
|
||||
assert!(!offered[0].locked);
|
||||
assert!(offered[1].locked);
|
||||
// Locked, not absent: the picker shows what subscribing would buy.
|
||||
assert_eq!(offered.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_kicks_own_token() {
|
||||
assert_eq!(token("1082364", "xqcAM"), "[emote:1082364:xqcAM]");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,795 @@
|
||||
//! Kick - livestream chat, read over the same Pusher websocket kick.com's own
|
||||
//! player page uses.
|
||||
//!
|
||||
//! Two things make this backend shaped differently from the others here.
|
||||
//!
|
||||
//! **A channel is a streamer, not a room you are a member of.** There is no
|
||||
//! join to perform and no membership to hold: naming a handle is the whole of
|
||||
//! it, and the chat is public to anybody with a browser. So a Kick account
|
||||
//! with no credential at all is useful - it reads every channel it is pointed
|
||||
//! at - and signing in buys exactly two things, sending and knowing what you
|
||||
//! are subscribed to. That is why the token is optional throughout rather than
|
||||
//! a precondition for connecting.
|
||||
//!
|
||||
//! **One websocket carries every channel.** Pusher multiplexes: each channel
|
||||
//! is a `chatrooms.<id>.v2` subscription on the one connection, so watching
|
||||
//! twenty streamers costs one socket rather than twenty. Sneedchat next door
|
||||
//! opens one per room because its protocol has no such verb; this one does,
|
||||
//! and joining a channel while connected is a subscribe frame rather than a
|
||||
//! reconnect.
|
||||
|
||||
pub mod api;
|
||||
pub mod emotes;
|
||||
|
||||
use crate::accounts::KickAccountConfig;
|
||||
use crate::runtime::ConnState;
|
||||
use crate::state::AppState;
|
||||
use anyhow::{Context, Result};
|
||||
use futures::{FutureExt, SinkExt, StreamExt};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||
|
||||
/// Kick's own Pusher application, as served to every visitor of kick.com.
|
||||
///
|
||||
/// Public by construction: a Pusher *key* is the identifier a browser needs to
|
||||
/// subscribe to public channels, which is why it appears in their page source
|
||||
/// and why reading a public chat needs no credential. It is not a secret and
|
||||
/// grants nothing beyond what a browser on the channel page already has.
|
||||
const PUSHER_KEY: &str = "32cbd69e4b950bf97679";
|
||||
const PUSHER_CLUSTER: &str = "us2";
|
||||
|
||||
fn pusher_url() -> String {
|
||||
format!(
|
||||
"wss://ws-{PUSHER_CLUSTER}.pusher.com/app/{PUSHER_KEY}?protocol=7&client=moho&version=8.4.0&flash=false"
|
||||
)
|
||||
}
|
||||
|
||||
/// Asked of the connection from elsewhere in the daemon, over a channel,
|
||||
/// because the socket is owned by its own task and these arrive from RPC.
|
||||
#[derive(Debug)]
|
||||
pub enum Command {
|
||||
/// Watch a channel, by whatever the user typed as a handle.
|
||||
Join(String),
|
||||
/// Stop watching one.
|
||||
Part(String),
|
||||
}
|
||||
|
||||
pub fn spawn(state: AppState, config: KickAccountConfig) {
|
||||
let account_id = config.account_id();
|
||||
state.runtime.reset_connection(&account_id);
|
||||
let join_handle = tokio::spawn({
|
||||
let account_id = account_id.clone();
|
||||
let state = state.clone();
|
||||
async move {
|
||||
run_with_retry(&state, &config, &account_id).await;
|
||||
state.runtime.remove_task_handle(&account_id);
|
||||
}
|
||||
});
|
||||
state.runtime.insert_task_handle(&account_id, join_handle.abort_handle());
|
||||
}
|
||||
|
||||
const RECONNECT_INITIAL_DELAY: Duration = Duration::from_secs(3);
|
||||
const RECONNECT_MAX_DELAY: Duration = Duration::from_secs(60);
|
||||
|
||||
async fn run_with_retry(state: &AppState, config: &KickAccountConfig, account_id: &str) {
|
||||
let mut delay = RECONNECT_INITIAL_DELAY;
|
||||
state.runtime.set_conn_state(state, account_id, ConnState::Connecting, None);
|
||||
loop {
|
||||
// Re-read the account rather than reusing the one this task was
|
||||
// spawned with. Everything about it can have changed since - and one
|
||||
// thing routinely has: the channels being watched, because watching a
|
||||
// streamer is a live command that also writes itself to disk. Running
|
||||
// from the spawn-time copy meant a reconnect resubscribed to whatever
|
||||
// was configured minutes ago, so a channel joined during the session
|
||||
// survived exactly until the first blip and then went quiet for good,
|
||||
// with the buffer still sitting there looking connected.
|
||||
let current = state.accounts.get_kick(account_id);
|
||||
let config = current.as_ref().unwrap_or(config);
|
||||
let result = std::panic::AssertUnwindSafe(run(state, config, account_id)).catch_unwind().await;
|
||||
let detail = match result {
|
||||
Ok(Ok(())) => "connection ended".to_string(),
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!("kick[{account_id}]: {e:#}");
|
||||
format!("{e:#}")
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::error!("kick[{account_id}]: connection task panicked");
|
||||
"internal error (see nobilis logs)".to_string()
|
||||
}
|
||||
};
|
||||
state.runtime.clear_kick_sender(account_id);
|
||||
state.runtime.set_conn_state(state, account_id, ConnState::Connecting, None);
|
||||
state.runtime.report_progress(state, account_id, &format!("{detail} - reconnecting in {}s...", delay.as_secs()));
|
||||
tokio::time::sleep(delay).await;
|
||||
delay = (delay * 2).min(RECONNECT_MAX_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(state: &AppState, config: &KickAccountConfig, account_id: &str) -> Result<()> {
|
||||
let http = api::client()?;
|
||||
|
||||
// Signed in or not is settled first, because everything downstream reads
|
||||
// differently depending: with no token this is a viewer, and the UI should
|
||||
// say so rather than failing at the moment somebody tries to talk.
|
||||
if let Some(token) = config.token.as_deref().filter(|t| !t.is_empty()) {
|
||||
match api::identity(&http, token).await {
|
||||
Ok(who) => {
|
||||
if let Some(name) = who.username {
|
||||
state.runtime.set_own_identity(account_id, &name);
|
||||
}
|
||||
}
|
||||
// Not fatal. A rejected token still leaves a working reader, and
|
||||
// saying so beats a reconnect loop that never explains itself.
|
||||
Err(e) => state.runtime.report_progress(state, account_id, &format!("signed out: {e:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Command>();
|
||||
state.runtime.set_kick_sender(account_id, tx);
|
||||
|
||||
let (mut socket, _) = tokio_tungstenite::connect_async(pusher_url())
|
||||
.await
|
||||
.context("connecting to Kick's chat")?;
|
||||
|
||||
// Two maps because Kick uses two numbers. A chat message names the
|
||||
// chatroom; the events on `channel.<id>` name the channel, which is a
|
||||
// different number for every streamer except the oldest few - where the
|
||||
// two happen to be equal, which is exactly the kind of coincidence that
|
||||
// makes one map look like it works.
|
||||
let mut watched = Watched::default();
|
||||
|
||||
for slug in &config.channels {
|
||||
match prepare(state, &http, config, account_id, slug).await {
|
||||
Ok(channel) => {
|
||||
subscribe(&mut socket, channel.chatroom_id, channel.id).await?;
|
||||
watched.add(&channel);
|
||||
}
|
||||
// One bad handle in a saved list must not stop the other twenty
|
||||
// from connecting - a channel can be renamed or banned between
|
||||
// sessions, and that is this account's problem with one buffer
|
||||
// rather than with Kick.
|
||||
Err(e) => {
|
||||
tracing::warn!("kick[{account_id}]: {slug}: {e:#}");
|
||||
state.runtime.report_progress(state, account_id, &format!("{slug}: {e:#}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.runtime.set_conn_state(state, account_id, ConnState::Connected, None);
|
||||
|
||||
// Pusher closes a socket it has not heard from. Its own timeout arrives in
|
||||
// the handshake, but a fixed interval well inside the shortest it ever
|
||||
// sends is simpler than tracking it and cannot be wrong in the direction
|
||||
// that matters.
|
||||
let mut ping = tokio::time::interval(Duration::from_secs(60));
|
||||
ping.tick().await;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
command = rx.recv() => match command {
|
||||
None => return Ok(()),
|
||||
Some(Command::Join(handle)) => {
|
||||
match prepare(state, &http, config, account_id, &handle).await {
|
||||
Ok(channel) => {
|
||||
// Propagated, not reported: a subscribe that will
|
||||
// not go out means this socket is finished, and
|
||||
// reconnecting re-subscribes everything including
|
||||
// the channel just asked for.
|
||||
subscribe(&mut socket, channel.chatroom_id, channel.id).await?;
|
||||
watched.add(&channel);
|
||||
}
|
||||
Err(e) => state.runtime.report_progress(state, account_id, &format!("{e:#}")),
|
||||
}
|
||||
}
|
||||
Some(Command::Part(slug)) => {
|
||||
for name in watched.remove(&slug) {
|
||||
let _ = socket.send(WsMessage::Text(serde_json::json!({
|
||||
"event": "pusher:unsubscribe",
|
||||
"data": { "channel": name }
|
||||
}).to_string())).await;
|
||||
}
|
||||
state.runtime.forget_kick_channel(&format!("{account_id}|{slug}"));
|
||||
}
|
||||
},
|
||||
_ = ping.tick() => {
|
||||
socket.send(WsMessage::Text(r#"{"event":"pusher:ping","data":{}}"#.to_string())).await
|
||||
.context("Kick's chat connection went away")?;
|
||||
}
|
||||
frame = socket.next() => match frame {
|
||||
None => anyhow::bail!("Kick closed the chat connection"),
|
||||
Some(Err(e)) => return Err(e).context("reading from Kick's chat"),
|
||||
Some(Ok(WsMessage::Text(text))) => handle_frame(state, account_id, &watched, &text, &mut socket).await?,
|
||||
Some(Ok(WsMessage::Close(_))) => anyhow::bail!("Kick closed the chat connection"),
|
||||
Some(Ok(_)) => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The channels this connection is following, by both of the numbers Kick
|
||||
/// identifies them with.
|
||||
///
|
||||
/// Two maps rather than one keyed by "some id", because the two number spaces
|
||||
/// are unrelated: nothing stops one streamer's chatroom id from equalling
|
||||
/// another's channel id, and a single map would then deliver one streamer's
|
||||
/// subscriptions into the other's buffer. Which map to ask is never a guess -
|
||||
/// the subscription name says which kind of number it carries.
|
||||
#[derive(Default)]
|
||||
struct Watched {
|
||||
by_chatroom: HashMap<u64, String>,
|
||||
by_channel: HashMap<u64, String>,
|
||||
}
|
||||
|
||||
impl Watched {
|
||||
fn add(&mut self, channel: &api::Channel) {
|
||||
self.by_chatroom.insert(channel.chatroom_id, channel.slug.clone());
|
||||
self.by_channel.insert(channel.id, channel.slug.clone());
|
||||
}
|
||||
|
||||
/// Forgets a channel, and names every subscription it was using so the
|
||||
/// caller can cancel each one. All four, or the socket keeps delivering
|
||||
/// events for a conversation that has been closed.
|
||||
fn remove(&mut self, slug: &str) -> Vec<String> {
|
||||
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
|
||||
}
|
||||
|
||||
fn chatroom(&self, id: u64) -> Option<&String> {
|
||||
self.by_chatroom.get(&id)
|
||||
}
|
||||
}
|
||||
|
||||
type Socket = tokio_tungstenite::WebSocketStream<
|
||||
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
|
||||
>;
|
||||
|
||||
/// Everything about starting to follow a channel except the subscription:
|
||||
/// resolve the handle, make its buffer, learn its emotes.
|
||||
///
|
||||
/// Split from the subscribe on purpose, because the two fail for opposite
|
||||
/// reasons and want opposite answers. A handle that does not resolve is this
|
||||
/// one channel's problem and the connection carries on without it; a socket
|
||||
/// that will not carry the subscription is the connection's problem, and
|
||||
/// swallowing that leaves a buffer sitting there looking joined while nothing
|
||||
/// will ever arrive in it.
|
||||
///
|
||||
/// The buffer exists before the subscription does, so the first message to
|
||||
/// arrive has somewhere to land rather than racing the thing that would have
|
||||
/// created it.
|
||||
async fn prepare(
|
||||
state: &AppState,
|
||||
http: &reqwest::Client,
|
||||
config: &KickAccountConfig,
|
||||
account_id: &str,
|
||||
handle: &str,
|
||||
) -> Result<api::Channel> {
|
||||
let channel = api::channel(http, handle).await?;
|
||||
// No set_buffer_group here. ensure_buffer already files a new buffer under
|
||||
// its own account's rail entry, which is exactly where a Kick channel
|
||||
// belongs - there is no guild or space above it. Saying so again with the
|
||||
// bare account id wrote a group that does not exist, and a buffer in a
|
||||
// group with no rail tile is a conversation with no way to reach it:
|
||||
// receiving fine, listed by the daemon, and invisible.
|
||||
let buffer = state.runtime.ensure_buffer(state, account_id, &channel.slug, "channel");
|
||||
|
||||
// Whether this account may *use* the streamer's subscriber emotes. Asked
|
||||
// once here rather than per message, and treated as "no" if Kick will not
|
||||
// say - which costs a greyed-out emote, not a conversation.
|
||||
let subscribed = match config.token.as_deref().filter(|t| !t.is_empty()) {
|
||||
None => false,
|
||||
Some(token) => api::standing(http, token, &channel.slug).await.map(|s| s.subscribed).unwrap_or(false),
|
||||
};
|
||||
let emotes = api::emotes(http, &channel.slug).await.unwrap_or_default();
|
||||
state.runtime.set_kick_channel(
|
||||
&buffer.id,
|
||||
crate::runtime::KickChannel { slug: channel.slug.clone(), chatroom_id: channel.chatroom_id, subscribed, emotes },
|
||||
);
|
||||
|
||||
Ok(channel)
|
||||
}
|
||||
|
||||
/// Asks the socket for everything that happens in a channel's chat.
|
||||
///
|
||||
/// Four 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
|
||||
/// - `channel.<channel>` going live, and the channel's own subscriptions
|
||||
///
|
||||
/// 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
|
||||
/// in `describe_action`, not here - subscribing to fewer channels would lose
|
||||
/// whole categories of event to avoid a duplicate that is cheaper to filter.
|
||||
///
|
||||
/// Empty auth: all four are public, and this backend subscribes to nothing
|
||||
/// that is not.
|
||||
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}"),
|
||||
] {
|
||||
socket
|
||||
.send(WsMessage::Text(
|
||||
serde_json::json!({ "event": "pusher:subscribe", "data": { "auth": "", "channel": channel } })
|
||||
.to_string(),
|
||||
))
|
||||
.await
|
||||
.context("subscribing to the channel's chat")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PusherFrame {
|
||||
event: String,
|
||||
#[serde(default)]
|
||||
data: serde_json::Value,
|
||||
/// Which subscription this arrived on. The most reliable way to know
|
||||
/// whose channel an event belongs to: a chat message names its room in the
|
||||
/// payload, but the doing-things events name variously the channel, the
|
||||
/// chatroom, or nothing at all - while Pusher always says which
|
||||
/// subscription delivered it.
|
||||
#[serde(default)]
|
||||
channel: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ChatMessage {
|
||||
id: String,
|
||||
chatroom_id: u64,
|
||||
content: String,
|
||||
#[serde(default)]
|
||||
created_at: Option<String>,
|
||||
sender: Sender,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Sender {
|
||||
#[serde(default)]
|
||||
username: Option<String>,
|
||||
#[serde(default)]
|
||||
id: Option<u64>,
|
||||
}
|
||||
|
||||
async fn handle_frame(
|
||||
state: &AppState,
|
||||
account_id: &str,
|
||||
watched: &Watched,
|
||||
text: &str,
|
||||
socket: &mut Socket,
|
||||
) -> Result<()> {
|
||||
let frame: PusherFrame = match serde_json::from_str(text) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
|
||||
// Pusher nests the real payload as a JSON *string* inside the frame, so
|
||||
// every event needs unwrapping twice.
|
||||
let payload = match &frame.data {
|
||||
serde_json::Value::String(s) => serde_json::from_str::<serde_json::Value>(s).unwrap_or(serde_json::Value::Null),
|
||||
other => other.clone(),
|
||||
};
|
||||
|
||||
match frame.event.as_str() {
|
||||
"pusher:ping" => {
|
||||
socket.send(WsMessage::Text(r#"{"event":"pusher:pong","data":{}}"#.to_string())).await?;
|
||||
}
|
||||
"pusher:error" => {
|
||||
let msg = payload.get("message").and_then(|m| m.as_str()).unwrap_or("Kick refused the connection");
|
||||
anyhow::bail!("{msg}");
|
||||
}
|
||||
e if e.ends_with("ChatMessageEvent") => {
|
||||
let Ok(msg) = serde_json::from_value::<ChatMessage>(payload) else { return Ok(()) };
|
||||
let Some(slug) = watched.chatroom(msg.chatroom_id) else { return Ok(()) };
|
||||
let from = msg.sender.username.unwrap_or_else(|| "someone".to_string());
|
||||
state.runtime.record_message_at(
|
||||
state,
|
||||
account_id,
|
||||
slug,
|
||||
"channel",
|
||||
&from,
|
||||
&msg.content,
|
||||
false,
|
||||
"chat",
|
||||
None,
|
||||
// Kick's own message id, so the same message replayed after a
|
||||
// reconnect is recognised rather than shown twice.
|
||||
Some(msg.id),
|
||||
false,
|
||||
None,
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
msg.sender.id.map(|i| i.to_string()),
|
||||
msg.created_at.as_deref().and_then(parse_timestamp),
|
||||
None,
|
||||
);
|
||||
}
|
||||
// Worth a line in the channel it happened in: somebody watching a
|
||||
// handful of streamers is largely watching for this.
|
||||
e if e.ends_with("StreamerIsLive") => {
|
||||
if let Some(slug) = channel_of(watched, &frame.channel, &payload) {
|
||||
system_line(state, account_id, &slug, "went live", "system");
|
||||
}
|
||||
}
|
||||
e if e.ends_with("StopStreamBroadcast") => {
|
||||
if let Some(slug) = channel_of(watched, &frame.channel, &payload) {
|
||||
system_line(state, account_id, &slug, "ended the stream", "system");
|
||||
}
|
||||
}
|
||||
// The things people do rather than say.
|
||||
e => {
|
||||
if let Some(line) = describe_action(e, &payload) {
|
||||
if let Some(slug) = channel_of(watched, &frame.channel, &payload) {
|
||||
system_line(state, account_id, &slug, &line, "reward");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One line for something somebody did in a channel, or None if this event is
|
||||
/// not one of those.
|
||||
///
|
||||
/// These are not chatter and are not system noise either: a redemption or a
|
||||
/// gifted sub is somebody spending something, and it is usually the thing the
|
||||
/// stream is about to react to. So they are recorded with their own kind and
|
||||
/// the client draws them on a plate of their own - visible when scrolling past
|
||||
/// at speed, which is the entire point of them.
|
||||
///
|
||||
/// Written against the field names Kick uses and tolerant of the ones it has
|
||||
/// used: `username` and `sender.username` both appear across these events, and
|
||||
/// an event whose shape has moved on should degrade to being skipped rather
|
||||
/// than to a line with "unknown" in it.
|
||||
fn describe_action(event: &str, p: &serde_json::Value) -> Option<String> {
|
||||
// Matched exactly, on the last segment, rather than by suffix. Kick sends
|
||||
// one real subscription as three events on three subscriptions - measured
|
||||
// on a live subathon: `SubscriptionEvent`, `ChannelSubscriptionEvent` and
|
||||
// an info-line `ChatMessageSentEvent`, all for the same person subscribing
|
||||
// once. A suffix test reported it twice, because "ChannelSubscriptionEvent"
|
||||
// ends with "SubscriptionEvent". So each name is listed and the duplicates
|
||||
// are named as the duplicates they are.
|
||||
let name = event.rsplit('\\').next().unwrap_or(event);
|
||||
|
||||
let text = |key: &str| p.get(key).and_then(|v| v.as_str()).map(str::to_string).filter(|s| !s.is_empty());
|
||||
let who = || {
|
||||
text("username")
|
||||
.or_else(|| p.get("sender").and_then(|s| s.get("username")).and_then(|v| v.as_str()).map(str::to_string))
|
||||
.or_else(|| p.get("user").and_then(|s| s.get("username")).and_then(|v| v.as_str()).map(str::to_string))
|
||||
};
|
||||
|
||||
match name {
|
||||
"RewardRedeemedEvent" => {
|
||||
let who = who()?;
|
||||
let reward = text("reward_title").or_else(|| text("title"))?;
|
||||
// What they typed with it, where the reward takes input - it is
|
||||
// the half that says what they actually asked for.
|
||||
Some(match text("user_input") {
|
||||
Some(input) => format!("{who} redeemed {reward}: {input}"),
|
||||
None => format!("{who} redeemed {reward}"),
|
||||
})
|
||||
}
|
||||
|
||||
"GiftedSubscriptionsEvent" => {
|
||||
let gifter = text("gifter_username").or_else(who)?;
|
||||
let count = p
|
||||
.get("gifted_usernames")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.len())
|
||||
.or_else(|| p.get("gifted_total").and_then(|v| v.as_u64()).map(|n| n as usize))
|
||||
.unwrap_or(1);
|
||||
Some(match count {
|
||||
0 | 1 => format!("{gifter} gifted a subscription"),
|
||||
n => format!("{gifter} gifted {n} subscriptions"),
|
||||
})
|
||||
}
|
||||
|
||||
// The one of the three that carries how long they have been
|
||||
// subscribed, which is the part worth saying.
|
||||
"SubscriptionEvent" => {
|
||||
let who = who()?;
|
||||
Some(match p.get("months").and_then(|v| v.as_u64()) {
|
||||
Some(n) if n > 1 => format!("{who} subscribed - {n} months"),
|
||||
_ => format!("{who} subscribed"),
|
||||
})
|
||||
}
|
||||
|
||||
"StreamHostEvent" | "StreamHostedEvent" => {
|
||||
let who = text("host_username").or_else(who)?;
|
||||
Some(match p.get("number_viewers").and_then(|v| v.as_u64()) {
|
||||
Some(n) if n > 0 => format!("{who} raided with {n} viewers"),
|
||||
_ => format!("{who} raided the channel"),
|
||||
})
|
||||
}
|
||||
|
||||
// Deliberately nothing. Both are the same subscription reported again
|
||||
// on another subscription - `ChatMessageSentEvent` is the info line
|
||||
// Kick's own page draws from, and `LuckyUsers...` is the companion to
|
||||
// a gift that has already been announced.
|
||||
"ChannelSubscriptionEvent" | "ChatMessageSentEvent" | "LuckyUsersWhoGotGiftSubscriptionsEvent" => None,
|
||||
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Which watched channel an event belongs to.
|
||||
///
|
||||
/// The subscription it arrived on is asked first and is the reliable answer:
|
||||
/// this connection subscribed per room, so the room is in the name. The
|
||||
/// payload is the fallback, because these events disagree about what they name
|
||||
/// - some carry the chatroom, some the channel, some neither - and a fallback
|
||||
/// that is occasionally right beats an event dropped for lack of a field.
|
||||
fn channel_of(watched: &Watched, subscription: &Option<String>, payload: &serde_json::Value) -> Option<String> {
|
||||
if let Some(name) = subscription.as_deref() {
|
||||
match id_in_subscription(name) {
|
||||
Some(Subscribed::Chatroom(id)) => {
|
||||
if let Some(slug) = watched.by_chatroom.get(&id) {
|
||||
return Some(slug.clone());
|
||||
}
|
||||
}
|
||||
Some(Subscribed::Channel(id)) => {
|
||||
if let Some(slug) = watched.by_channel.get(&id) {
|
||||
return Some(slug.clone());
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
// Each id is looked up only in its own map, for the same reason there are
|
||||
// two of them.
|
||||
if let Some(id) = payload.get("chatroom_id").and_then(|v| v.as_u64()) {
|
||||
if let Some(slug) = watched.by_chatroom.get(&id) {
|
||||
return Some(slug.clone());
|
||||
}
|
||||
}
|
||||
let channel = payload
|
||||
.get("livestream")
|
||||
.and_then(|l| l.get("channel_id"))
|
||||
.or_else(|| payload.get("channel_id"))
|
||||
.and_then(|v| v.as_u64())?;
|
||||
watched.by_channel.get(&channel).cloned()
|
||||
}
|
||||
|
||||
/// Which of Kick's two numbers a subscription name carries.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Subscribed {
|
||||
Chatroom(u64),
|
||||
Channel(u64),
|
||||
}
|
||||
|
||||
fn id_in_subscription(name: &str) -> Option<Subscribed> {
|
||||
if let Some(rest) = name.strip_prefix("chatrooms.").or_else(|| name.strip_prefix("chatroom_")) {
|
||||
return rest.split('.').next()?.parse().ok().map(Subscribed::Chatroom);
|
||||
}
|
||||
if let Some(rest) = name.strip_prefix("channel.") {
|
||||
return rest.split('.').next()?.parse().ok().map(Subscribed::Channel);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A line that nobody said.
|
||||
///
|
||||
/// `kind` is what separates "the stream started" from "somebody redeemed
|
||||
/// something": both are events rather than chatter, but only one of them is a
|
||||
/// person doing something, and the client draws that one on a plate.
|
||||
fn system_line(state: &AppState, account_id: &str, slug: &str, what: &str, kind: &str) {
|
||||
state.runtime.record_message(
|
||||
state, account_id, slug, "channel", slug, what, false, kind, None, None, false, None,
|
||||
Vec::new(), Vec::new(), None,
|
||||
);
|
||||
}
|
||||
|
||||
/// Kick's timestamps, as unix seconds.
|
||||
///
|
||||
/// They arrive as RFC 3339 (`2026-01-08T23:32:57.000000Z`). Without this every
|
||||
/// message from a reconnect's replay would be dated to the reconnect - the same
|
||||
/// problem `record_message_at` exists to solve everywhere else.
|
||||
fn parse_timestamp(raw: &str) -> Option<i64> {
|
||||
chrono::DateTime::parse_from_rfc3339(raw).ok().map(|t| t.timestamp())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_kicks_timestamps() {
|
||||
assert_eq!(parse_timestamp("2026-01-08T23:32:57.000000Z"), Some(1767915177));
|
||||
assert_eq!(parse_timestamp("2026-01-08T23:32:57Z"), Some(1767915177));
|
||||
assert_eq!(parse_timestamp("not a time"), None);
|
||||
}
|
||||
|
||||
/// odablock's real numbers: the chatroom and the channel differ, which is
|
||||
/// the ordinary case and the one a single map gets wrong.
|
||||
fn watching_odablock() -> Watched {
|
||||
let mut w = Watched::default();
|
||||
w.add(&api::Channel {
|
||||
id: 2401072,
|
||||
chatroom_id: 2393554,
|
||||
slug: "odablock".into(),
|
||||
username: "odablock".into(),
|
||||
avatar_url: None,
|
||||
subscribers_only: false,
|
||||
followers_only: false,
|
||||
});
|
||||
w
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_the_channel_by_the_subscription_it_arrived_on() {
|
||||
let w = watching_odablock();
|
||||
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"] {
|
||||
assert_eq!(channel_of(&w, &Some(name.into()), &none).as_deref(), Some("odablock"), "for {name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_whichever_id_the_payload_carries() {
|
||||
let w = watching_odablock();
|
||||
for payload in [
|
||||
serde_json::json!({ "livestream": { "channel_id": 2401072 } }),
|
||||
serde_json::json!({ "channel_id": 2401072 }),
|
||||
serde_json::json!({ "chatroom_id": 2393554 }),
|
||||
] {
|
||||
assert_eq!(channel_of(&w, &None, &payload).as_deref(), Some("odablock"), "for {payload}");
|
||||
}
|
||||
// A channel this account is not watching is not ours to report.
|
||||
assert_eq!(channel_of(&w, &None, &serde_json::json!({ "channel_id": 1 })), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_confuses_one_streamers_numbers_for_anothers() {
|
||||
// The bug two maps exist to prevent: nothing stops one streamer's
|
||||
// chatroom id from being another's channel id, and a single map keyed
|
||||
// by "some id" would deliver one's events into the other's buffer.
|
||||
let mut w = Watched::default();
|
||||
w.add(&api::Channel {
|
||||
id: 999, chatroom_id: 111, slug: "first".into(), username: "first".into(),
|
||||
avatar_url: None, subscribers_only: false, followers_only: false,
|
||||
});
|
||||
w.add(&api::Channel {
|
||||
id: 222, chatroom_id: 999, slug: "second".into(), username: "second".into(),
|
||||
avatar_url: None, subscribers_only: false, followers_only: false,
|
||||
});
|
||||
// 999 is first's channel and second's chatroom. Which one is meant is
|
||||
// never a guess - the subscription name says which kind it is.
|
||||
assert_eq!(channel_of(&w, &Some("channel.999".into()), &serde_json::json!({})).as_deref(), Some("first"));
|
||||
assert_eq!(channel_of(&w, &Some("chatrooms.999.v2".into()), &serde_json::json!({})).as_deref(), Some("second"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_a_channel_cancels_every_subscription_it_had() {
|
||||
let mut w = watching_odablock();
|
||||
let mut names = w.remove("odablock");
|
||||
names.sort();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec!["channel.2401072", "chatroom_2393554", "chatrooms.2393554", "chatrooms.2393554.v2"]
|
||||
);
|
||||
// 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!(w.remove("odablock").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_a_redemption() {
|
||||
let p = serde_json::json!({ "username": "GayHayride", "reward_title": "CAT PATS", "user_input": "" });
|
||||
assert_eq!(
|
||||
describe_action("App\\Events\\RewardRedeemedEvent", &p).as_deref(),
|
||||
Some("GayHayride redeemed CAT PATS")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_what_they_typed_with_it() {
|
||||
// The half that says what was actually asked for, where the reward
|
||||
// takes input at all.
|
||||
let p = serde_json::json!({ "username": "Hecklephish", "reward_title": "CAT TREATS", "user_input": "for Mittens" });
|
||||
assert_eq!(
|
||||
describe_action("App\\Events\\RewardRedeemedEvent", &p).as_deref(),
|
||||
Some("Hecklephish redeemed CAT TREATS: for Mittens")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_subscriptions_and_gifts_and_raids() {
|
||||
let subbed = serde_json::json!({ "username": "someone", "months": 1 });
|
||||
assert_eq!(describe_action("SubscriptionEvent", &subbed).as_deref(), Some("someone subscribed"));
|
||||
|
||||
let resub = serde_json::json!({ "username": "someone", "months": 7 });
|
||||
assert_eq!(describe_action("SubscriptionEvent", &resub).as_deref(), Some("someone subscribed - 7 months"));
|
||||
|
||||
let gifts = serde_json::json!({ "gifter_username": "big", "gifted_usernames": ["a", "b", "c"] });
|
||||
assert_eq!(describe_action("GiftedSubscriptionsEvent", &gifts).as_deref(), Some("big gifted 3 subscriptions"));
|
||||
|
||||
let raid = serde_json::json!({ "host_username": "friend", "number_viewers": 42 });
|
||||
assert_eq!(describe_action("StreamHostEvent", &raid).as_deref(), Some("friend raided with 42 viewers"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn takes_the_name_from_wherever_the_event_put_it() {
|
||||
// These events disagree about where the person goes, and an event
|
||||
// whose shape has moved on should be skipped rather than reported with
|
||||
// a hole in it.
|
||||
let nested = serde_json::json!({ "sender": { "username": "nested" }, "reward_title": "X" });
|
||||
assert_eq!(describe_action("RewardRedeemedEvent", &nested).as_deref(), Some("nested redeemed X"));
|
||||
|
||||
let nameless = serde_json::json!({ "reward_title": "X" });
|
||||
assert_eq!(describe_action("RewardRedeemedEvent", &nameless), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_events_that_are_not_somebody_doing_something() {
|
||||
let p = serde_json::json!({ "username": "someone" });
|
||||
assert_eq!(describe_action("App\\Events\\MessageDeletedEvent", &p), None);
|
||||
assert_eq!(describe_action("App\\Events\\ChatroomUpdatedEvent", &p), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_subscription_is_reported_once() {
|
||||
// Measured on a live subathon: subscribing once produces all three of
|
||||
// these, on three different subscriptions. Only the first says
|
||||
// anything the other two do not, so only the first is believed - and
|
||||
// the other two are named rather than left to a suffix test, which is
|
||||
// what reported the same person subscribing twice.
|
||||
let sub = serde_json::json!({ "chatroom_id": 2393554, "username": "doni433", "months": 1 });
|
||||
assert_eq!(
|
||||
describe_action("App\\Events\\SubscriptionEvent", &sub).as_deref(),
|
||||
Some("doni433 subscribed")
|
||||
);
|
||||
let same = serde_json::json!({ "user_ids": [584331], "username": "doni433", "channel_id": 2401072 });
|
||||
assert_eq!(describe_action("App\\Events\\ChannelSubscriptionEvent", &same), None);
|
||||
let info = serde_json::json!({ "message": { "action": "subscribe" }, "user": { "username": "doni433" } });
|
||||
assert_eq!(describe_action("App\\Events\\ChatMessageSentEvent", &info), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_a_real_gifted_subscription_payload() {
|
||||
// Copied from the wire, prefix and all - this one arrives with no
|
||||
// `App\Events\` on it at all, which is why the name is taken from the
|
||||
// last segment rather than assumed to have one.
|
||||
let p = serde_json::json!({
|
||||
"chatroom_id": 2393554,
|
||||
"gifted_usernames": ["Interstelar", "Ron_OSRS", "Auzzzii", "MeowtimeJay", "stradivarius31"],
|
||||
"gifter_username": "ItsDez",
|
||||
"gifted_total": 5
|
||||
});
|
||||
assert_eq!(
|
||||
describe_action("GiftedSubscriptionsEvent", &p).as_deref(),
|
||||
Some("ItsDez gifted 5 subscriptions")
|
||||
);
|
||||
}
|
||||
|
||||
#[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("chatrooms.abc"), None);
|
||||
assert_eq!(id_in_subscription("something-else"), None);
|
||||
}
|
||||
}
|
||||
@@ -3,5 +3,6 @@ pub mod discord;
|
||||
pub mod discord_voice;
|
||||
pub mod irc;
|
||||
pub mod irc_dcc;
|
||||
pub mod kick;
|
||||
pub mod matrix;
|
||||
pub mod sockchat;
|
||||
|
||||
@@ -192,6 +192,9 @@ async fn main() -> Result<()> {
|
||||
for cfg in state.accounts.all_matrix() {
|
||||
backend::matrix::spawn(state.clone(), cfg);
|
||||
}
|
||||
for cfg in state.accounts.all_kick() {
|
||||
backend::kick::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.
|
||||
|
||||
@@ -48,8 +48,21 @@ pub async fn dispatch(
|
||||
(Some(serde_json::to_value(groups).unwrap()), None)
|
||||
}
|
||||
|
||||
// Whatever this conversation lets you put in a message that is not
|
||||
// text. Two services answer it and they answer differently - Discord's
|
||||
// emoji carry their picture in the token, while Kick's have to be
|
||||
// named alongside a URL and a verdict on whether this account may send
|
||||
// them - but the question the client is asking is the same one, so it
|
||||
// stays one method rather than becoming one per protocol.
|
||||
"listBufferEmoji" => match p_str_opt(params, "bufferId") {
|
||||
None => (None, Some("listBufferEmoji requires \"bufferId\"".to_string())),
|
||||
Some(buffer_id) if buffer_id.starts_with("kick:") => match state.runtime.kick_channel(buffer_id) {
|
||||
None => (Some(serde_json::json!([])), None),
|
||||
Some(channel) => (
|
||||
Some(serde_json::json!(backend::kick::emotes::offer(&channel.emotes, channel.subscribed))),
|
||||
None,
|
||||
),
|
||||
},
|
||||
Some(buffer_id) => (Some(serde_json::json!(state.runtime.get_discord_buffer_emojis(buffer_id))), None),
|
||||
},
|
||||
|
||||
@@ -102,6 +115,7 @@ pub async fn dispatch(
|
||||
{ "id": "matrix", "name": "Matrix", "available": true },
|
||||
{ "id": "discord", "name": "Discord", "available": true },
|
||||
{ "id": "sockchat", "name": "Sneedchat", "available": true },
|
||||
{ "id": "kick", "name": "Kick", "available": true },
|
||||
{ "id": "jabber", "name": "XMPP", "available": false },
|
||||
{ "id": "slack", "name": "Slack", "available": false },
|
||||
])),
|
||||
@@ -275,6 +289,14 @@ pub async fn dispatch(
|
||||
}
|
||||
None => (None, Some("no such account".to_string())),
|
||||
}
|
||||
} else if id.starts_with("kick:") {
|
||||
match state.accounts.get_kick(id) {
|
||||
Some(cfg) => {
|
||||
backend::kick::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) => {
|
||||
@@ -323,6 +345,29 @@ pub async fn dispatch(
|
||||
(Some(a), Some(n)) => (a, n),
|
||||
_ => return (None, Some("joinBuffer requires a known \"accountId\" and \"name\"".to_string())),
|
||||
};
|
||||
// Kick has no join to perform: a channel is a streamer, and naming
|
||||
// one is the whole of it. So this is "start watching", which the
|
||||
// connection does by subscribing its existing socket - and the
|
||||
// handle is remembered, so it comes back tomorrow.
|
||||
if id.starts_with("kick:") {
|
||||
let slug = backend::kick::api::normalise_slug(name);
|
||||
if slug.is_empty() {
|
||||
return (None, Some(format!("\"{name}\" is not a Kick handle")));
|
||||
}
|
||||
let Some(sender) = state.runtime.kick_sender(id) else {
|
||||
return (None, Some("that Kick account is not connected".to_string()));
|
||||
};
|
||||
if sender.send(backend::kick::Command::Join(slug.clone())).is_err() {
|
||||
return (None, Some("that Kick account is not connected".to_string()));
|
||||
}
|
||||
if let Some(mut cfg) = state.accounts.get_kick(id) {
|
||||
if !cfg.channels.contains(&slug) {
|
||||
cfg.channels.push(slug);
|
||||
let _ = state.accounts.set_kick_channels(id, cfg.channels);
|
||||
}
|
||||
}
|
||||
return (Some(ok_node()), None);
|
||||
}
|
||||
match state.runtime.irc_sender(id) {
|
||||
None => (None, Some("join failed (account not connected?)".to_string())),
|
||||
Some(sender) => match sender.send_join(name) {
|
||||
@@ -1093,6 +1138,18 @@ pub async fn dispatch(
|
||||
// are a fixed list rather than something joined.
|
||||
if let Some(sender) = state.runtime.irc_sender(&buffer.account_id) {
|
||||
let _ = sender.send_part(&buffer.name);
|
||||
} else if buffer.account_id.starts_with("kick:") {
|
||||
// Nothing to leave - watching a streamer is not a
|
||||
// membership - so this unsubscribes the socket and
|
||||
// forgets the handle, which is all "closing" can
|
||||
// mean here.
|
||||
if let Some(sender) = state.runtime.kick_sender(&buffer.account_id) {
|
||||
let _ = sender.send(backend::kick::Command::Part(buffer.name.clone()));
|
||||
}
|
||||
if let Some(cfg) = state.accounts.get_kick(&buffer.account_id) {
|
||||
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("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 {
|
||||
@@ -1218,6 +1275,31 @@ pub async fn dispatch(
|
||||
}
|
||||
}
|
||||
},
|
||||
Some(buffer) if buffer.account_id.starts_with("kick:") => {
|
||||
let Some(cfg) = state.accounts.get_kick(&buffer.account_id) else {
|
||||
return (None, Some("account not connected".to_string()));
|
||||
};
|
||||
// The one thing a signed-out Kick account cannot do. Said
|
||||
// plainly, because "not connected" would be a lie - it is
|
||||
// connected, and reading fine.
|
||||
let Some(token) = cfg.token.as_deref().filter(|t| !t.is_empty()) else {
|
||||
return (None, Some("sign in to Kick from Accounts to talk in chat".to_string()));
|
||||
};
|
||||
let Some(channel) = state.runtime.kick_channel(buffer_id) else {
|
||||
return (None, Some("that channel is not being watched".to_string()));
|
||||
};
|
||||
// Kick has no attachments in chat and no threaded replies;
|
||||
// both are ignored rather than refused, the same way every
|
||||
// other protocol-specific field is where it does not apply.
|
||||
let http = match backend::kick::api::client() {
|
||||
Ok(c) => c,
|
||||
Err(e) => return (None, Some(e.to_string())),
|
||||
};
|
||||
match backend::kick::api::send_message(&http, token, channel.chatroom_id, body).await {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
}
|
||||
Some(buffer) if buffer.account_id.starts_with("sockchat:") => {
|
||||
// Sneedchat answers somebody by name rather than by
|
||||
// message id, so a reply needs whoever wrote the message
|
||||
@@ -1581,6 +1663,53 @@ pub async fn dispatch(
|
||||
(Some(serde_json::json!({ "loginId": login_id })), None)
|
||||
}
|
||||
|
||||
// Adds a Kick account, signed in or not.
|
||||
//
|
||||
// The token is optional and that is the interesting part: Kick's chat
|
||||
// is public, so an account with no credential reads every channel it
|
||||
// is pointed at. Somebody who only wants to watch never has to sign in
|
||||
// at all, and one who does gets sending and their subscriber emotes.
|
||||
//
|
||||
// Synchronous, unlike Discord's and Matrix's logins: there is nothing
|
||||
// to wait for. The browser window already did the signing in (see
|
||||
// moho's browser-login), so all this does is check who the token
|
||||
// belongs to and start the connection.
|
||||
"addKickAccount" => {
|
||||
let token = p_str_opt(params, "token")
|
||||
.map(backend::kick::api::bare_token)
|
||||
.filter(|t| !t.is_empty());
|
||||
let http = match backend::kick::api::client() {
|
||||
Ok(c) => c,
|
||||
Err(e) => return (None, Some(e.to_string())),
|
||||
};
|
||||
let username = match &token {
|
||||
Some(token) => match backend::kick::api::identity(&http, token).await {
|
||||
Ok(who) => who.username.unwrap_or_default(),
|
||||
Err(e) => return (None, Some(format!("{e:#}"))),
|
||||
},
|
||||
// A name rather than an empty one, because it becomes half the
|
||||
// account id and shows in the client as who this account is.
|
||||
None => "viewer".to_string(),
|
||||
};
|
||||
let existing = state.accounts.get_kick(&format!("kick:{username}"));
|
||||
let config = crate::accounts::KickAccountConfig {
|
||||
username,
|
||||
token,
|
||||
// Signing in again keeps what was being watched. Losing a
|
||||
// channel list to a re-login would be a poor trade for a
|
||||
// refreshed token.
|
||||
channels: existing.map(|e| e.channels).unwrap_or_default(),
|
||||
};
|
||||
match state.accounts.add_kick(config) {
|
||||
Ok(saved) => {
|
||||
let id = saved.account_id();
|
||||
backend::kick::spawn(state.clone(), saved);
|
||||
(Some(serde_json::json!({ "accountId": id })), None)
|
||||
}
|
||||
Err(e) => (None, Some(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
// Session verification (SAS - "compare emoji") + recovery key -
|
||||
// see backend/matrix/verification.rs's module doc for the flow.
|
||||
// Self-verification only: accountId always names *our own* Matrix
|
||||
|
||||
@@ -130,6 +130,18 @@ pub const DCC_KEEP: usize = 100;
|
||||
/// real prompt among fifty fake ones at worst.
|
||||
const DCC_PENDING_PER_NICK: usize = 3;
|
||||
|
||||
/// One Kick channel this daemon is watching.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct KickChannel {
|
||||
pub slug: String,
|
||||
/// The room the websocket subscribed to and messages are posted to.
|
||||
pub chatroom_id: u64,
|
||||
/// Whether this account is subscribed to this streamer, which decides
|
||||
/// only which emotes the picker offers - see backend::kick::emotes.
|
||||
pub subscribed: bool,
|
||||
pub emotes: Vec<crate::backend::kick::api::Emote>,
|
||||
}
|
||||
|
||||
/// Somebody in a voice channel, as a call view needs them.
|
||||
///
|
||||
/// A struct rather than the tuple this used to be: it grew a picture, and a
|
||||
@@ -334,6 +346,11 @@ pub struct Runtime {
|
||||
/// sending has to target the specific room's own socket rather than
|
||||
/// one shared per-account sender.
|
||||
sockchat_senders: Mutex<HashMap<String, HashMap<u32, tokio::sync::mpsc::UnboundedSender<String>>>>,
|
||||
/// 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>>>,
|
||||
/// What is known about each watched Kick channel, by buffer id.
|
||||
kick_channels: Mutex<HashMap<String, KickChannel>>,
|
||||
/// Matrix-specific: buffer id -> room id. Matrix buffer *names* are
|
||||
/// human-friendly (see backend/matrix/rooms.rs's naming fallback
|
||||
/// chain), but sending/reacting/etc. needs the real `!opaque:server`
|
||||
@@ -511,6 +528,8 @@ impl Runtime {
|
||||
discord_buffer_emojis: Mutex::new(HashMap::new()),
|
||||
discord_friends: Mutex::new(HashMap::new()),
|
||||
sockchat_senders: Mutex::new(HashMap::new()),
|
||||
kick_senders: Mutex::new(HashMap::new()),
|
||||
kick_channels: Mutex::new(HashMap::new()),
|
||||
matrix_rooms: Mutex::new(HashMap::new()),
|
||||
matrix_room_names: Mutex::new(HashMap::new()),
|
||||
matrix_space_parents: Mutex::new(HashMap::new()),
|
||||
@@ -586,6 +605,11 @@ impl Runtime {
|
||||
let conn = conn_states.get(&id).map(|s| s.as_str()).unwrap_or("disconnected");
|
||||
crate::accounts::matrix_account_to_json(cfg, conn, self.has_matrix_backup(&id))
|
||||
}));
|
||||
out.extend(state.accounts.all_kick().iter().map(|cfg| {
|
||||
let id = cfg.account_id();
|
||||
let conn = conn_states.get(&id).map(|s| s.as_str()).unwrap_or("disconnected");
|
||||
crate::accounts::kick_account_to_json(cfg, conn)
|
||||
}));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -1842,6 +1866,36 @@ impl Runtime {
|
||||
/// account-level connection is being rebuilt from scratch (see
|
||||
/// backend::sockchat::run_with_retry), so a stale sender from a
|
||||
/// pre-restart room task can't be mistaken for a live one.
|
||||
/// What this daemon knows about one watched Kick channel.
|
||||
///
|
||||
/// Held rather than re-fetched because all three parts are answers to
|
||||
/// questions asked constantly and changing rarely: which room to post to,
|
||||
/// what the emote picker should show, and whether this account may use the
|
||||
/// subscriber half of it.
|
||||
pub fn set_kick_channel(&self, buffer_id: &str, channel: KickChannel) {
|
||||
self.kick_channels.lock().unwrap().insert(buffer_id.to_string(), channel);
|
||||
}
|
||||
|
||||
pub fn kick_channel(&self, buffer_id: &str) -> Option<KickChannel> {
|
||||
self.kick_channels.lock().unwrap().get(buffer_id).cloned()
|
||||
}
|
||||
|
||||
pub fn forget_kick_channel(&self, buffer_id: &str) {
|
||||
self.kick_channels.lock().unwrap().remove(buffer_id);
|
||||
}
|
||||
|
||||
pub fn set_kick_sender(&self, account_id: &str, sender: tokio::sync::mpsc::UnboundedSender<crate::backend::kick::Command>) {
|
||||
self.kick_senders.lock().unwrap().insert(account_id.to_string(), sender);
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
pub fn clear_kick_sender(&self, account_id: &str) {
|
||||
self.kick_senders.lock().unwrap().remove(account_id);
|
||||
}
|
||||
|
||||
pub fn clear_sockchat_senders(&self, account_id: &str) {
|
||||
self.sockchat_senders.lock().unwrap().remove(account_id);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user