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:
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user