A new Kick account arrives already following what you follow
Read once, on the first connect, and marked so it never happens again. That flag is the whole design: a follow list re-read on every connect would put back every channel the person had closed, which is the one thing closing a channel is supposed to mean. After the first sync the list is theirs. The endpoint needed care. `/api/v2/channels/followed` is the follow list; `/api/v1/channels/followed` answers 200 to an anonymous request and looks like it works, because there is a real streamer whose handle is literally "followed" - so the v1 path quietly returns one stranger's channel object instead of anybody's follows. Wrong in a way that survives testing. Parsing is tolerant of the wrapper and strict about the result: Kick has shipped this list bare, under `data`, and under `channels`, with the handle either at the top of an item or one level down. An entry that yields no handle costs that entry rather than the other forty, and every survivor goes through the same gate a typed handle does, so nothing off the network reaches a URL unchecked. Emotes and the subscription check moved off the connect path, because fifty channels at three round trips each meant a hundred and fifty requests before a single message arrived. Neither is needed that early - the room id is what a message needs to land, and the picker is opened seconds later at the earliest - so they fill in behind the chat. Capped at fifty. Somebody can follow hundreds, each costs a buffer, four subscriptions and an emote table, and the rest are a handle away. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -193,6 +193,14 @@ pub struct KickAccountConfig {
|
||||
pub token: Option<String>,
|
||||
#[serde(default)]
|
||||
pub channels: Vec<String>,
|
||||
/// Whether this account's follows have been read into `channels` yet.
|
||||
///
|
||||
/// Once, not on every connect, and that is the whole point of the flag: a
|
||||
/// follow list seeded repeatedly would undo closing a channel, which is
|
||||
/// the one thing closing it is supposed to mean. After the first sync the
|
||||
/// list belongs to whoever is using it.
|
||||
#[serde(default)]
|
||||
pub followed_synced: bool,
|
||||
}
|
||||
|
||||
impl KickAccountConfig {
|
||||
@@ -381,6 +389,19 @@ impl AccountStore {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Records that this account's follows have been read in, so they are not
|
||||
/// read in again - see `KickAccountConfig::followed_synced`.
|
||||
pub fn mark_kick_follows_synced(&self, account_id: &str) -> Result<bool> {
|
||||
let mut kick = self.kick.lock().unwrap();
|
||||
let Some(config) = kick.get_mut(account_id) else { return Ok(false) };
|
||||
if config.followed_synced {
|
||||
return Ok(false);
|
||||
}
|
||||
config.followed_synced = true;
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sockchat.lock().unwrap(), &self.matrix.lock().unwrap(), &kick)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Remembers which streamers this account watches.
|
||||
///
|
||||
/// Persisted rather than rebuilt from open buffers, for the same reason
|
||||
|
||||
@@ -271,6 +271,65 @@ pub async fn identity(http: &reqwest::Client, token: &str) -> Result<Identity> {
|
||||
Ok(who)
|
||||
}
|
||||
|
||||
/// The channels this account follows, as handles.
|
||||
///
|
||||
/// `/api/v2/channels/followed`, which is the only one of these that is what it
|
||||
/// looks like. `/api/v1/channels/followed` answers 200 to an anonymous request
|
||||
/// and looks like it works - it is a *channel* lookup, and there is a real
|
||||
/// streamer whose handle is literally "followed", so the v1 path quietly
|
||||
/// returns one stranger's channel instead of anybody's follow list. That is
|
||||
/// the kind of wrong answer that survives testing.
|
||||
///
|
||||
/// Tolerant about the wrapper because this list is only ever read: Kick has
|
||||
/// shipped it bare, under `data`, and under `channels`, and each item names its
|
||||
/// slug either directly or one level down. Anything that does not yield a
|
||||
/// handle is skipped rather than failing the whole list - one unfamiliar entry
|
||||
/// should cost that entry, not the other forty.
|
||||
pub async fn followed(http: &reqwest::Client, token: &str) -> Result<Vec<String>> {
|
||||
let res = http
|
||||
.get(format!("{API_ROOT}/api/v2/channels/followed?page=1&limit=100"))
|
||||
.header("Accept", "application/json")
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.context("asking Kick which channels this account follows")?;
|
||||
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 {} for this account's follows", res.status());
|
||||
}
|
||||
let body: serde_json::Value = res.json().await.context("reading this account's follows")?;
|
||||
Ok(slugs_in(&body))
|
||||
}
|
||||
|
||||
/// Every handle in a follow list, whatever shape the list arrived in.
|
||||
fn slugs_in(body: &serde_json::Value) -> Vec<String> {
|
||||
let items = body
|
||||
.as_array()
|
||||
.or_else(|| body.get("data").and_then(|v| v.as_array()))
|
||||
.or_else(|| body.get("channels").and_then(|v| v.as_array()))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut out = Vec::new();
|
||||
for item in items {
|
||||
let raw = item
|
||||
.get("slug")
|
||||
.or_else(|| item.get("channel").and_then(|c| c.get("slug")))
|
||||
.or_else(|| item.get("channel_slug"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
// Through the same gate a typed handle goes through, so nothing from
|
||||
// the network reaches a URL without being a handle first.
|
||||
let slug = normalise_slug(raw);
|
||||
if !slug.is_empty() && !out.contains(&slug) {
|
||||
out.push(slug);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// What this account is to one channel.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct Standing {
|
||||
@@ -492,6 +551,43 @@ mod tests {
|
||||
assert_eq!(bare_token("bearerish"), "bearerish");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_a_follow_list_in_any_of_its_shapes() {
|
||||
let bare = serde_json::json!([{ "slug": "tayl31gh" }, { "slug": "odablock" }]);
|
||||
assert_eq!(slugs_in(&bare), vec!["tayl31gh", "odablock"]);
|
||||
|
||||
let wrapped = serde_json::json!({ "data": [{ "slug": "tayl31gh" }] });
|
||||
assert_eq!(slugs_in(&wrapped), vec!["tayl31gh"]);
|
||||
|
||||
let named = serde_json::json!({ "channels": [{ "slug": "tayl31gh" }] });
|
||||
assert_eq!(slugs_in(&named), vec!["tayl31gh"]);
|
||||
|
||||
// The handle one level down, which is how a list of livestreams reads.
|
||||
let nested = serde_json::json!({ "data": [{ "channel": { "slug": "odablock" } }] });
|
||||
assert_eq!(slugs_in(&nested), vec!["odablock"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_follow_list_that_is_not_one_yields_nothing() {
|
||||
assert!(slugs_in(&serde_json::json!({ "message": "Unauthenticated." })).is_empty());
|
||||
assert!(slugs_in(&serde_json::json!(null)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_unusable_entry_does_not_cost_the_others() {
|
||||
let mixed = serde_json::json!([
|
||||
{ "slug": "good" },
|
||||
{ "nothing": "here" },
|
||||
{ "slug": "../admin" },
|
||||
{ "slug": "also_good" },
|
||||
{ "slug": "good" }
|
||||
]);
|
||||
// Skipped, deduplicated, and every survivor is a real handle - the
|
||||
// same gate a typed one goes through, so nothing off the network
|
||||
// reaches a URL unchecked.
|
||||
assert_eq!(slugs_in(&mixed), vec!["good", "also_good"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_a_percent_encoded_csrf_token() {
|
||||
assert_eq!(percent_decode("abc%3D%3D"), "abc==");
|
||||
|
||||
+77
-11
@@ -70,6 +70,14 @@ pub fn spawn(state: AppState, config: KickAccountConfig) {
|
||||
state.runtime.insert_task_handle(&account_id, join_handle.abort_handle());
|
||||
}
|
||||
|
||||
/// How many followed channels to open on a first connect.
|
||||
///
|
||||
/// A cap rather than all of them, because somebody can follow hundreds and
|
||||
/// each one costs a buffer, four subscriptions and its own emote table. Fifty
|
||||
/// is more channels than anyone watches at once and still opens quickly; the
|
||||
/// rest are a handle away in the "+" box.
|
||||
const MAX_FOLLOWED: usize = 50;
|
||||
|
||||
const RECONNECT_INITIAL_DELAY: Duration = Duration::from_secs(3);
|
||||
const RECONNECT_MAX_DELAY: Duration = Duration::from_secs(60);
|
||||
|
||||
@@ -126,6 +134,39 @@ async fn run(state: &AppState, config: &KickAccountConfig, account_id: &str) ->
|
||||
}
|
||||
}
|
||||
|
||||
// What this account already follows on Kick, the first time it connects.
|
||||
//
|
||||
// Once, and never again - the flag is persisted. A follow list re-read on
|
||||
// every connect would put back every channel the person had closed, which
|
||||
// is the one thing closing a channel is supposed to mean; after this, the
|
||||
// list is theirs.
|
||||
let mut channels = config.channels.clone();
|
||||
if let Some(token) = config.token.as_deref().filter(|t| !t.is_empty()) {
|
||||
if !config.followed_synced {
|
||||
match api::followed(&http, token).await {
|
||||
Ok(followed) => {
|
||||
let before = channels.len();
|
||||
for slug in followed.into_iter().take(MAX_FOLLOWED) {
|
||||
if !channels.contains(&slug) {
|
||||
channels.push(slug);
|
||||
}
|
||||
}
|
||||
let added = channels.len() - before;
|
||||
let _ = state.accounts.set_kick_channels(account_id, channels.clone());
|
||||
let _ = state.accounts.mark_kick_follows_synced(account_id);
|
||||
if added > 0 {
|
||||
state.runtime.report_progress(state, account_id, &format!("following {added} channels you already follow on Kick"));
|
||||
}
|
||||
}
|
||||
// Not fatal, and not retried on the next connect either -
|
||||
// marking it done anyway would be worse, since a person who
|
||||
// has since closed those channels would get them all back.
|
||||
// Reported, so a failure is visible rather than silent.
|
||||
Err(e) => state.runtime.report_progress(state, account_id, &format!("could not read your Kick follows: {e:#}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Command>();
|
||||
state.runtime.set_kick_sender(account_id, tx);
|
||||
|
||||
@@ -140,7 +181,7 @@ async fn run(state: &AppState, config: &KickAccountConfig, account_id: &str) ->
|
||||
// makes one map look like it works.
|
||||
let mut watched = Watched::default();
|
||||
|
||||
for slug in &config.channels {
|
||||
for slug in &channels {
|
||||
match prepare(state, &http, config, account_id, slug).await {
|
||||
Ok(channel) => {
|
||||
subscribe(&mut socket, channel.chatroom_id, channel.id).await?;
|
||||
@@ -276,6 +317,7 @@ async fn prepare(
|
||||
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
|
||||
@@ -283,20 +325,44 @@ async fn prepare(
|
||||
// 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 },
|
||||
crate::runtime::KickChannel {
|
||||
slug: channel.slug.clone(),
|
||||
chatroom_id: channel.chatroom_id,
|
||||
subscribed: false,
|
||||
emotes: Vec::new(),
|
||||
},
|
||||
);
|
||||
|
||||
// The emote table and this account's standing in the channel are fetched
|
||||
// in the background rather than before the subscription.
|
||||
//
|
||||
// They are two more round trips per channel, and connecting can open fifty
|
||||
// at once now that a first connect brings in what the account follows -
|
||||
// which made "connecting" take as long as a hundred and fifty requests
|
||||
// while no messages arrived at all. Nothing needs them to be there yet:
|
||||
// the room id above is what a message needs to land, and the emote picker
|
||||
// is opened seconds later at the earliest.
|
||||
tokio::spawn({
|
||||
let state = state.clone();
|
||||
let http = http.clone();
|
||||
let token = config.token.clone().filter(|t| !t.is_empty());
|
||||
let slug = channel.slug.clone();
|
||||
let buffer_id = buffer.id.clone();
|
||||
async move {
|
||||
// Whether this account may *use* the streamer's subscriber emotes.
|
||||
// Treated as "no" if Kick will not say, which costs a greyed-out
|
||||
// emote rather than a conversation.
|
||||
let subscribed = match token.as_deref() {
|
||||
None => false,
|
||||
Some(token) => api::standing(&http, token, &slug).await.map(|s| s.subscribed).unwrap_or(false),
|
||||
};
|
||||
let emotes = api::emotes(&http, &slug).await.unwrap_or_default();
|
||||
state.runtime.set_kick_emotes(&buffer_id, emotes, subscribed);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(channel)
|
||||
}
|
||||
|
||||
|
||||
+6
-3
@@ -1710,9 +1710,12 @@ pub async fn dispatch(
|
||||
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.
|
||||
// Signing in again keeps what was being watched, and keeps
|
||||
// knowing that the follows have already been read in. Losing
|
||||
// either to a re-login would be a poor trade for a refreshed
|
||||
// token - the second one would put back every channel the
|
||||
// person had closed.
|
||||
followed_synced: existing.as_ref().is_some_and(|e| e.followed_synced),
|
||||
channels: existing.map(|e| e.channels).unwrap_or_default(),
|
||||
};
|
||||
match state.accounts.add_kick(config) {
|
||||
|
||||
@@ -1876,6 +1876,20 @@ impl Runtime {
|
||||
self.kick_channels.lock().unwrap().insert(buffer_id.to_string(), channel);
|
||||
}
|
||||
|
||||
/// Fills in a channel's emote table once it has been fetched.
|
||||
///
|
||||
/// Separate from `set_kick_channel` because the two are known at different
|
||||
/// times on purpose: the room id is needed before the first message can
|
||||
/// land, while the emotes are only needed when somebody opens the picker.
|
||||
/// Matched on the buffer rather than replacing it wholesale, so a channel
|
||||
/// closed while its emotes were in flight stays closed.
|
||||
pub fn set_kick_emotes(&self, buffer_id: &str, emotes: Vec<crate::backend::kick::api::Emote>, subscribed: bool) {
|
||||
if let Some(channel) = self.kick_channels.lock().unwrap().get_mut(buffer_id) {
|
||||
channel.emotes = emotes;
|
||||
channel.subscribed = subscribed;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn kick_channel(&self, buffer_id: &str) -> Option<KickChannel> {
|
||||
self.kick_channels.lock().unwrap().get(buffer_id).cloned()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user