Kick chat talks in 7TV emotes, so read them

Most of what a Kick channel says was arriving as bare words. Channels
install a 7TV set and the chat then talks in it, and the words are not even
the right words - a 7TV name is chosen to be typed, not read.

Only 7TV, and the issue was wrong to name three. Checked rather than
assumed: `api.betterttv.net/3/cached/users/kick/<id>` answers 404 where the
same path with `twitch` answers 200, and FrankerFaceZ has no Kick route at
all. Both are Twitch products that never followed, and naming them would
have promised something that cannot be delivered.

The part that would have failed silently: 7TV keys its sets by Kick's *user*
id, not the channel id, and they are different numbers - xqc is user 676 and
channel 668, and asking with 668 answers 404 rather than an empty set. moho
stored only the channel id, so `user_id` had to be captured first; without
it this would have looked like a client where no channel has any emotes.

Fetched per channel because that is what they are, global set first and the
channel's own on top so a channel overriding a global name gets its own
picture. Best-effort and quiet: most channels have no 7TV set and answer
404, which is ordinary rather than a failure, and chat reads exactly as it
did before when there are none.

Closes #195.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-12 11:02:58 -04:00
co-authored by Claude Opus 5
parent d584492822
commit 5d408bdc93
6 changed files with 203 additions and 2 deletions
+9
View File
@@ -48,6 +48,9 @@ pub fn client() -> Result<reqwest::Client> {
#[derive(Debug, Clone)]
pub struct Channel {
pub id: u64,
/// The person behind the channel. Not `id`: for xqc those are 676 and 668,
/// and 7TV answers only to the first.
pub user_id: Option<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,
@@ -84,6 +87,11 @@ pub struct Live {
#[derive(Deserialize)]
struct ChannelJson {
id: u64,
/// The person behind the channel, which is a different number from `id`.
/// 7TV keys its emote sets by this one - see seventv.rs, where asking with
/// the channel id answers 404.
#[serde(default)]
user_id: Option<u64>,
slug: String,
chatroom: ChatroomJson,
user: Option<UserJson>,
@@ -206,6 +214,7 @@ pub async fn channel(http: &reqwest::Client, slug: &str) -> Result<Channel> {
Ok(Channel {
id: json.id,
user_id: json.user_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),
+1
View File
@@ -34,6 +34,7 @@ pub mod cards;
pub mod chat;
pub mod live;
pub mod pins;
pub mod seventv;
pub mod socket;
#[cfg(test)]
mod testkit;
+145
View File
@@ -0,0 +1,145 @@
//! 7TV emotes, which is most of what a Kick channel actually says.
//!
//! ## Why only 7TV
//!
//! The issue this answers named three providers. Only one of them serves Kick:
//! `api.betterttv.net/3/cached/users/kick/<id>` answers 404 where the same
//! path with `twitch` answers 200, and FrankerFaceZ has no Kick route at all.
//! Both are Twitch products that never followed. So this is 7TV, and naming
//! the other two would be promising something that cannot be delivered.
//!
//! ## The id that matters
//!
//! 7TV keys its sets by Kick's **user** id, not the channel id, and those are
//! different numbers: xqc is user 676 and channel 668, and asking with 668
//! answers 404. moho stored only the channel id, which is why `user_id` had to
//! be captured before any of this could work - a lookup with the wrong number
//! would have failed silently and looked like a channel with no emotes.
//!
//! ## Why these are not Kick emotes
//!
//! A Kick emote arrives in the message as `[emote:1082364:xqcAM]`, carrying
//! its own id, so it resolves anywhere. A 7TV emote arrives as the bare word
//! `xqcL` and means nothing without the channel's set - it is a word that
//! happens to be a picture in one room and ordinary text everywhere else.
//! That is why these are kept per channel and substituted by name.
use anyhow::{Context, Result};
use std::collections::BTreeMap;
/// Everybody's emotes, wherever they are. Fetched once and shared: the global
/// set is the same for every channel, and a request per channel for identical
/// content is a request wasted.
const GLOBAL_SET: &str = "https://7tv.io/v3/emote-sets/global";
/// Which size to ask for. 2x is what other clients draw inline - 1x is
/// visibly soft on a modern display, and 4x is four times the bytes for
/// something rendered twenty pixels tall.
const SIZE: &str = "2x.webp";
/// How many emotes to take from one set.
///
/// A cap rather than a limit anybody should hit: xqc's channel has 965, which
/// is fine, but the field is attacker-controlled in the sense that a channel
/// owner chooses it, and an unbounded map is sent to every window.
const MAX_EMOTES: usize = 2000;
/// Reads a 7TV emote list into name -> picture.
///
/// The host URL arrives protocol-relative (`//cdn.7tv.app/...`), which is a
/// convention from when pages could be http; it is made absolute here so the
/// window never has to know that.
pub fn read_set(emotes: &serde_json::Value) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
for emote in emotes.as_array().into_iter().flatten().take(MAX_EMOTES) {
let Some(name) = emote["name"].as_str().filter(|n| !n.is_empty()) else { continue };
let Some(host) = emote["data"]["host"]["url"].as_str().filter(|u| !u.is_empty()) else { continue };
let host = host.strip_prefix("//").map(|h| format!("https://{h}")).unwrap_or_else(|| host.to_string());
out.insert(name.to_string(), format!("{host}/{SIZE}"));
}
out
}
async fn fetch(http: &reqwest::Client, url: &str) -> Result<serde_json::Value> {
let resp = http.get(url).send().await.context("asking 7TV")?;
if !resp.status().is_success() {
anyhow::bail!("7TV answered {}", resp.status());
}
resp.json().await.context("unreadable answer from 7TV")
}
/// The emotes everybody has.
pub async fn global(http: &reqwest::Client) -> BTreeMap<String, String> {
match fetch(http, GLOBAL_SET).await {
Ok(v) => read_set(&v["emotes"]),
Err(e) => {
tracing::debug!("kick: 7TV global emotes: {e}");
BTreeMap::new()
}
}
}
/// One channel's own, on top of the global ones.
///
/// A channel with no 7TV link answers 404, which is the ordinary case rather
/// than a failure - most channels have none, and a warning per channel per
/// connect would be noise for a thing that is working correctly.
pub async fn for_channel(http: &reqwest::Client, user_id: u64) -> BTreeMap<String, String> {
match fetch(http, &format!("https://7tv.io/v3/users/kick/{user_id}")).await {
Ok(v) => read_set(&v["emote_set"]["emotes"]),
Err(e) => {
tracing::debug!("kick: 7TV emotes for user {user_id}: {e}");
BTreeMap::new()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn set() -> serde_json::Value {
json!([
{ "name": "GAMBA", "data": { "host": { "url": "//cdn.7tv.app/emote/01G3W" } } },
{ "name": "xqcL", "data": { "host": { "url": "//cdn.7tv.app/emote/01ABC" } } }
])
}
/// The shape 7TV actually answers with, read off a live response.
#[test]
fn reads_a_set_into_words_and_pictures() {
let got = read_set(&set());
assert_eq!(got.get("GAMBA").unwrap(), "https://cdn.7tv.app/emote/01G3W/2x.webp");
assert_eq!(got.len(), 2);
}
/// Protocol-relative is how 7TV writes it, and the window should never
/// have to know that.
#[test]
fn a_protocol_relative_host_is_made_absolute() {
assert!(read_set(&set()).values().all(|u| u.starts_with("https://")));
// One that is already absolute is left alone.
let absolute = json!([{ "name": "a", "data": { "host": { "url": "https://example.net/e" } } }]);
assert_eq!(read_set(&absolute).get("a").unwrap(), "https://example.net/e/2x.webp");
}
/// An entry missing either half is not half an emote.
#[test]
fn an_unusable_entry_is_skipped_rather_than_guessed_at() {
let odd = json!([
{ "name": "", "data": { "host": { "url": "//cdn/x" } } },
{ "name": "noHost" },
{ "name": "ok", "data": { "host": { "url": "//cdn/y" } } }
]);
let got = read_set(&odd);
assert_eq!(got.len(), 1);
assert!(got.contains_key("ok"));
}
#[test]
fn nothing_at_all_is_no_emotes_rather_than_a_panic() {
assert!(read_set(&json!(null)).is_empty());
assert!(read_set(&json!([])).is_empty());
}
}
+27 -2
View File
@@ -380,6 +380,8 @@ async fn prepare(
channel_id: channel.id,
history_cursor: None,
chatroom_id: channel.chatroom_id,
user_id: channel.user_id,
word_emotes: Default::default(),
subscribed: false,
followers_only: channel.followers_only,
subscribers_only: channel.subscribers_only,
@@ -388,6 +390,29 @@ async fn prepare(
},
);
// The words this channel talks in that Kick knows nothing about. Fetched
// per channel because they are per channel: a 7TV emote is a bare word
// that is a picture in one room and ordinary text in the next.
//
// Best-effort and quiet. Most channels have no 7TV set and answer 404,
// which is the ordinary case rather than a failure, and chat reads fine
// without them - which is exactly how it read before this existed.
if let Some(user_id) = channel.user_id {
let http = http.clone();
let state = state.clone();
let buffer_id = buffer.id.clone();
tokio::spawn(async move {
let mut words = seventv::global(&http).await;
// The channel's own on top, so a channel that overrides a global
// name gets its own picture rather than everybody's.
words.extend(seventv::for_channel(&http, user_id).await);
if !words.is_empty() {
tracing::debug!("kick[{buffer_id}]: {} 7TV emote(s)", words.len());
state.runtime.set_kick_word_emotes(&state, &buffer_id, words);
}
});
}
// What is on air. A Kick channel is a stream as much as a chat, and moho
// showed only the chat - so the title, the game and how many people are
// watching, which is most of what a viewer wants to know, were nowhere.
@@ -680,12 +705,12 @@ mod tests {
// 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(),
id: 999, user_id: None, chatroom_id: 111, slug: "first".into(), username: "first".into(),
avatar_url: None, subscribers_only: false, followers_only: false, live: None, followers: None,
playback_url: None,
});
w.add(&api::Channel {
id: 222, chatroom_id: 999, slug: "second".into(), username: "second".into(),
id: 222, user_id: None, chatroom_id: 999, slug: "second".into(), username: "second".into(),
avatar_url: None, subscribers_only: false, followers_only: false, live: None, followers: None,
playback_url: None,
});
+1
View File
@@ -58,6 +58,7 @@ pub(super) fn watching_odablock() -> Watched {
let mut w = Watched::default();
w.add(&api::Channel {
id: 2401072,
user_id: None,
chatroom_id: 2393554,
slug: "odablock".into(),
username: "odablock".into(),
+20
View File
@@ -209,6 +209,12 @@ pub struct KickChannel {
pub history_cursor: Option<String>,
/// The room the websocket subscribed to and messages are posted to.
pub chatroom_id: u64,
/// The person behind the channel, which 7TV keys its emote sets by - a
/// different number from `channel_id`, and the only one 7TV answers to.
pub user_id: Option<u64>,
/// Emotes this channel's viewers talk in that Kick knows nothing about,
/// by the word that stands for them - see backend/kick/seventv.rs.
pub word_emotes: std::collections::BTreeMap<String, String>,
/// Whether this account is subscribed to this streamer, which decides
/// only which emotes the picker offers - see backend::kick::emotes.
pub subscribed: bool,
@@ -3080,6 +3086,20 @@ impl Runtime {
state.events.emit("bufferListChange", serde_json::to_value(&updated).unwrap());
}
/// Records a channel's 7TV emotes and tells the windows, which need them
/// to turn a word in a message into the picture it stands for.
pub fn set_kick_word_emotes(&self, state: &AppState, buffer_id: &str, words: std::collections::BTreeMap<String, String>) {
{
let mut all = self.kick_channels.lock().unwrap();
let Some(channel) = all.get_mut(buffer_id) else { return };
channel.word_emotes = words.clone();
}
state.events.emit(
"kickWordEmotes",
serde_json::json!({ "bufferId": buffer_id, "emotes": words }),
);
}
pub fn set_kick_history_cursor(&self, buffer_id: &str, cursor: Option<String>) {
if let Some(channel) = self.kick_channels.lock().unwrap().get_mut(buffer_id) {
channel.history_cursor = cursor;