What an account can send, rather than what a room owns

The daemon half of #205. `listBufferEmoji` answers "what does this channel
own", which is the right question for rendering a message and the wrong one
for a picker - on every service moho speaks an account can send emoji from
places it is not currently looking at.

So there is a second answer beside it, assembled per account from every
source: a Discord guild, a Kick channel, Sneedchat's own table. Each source
carries where it can be sent, and `listAllEmoji` works out whether it
reaches the conversation being asked about - a fact about the pair, so it is
computed rather than stored.

Two subscriptions decide most of it, and both are read rather than assumed:

- Discord's `premium_type` from READY, which is the only place the payload
  carries it. Any of Classic, Nitro or Basic buys cross-guild use; 0 or
  absent is none.
- Kick's per-channel standing, which was already fetched to decide which
  emotes the picker offers. A subscription there buys that channel's emotes
  across the whole site, so it sets `sendableAnywhere` rather than leaving
  them false everywhere but the room on screen.

An emote owned somewhere unpaid comes back `locked` rather than absent, the
way Kick's own list already worked: the picker is how somebody learns what
subscribing would buy, and hiding it answers a question by pretending it was
not asked.

Towards #205.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-12 12:24:11 -04:00
co-authored by Claude Opus 5
parent d4206362d4
commit c74ff95451
6 changed files with 226 additions and 0 deletions
+8
View File
@@ -309,6 +309,14 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
.unwrap_or("me")
.to_string();
state.runtime.set_own_identity(&account_id, &username);
// Nitro, which is what lets this account's emoji be
// used away from the guild they belong to. Read here
// because READY is the only place the payload carries
// it - `premium_type` 1 is Classic, 2 is Nitro, 3 is
// Basic, and all three buy the cross-guild use this
// cares about; 0 or absent is none.
let nitro = d["user"]["premium_type"].as_u64().unwrap_or(0) > 0;
state.runtime.set_emoji_unrestricted(&account_id, nitro);
// What a later reconnect needs to pick this session
// back up rather than starting over.
if let Some(session_id) = d["session_id"].as_str() {
+36
View File
@@ -419,6 +419,42 @@ pub(super) async fn register_guild_channels(state: &AppState, config: &DiscordAc
let me = own_member(config, guild).await;
let own_roles = member_role_ids(me.as_ref());
// This guild's emoji, as a place they come from rather than as a copy on
// every channel. The per-channel copy below stays: it answers "what does
// this room own", which is what a message being rendered needs, while
// this answers "what can this account send", which is what the picker
// needs - and with Nitro those are different sets (see #205).
if let Some(emojis) = guild["emojis"].as_array() {
let entries: Vec<crate::model::EmojiEntry> = emojis
.iter()
.filter(|e| e["available"].as_bool().unwrap_or(true))
.filter_map(|e| {
Some(crate::model::EmojiEntry {
id: e["id"].as_str()?.to_string(),
name: e["name"].as_str()?.to_string(),
url: None,
animated: e["animated"].as_bool().unwrap_or(false),
locked: false,
})
})
.collect();
if !entries.is_empty() {
state.runtime.set_emoji_source(
&config.account_id(),
crate::model::EmojiSource {
id: guild_id.to_string(),
name: guild_name.clone(),
service: "discord".to_string(),
icon_url: cached_guild_icon(guild_id, guild["icon"].as_str()).await,
// Named by channel below, once the visible set is known.
buffers: Vec::new(),
sendable_anywhere: state.runtime.emoji_unrestricted(&config.account_id()),
emoji: entries,
},
);
}
}
// Kept where every path that registers a guild passes, rather than in the
// GUILD_CREATE arm alone: for this kind of account Discord sends the
// guilds inside READY and GUILD_CREATE never fires, so everything that
+34
View File
@@ -474,6 +474,8 @@ async fn prepare(
let token = config.token.clone().filter(|t| !t.is_empty());
let slug = channel.slug.clone();
let buffer_id = buffer.id.clone();
let account_id = account_id.to_string();
let avatar_url = channel.avatar_url.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
@@ -483,6 +485,38 @@ async fn prepare(
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();
// The same emotes, as a place they come from rather than as this
// room's list. A Kick subscription buys the right to use that
// channel's emotes across the whole site, so whether these reach
// beyond this buffer is the subscription itself - which is why the
// flag is set from it rather than being false everywhere but the
// room on screen (see #205).
if !emotes.is_empty() {
let entries: Vec<crate::model::EmojiEntry> = emotes
.iter()
.map(|e| crate::model::EmojiEntry {
id: emotes::token(&e.id, &e.name),
name: e.name.clone(),
url: Some(e.url.clone()),
animated: false,
locked: e.subscribers_only && !subscribed,
})
.collect();
state.runtime.set_emoji_source(
&account_id,
crate::model::EmojiSource {
id: format!("kick:{slug}"),
name: slug.clone(),
service: "kick".to_string(),
icon_url: avatar_url.clone(),
buffers: vec![buffer_id.clone()],
sendable_anywhere: subscribed,
emoji: entries,
},
);
}
state.runtime.set_kick_emotes(&buffer_id, emotes, subscribed);
}
});
+45
View File
@@ -1,5 +1,50 @@
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.
///
/// 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,
/// and it is the only way to say *why* one cannot be sent here.
#[derive(Serialize, Clone, Debug)]
pub struct EmojiSource {
/// Unique within an account. A Discord guild id, a Kick channel slug.
pub id: String,
pub name: String,
pub service: String,
/// The guild's icon or the channel's avatar, for the jump strip.
#[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
/// non-empty list names the buffers, for the ones that are tied to a
/// place unless a subscription says otherwise.
#[serde(rename = "buffers")]
pub buffers: Vec<String>,
/// Usable away from `buffers` too, because something was paid for: Nitro,
/// or a Kick subscription to this channel.
#[serde(rename = "sendableAnywhere")]
pub sendable_anywhere: bool,
pub emoji: Vec<EmojiEntry>,
}
#[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.
pub id: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default)]
pub animated: bool,
/// Owned by somewhere this account has not paid into, so it can be looked
/// at and not sent. Shown rather than hidden: the picker is also how
/// somebody learns what subscribing would buy.
#[serde(default)]
pub locked: bool,
}
/// Matches the wire contract's Account JSON shape exactly (see
/// daemon/nobilis/model.c's nobilis_account_json) - the frontend renders these
/// fields directly, so names/shape here are not negotiable.
+55
View File
@@ -94,6 +94,61 @@ pub async fn dispatch(
// 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.
// Everything this account can send, wherever it came from.
//
// A different question from `listBufferEmoji`, which answers "what
// does this room own" and is what rendering a message needs. This is
// what the picker needs, and on every service moho speaks the two
// differ: Discord Nitro reaches every guild's emoji from anywhere, a
// Kick subscription reaches that channel's emotes across the site.
//
// `bufferId` is where the answer is *for* - each source comes back
// saying whether it can be sent there - rather than what limits it.
"listAllEmoji" => {
let Some(buffer_id) = p_str_opt(params, "bufferId") else {
return (None, Some("listAllEmoji requires \"bufferId\"".to_string()));
};
let account_id = channel_account(state, buffer_id);
let mut sources = state.runtime.emoji_catalogue(&account_id);
// Sneedchat's table is the same everywhere and belongs to no
// room, so it is added here rather than recorded per channel.
if account_id.starts_with("sneedchat:") {
sources.push(crate::model::EmojiSource {
id: "sneedchat".to_string(),
name: "Sneedchat".to_string(),
service: "sneedchat".to_string(),
icon_url: None,
buffers: Vec::new(),
sendable_anywhere: true,
emoji: backend::sneedchat::smilies::SMILIES
.iter()
.map(|s| crate::model::EmojiEntry {
id: s.aliases.first().unwrap_or(&s.label).to_string(),
name: s.label.to_string(),
url: None,
animated: false,
locked: false,
})
.collect(),
});
}
// Whether each source reaches this conversation. Worked out here
// rather than stored, because it is a fact about the pair.
let answer: Vec<serde_json::Value> = sources
.into_iter()
.map(|s| {
let here = s.buffers.is_empty() || s.buffers.iter().any(|b| b == buffer_id);
let usable = here || s.sendable_anywhere;
let mut v = serde_json::to_value(&s).unwrap_or_default();
v["usableHere"] = serde_json::json!(usable);
v
})
.collect();
(Some(serde_json::json!(answer)), None)
}
"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) {
+48
View File
@@ -445,6 +445,18 @@ pub struct Runtime {
/// register_guild_channels, populated straight from GUILD_CREATE with
/// no extra request needed. Empty/absent for IRC and any Discord DM.
discord_buffer_emojis: Mutex<HashMap<String, Vec<serde_json::Value>>>,
/// Everything an account can send, by where it came from - see
/// `emoji_catalogue`. Keyed account id -> source id -> the source.
///
/// Separate from `discord_buffer_emojis`, which answers "what does this
/// channel own". This answers "what can this account send", and they are
/// different questions on every service: Discord Nitro reaches every
/// guild's emoji from anywhere, and a Kick subscription reaches that
/// channel's emotes across the whole site.
emoji_catalogue: Mutex<HashMap<String, std::collections::BTreeMap<String, crate::model::EmojiSource>>>,
/// Whether this account's subscription lets it use emoji away from where
/// they live - Discord Nitro. Absent means no.
emoji_unrestricted: Mutex<std::collections::HashSet<String>>,
/// Discord-specific: account id -> friends list ({userId, username,
/// globalName, avatarUrl, status}), seeded from READY's `relationships`
/// (type 1 = friend) + `presences`, kept current by live PRESENCE_UPDATE
@@ -961,6 +973,8 @@ impl Runtime {
discord_guild_id: Mutex::new(HashMap::new()),
discord_history_inflight: Mutex::new(HashSet::new()),
discord_buffer_emojis: Mutex::new(HashMap::new()),
emoji_catalogue: Mutex::new(HashMap::new()),
emoji_unrestricted: Mutex::new(std::collections::HashSet::new()),
discord_friends: Mutex::new(HashMap::new()),
sneedchat_senders: Mutex::new(HashMap::new()),
kick_senders: Mutex::new(HashMap::new()),
@@ -1543,6 +1557,40 @@ impl Runtime {
self.discord_buffer_emojis.lock().unwrap().insert(buffer_id.to_string(), emojis);
}
/// Records one place emoji come from, replacing what was known about it.
pub fn set_emoji_source(&self, account_id: &str, source: crate::model::EmojiSource) {
self.emoji_catalogue
.lock()
.unwrap()
.entry(account_id.to_string())
.or_default()
.insert(source.id.clone(), source);
}
/// Everything this account can reach, newest knowledge of each source.
pub fn emoji_catalogue(&self, account_id: &str) -> Vec<crate::model::EmojiSource> {
self.emoji_catalogue
.lock()
.unwrap()
.get(account_id)
.map(|m| m.values().cloned().collect())
.unwrap_or_default()
}
/// Whether this account may use its emoji anywhere - Discord Nitro.
pub fn set_emoji_unrestricted(&self, account_id: &str, yes: bool) {
let mut all = self.emoji_unrestricted.lock().unwrap();
if yes {
all.insert(account_id.to_string());
} else {
all.remove(account_id);
}
}
pub fn emoji_unrestricted(&self, account_id: &str) -> bool {
self.emoji_unrestricted.lock().unwrap().contains(account_id)
}
pub fn get_discord_buffer_emojis(&self, buffer_id: &str) -> Vec<serde_json::Value> {
self.discord_buffer_emojis.lock().unwrap().get(buffer_id).cloned().unwrap_or_default()
}