Remove 7TV: Kick does not render it, so neither should we

Reverts #195 and its emoji source.

7TV on Kick is a browser extension, not a Kick feature. Kick's own chat
shows the bare word, which means moho was drawing pictures where every
viewer without that extension sees text - and offering those words in the
picker, where sending one posts a word most of the channel reads as a word.

That is worse than not having the feature. A client that renders more than
the service does is not being generous, it is showing a different
conversation from the one everybody else is in, and only this client's
reader would be confused about which.

Out with it: the module, the per-channel fetch, the word map on KickChannel,
the `kickWordEmotes` event, and `user_id` on the Kick channel, which was
captured only because 7TV keyed on it and now has nothing to answer.

Kick's own `[emote:id:name]` is untouched. Those are Kick's, they render in
Kick, and they were never the thing in question.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-12 13:17:30 -04:00
co-authored by Claude Opus 5
parent 0a0ee0ff81
commit 7e15621f43
8 changed files with 12 additions and 240 deletions
-9
View File
@@ -48,9 +48,6 @@ 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,
@@ -87,11 +84,6 @@ 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>,
@@ -214,7 +206,6 @@ 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,7 +34,6 @@ 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
@@ -1,145 +0,0 @@
//! 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());
}
}
+2 -56
View File
@@ -380,8 +380,6 @@ 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,
@@ -390,58 +388,6 @@ 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();
let account_id = account_id.to_string();
let slug = channel.slug.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());
// Also a place emoji come from, so the picker offers them.
// The id is the word itself, because that is literally what
// goes in the message - a 7TV emote is a word that happens to
// be a picture, which is why it is tied to this buffer: it
// means nothing in a channel whose set does not have it.
state.runtime.set_emoji_source(
&account_id,
crate::model::EmojiSource {
id: format!("7tv:{slug}"),
name: format!("{slug} · 7TV"),
service: "kick".to_string(),
kind: "text".to_string(),
icon_url: None,
buffers: vec![buffer_id.clone()],
sendable_anywhere: false,
emoji: words
.iter()
.map(|(word, url)| crate::model::EmojiEntry {
id: word.clone(),
name: word.clone(),
url: Some(url.clone()),
animated: false,
locked: false,
})
.collect(),
},
);
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.
@@ -769,12 +715,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, user_id: None, chatroom_id: 111, slug: "first".into(), username: "first".into(),
id: 999, 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, user_id: None, chatroom_id: 999, slug: "second".into(), username: "second".into(),
id: 222, 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,7 +58,6 @@ 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(),
+4 -4
View File
@@ -155,8 +155,8 @@ pub fn escape_html(text: &str) -> String {
/// it would leave those clients reading an `<img>` tag as words.
///
/// The shortcode is matched with its colons, so a bare word that happens to
/// share a name with an emoticon is left alone - unlike 7TV, where the bare
/// word *is* the emote, Matrix's are written deliberately.
/// share a name with an emoticon is left alone: a Matrix emoticon is written
/// deliberately, which is what the colons are for.
pub fn inline_emoticons(html: &str, emoticons: &std::collections::BTreeMap<String, String>) -> String {
if emoticons.is_empty() || !html.contains(':') {
return html.to_string();
@@ -395,8 +395,8 @@ mod tests {
assert_eq!(inline_emoticons("nothing here", &codes()), "nothing here");
}
/// Unlike 7TV, where the bare word is the emote, Matrix's are written
/// deliberately - so a word that merely shares the name is not one.
/// Written deliberately, colons and all - so a word that merely shares
/// the name is not one.
#[test]
fn a_bare_word_is_not_an_emoticon() {
assert_eq!(inline_emoticons("the cat sat", &codes()), "the cat sat");
+6 -4
View File
@@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize};
/// One place an account's emoji come from: a Discord guild, a Kick channel,
/// Sneedchat's own table, 7TV's global set.
/// Sneedchat's own table, a Matrix emoticon pack.
///
/// A source rather than a flat list because the picker is organised by where
/// things came from - that is how somebody finds an emote they half remember,
@@ -29,7 +29,8 @@ pub struct EmojiSource {
/// is the thing a picker cannot guess.
///
/// `"text"` - the entry's `id` is written into the message being composed.
/// Discord, Kick, Sneedchat and 7TV all work this way: what is sent is a
/// Discord, Kick, Sneedchat and Matrix emoticons all work this way: what
/// is sent is a
/// run of characters the service recognises.
///
/// `"event"` - the entry is sent on its own, immediately, and never
@@ -43,7 +44,8 @@ pub struct EmojiSource {
#[serde(rename = "iconUrl", skip_serializing_if = "Option::is_none")]
pub icon_url: Option<String>,
/// Where these can be sent. Empty means anywhere this account can talk -
/// which is what Sneedchat's table and 7TV's global set are - and a
/// which is what Sneedchat's table and a Matrix account's own pack are -
/// and a
/// non-empty list names the buffers, for the ones that are tied to a
/// place unless a subscription says otherwise.
#[serde(rename = "buffers")]
@@ -58,7 +60,7 @@ pub struct EmojiSource {
#[derive(Serialize, Clone, Debug)]
pub struct EmojiEntry {
/// What goes in the message. Kick's `[emote:id:name]`, Discord's id, a
/// 7TV word, a Sneedchat shortcode - whatever this service sends.
/// a Sneedchat shortcode, a Matrix `:name:` - whatever this service reads.
pub id: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
-20
View File
@@ -209,12 +209,6 @@ 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,
@@ -3138,20 +3132,6 @@ 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;