The one line a channel wants everybody to have read

Kick pins a message above its chat - the link, the rules, the thing being
talked about - and moho ignored it entirely. The one line a channel had
deliberately put in front of everybody was the one line this client did not
show.

Two routes in, and both are needed. The **Pusher event**, on the socket the
chat already arrives on, which reports a pin made while you are watching. And
**`/api/v2/channels/{slug}/pinned-message`** on opening a channel, because a
pin set before you arrived is the ordinary case.

That endpoint is authenticated and answers 401 without a token Kick still
accepts, so it is best-effort by construction: a channel whose pin cannot be
read is one that learns about the pin when it next changes, which is worth
more than refusing to join. Verified against a live channel - the request goes
out, comes back 401, is logged at debug, and two hundred frames of chat arrive
past it untouched.

**Matched by shape rather than by name**, which is the opposite of the rule
the subscription events follow a few arms below. Those are matched exactly
because three of them mean the same thing and had to be told apart. These are
the other case: Kick documents none of it, the names come from the wrappers
that reverse-engineered this socket, and matching one exactly means a rename
turns the feature off silently - the hardest failure to notice and to
attribute. So any event named for a pin is handled and its direction read from
the name. Anchored, not a bare search: a plain `contains("pin")` would read a
`SpinWheelEvent` as a pin.

Direction is read from the name rather than from an empty payload, because an
unpin may well echo the message being unpinned - read as a pin, that would put
back the very thing it announced the removal of. And a pin event carrying
nothing readable is left alone rather than treated as an unpin: a shape having
moved on is not a channel having taken its pin down.

The pin is held rather than only broadcast, the way a live poll card is, so a
window opening an hour later is still told about it.

Unpinning is a moderator gesture and stays with #76's moderation, as the issue
says.

Part of #130.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-10 20:56:06 -04:00
co-authored by Claude Opus 5
parent f6b3696dbf
commit 117dda11fe
6 changed files with 312 additions and 0 deletions
+25
View File
@@ -1627,6 +1627,31 @@ pub async fn vote_prediction(
Ok(body.data.and_then(|d| d.prediction.map(|p| (p, d.user_vote))))
}
/// What a channel currently has pinned above its chat.
///
/// Authenticated, and answered `401 Unauthenticated` without a token Kick
/// still accepts - which is why every caller treats a failure as "no pin
/// known yet" rather than as an error. The pin arrives over the socket as soon
/// as it next changes regardless, so the cost of failing here is a stale
/// absence rather than a missing feature.
///
/// The response is handed back raw. Kick documents none of this and the
/// wrapping has moved before; `pins::read` looks for the message at every
/// depth it has been seen at rather than committing this signature to one.
pub async fn pinned_message(http: &reqwest::Client, token: Option<&str>, slug: &str) -> Result<serde_json::Value> {
let mut req = http
.get(format!("{API_ROOT}/api/v2/channels/{slug}/pinned-message"))
.header("Accept", "application/json");
if let Some(token) = token.filter(|t| !t.is_empty()) {
req = req.bearer_auth(token);
}
let res = req.send().await.context("asking Kick what is pinned")?;
if !res.status().is_success() {
bail!("Kick answered {} about the pinned message", res.status());
}
res.json().await.context("reading Kick's answer about the pinned message")
}
/// The smallest bet Kick accepts, which its own form enforces.
pub const MIN_PREDICTION_BET: i64 = 10;
+23
View File
@@ -334,6 +334,29 @@ pub(super) fn handle_event(
}
}
// The one line a channel wants everybody to have read.
//
// Matched by shape rather than by exact name, unlike the subscription
// events below. Those are matched exactly because three of them mean
// the same thing and had to be told apart; these are the opposite
// case - Kick documents neither, the names come from the wrappers
// that reverse-engineered this socket, and a rename would silently
// turn the feature off. Any event about a pin is handled, and its
// direction is read from the name.
e if pins::is_pin_event(e) => {
let Some(slug) = channel_of(watched, &frame.channel, &payload) else { return Ok(()) };
let buffer_id = crate::model::buffer_id(account_id, &slug);
if pins::is_unpin(e) {
pins::announce(state, &buffer_id, None);
} else if let Some(pin) = pins::read(&payload) {
pins::announce(state, &buffer_id, Some(pin));
}
// A pin event carrying nothing readable is left alone rather than
// treated as an unpin: the shape having moved on is not the
// channel having taken its pin down, and clearing on it would
// wipe a pin that is still up.
}
// A moderator emptying the room.
e if e.ends_with("ChatroomClearEvent") => {
if let Some(slug) = channel_of(watched, &frame.channel, &payload) {
+1
View File
@@ -33,6 +33,7 @@
pub mod cards;
pub mod chat;
pub mod live;
pub mod pins;
pub mod socket;
#[cfg(test)]
mod testkit;
+237
View File
@@ -0,0 +1,237 @@
//! The message a Kick channel wants everybody to have read.
//!
//! kick.com pins one line above the chat - the link, the rules, the thing
//! being talked about - and it stays there until it is replaced or taken down.
//! This client ignored it entirely, so the one line a channel deliberately put
//! in front of everybody was the one line moho did not show.
//!
//! It arrives two ways, and both are needed for the obvious reason: the event
//! only tells you about a pin made *while you are watching*, and a pin set
//! before you arrived is the ordinary case.
//!
//! - **The Pusher event**, on the same socket the chat arrives on. No auth, no
//! extra transport - a new arm beside the seventeen already handled.
//! - **`/api/v2/channels/{slug}/pinned-message`** on joining, which needs a
//! token and is therefore best-effort: an account with none, or with one
//! Kick has since expired, simply learns about the pin when it next changes.
//!
//! Nothing here is documented by Kick. The event names come from the wrappers
//! that have reverse-engineered this socket and agree on them, and the parse
//! is deliberately lenient for the same reason `cards` is: a payload whose
//! shape has moved on should show nothing rather than something wrong.
use super::*;
use serde_json::Value;
/// What is pinned in a channel right now.
#[derive(Clone, Debug, PartialEq)]
pub struct Pin {
/// The pinned message's own id, which is what a client keys a dismissal
/// on - so dismissing this pin does not dismiss the next one.
pub id: String,
pub from: String,
pub body: String,
}
impl Pin {
pub(super) fn to_json(&self, buffer_id: &str) -> Value {
serde_json::json!({
"bufferId": buffer_id,
"id": self.id,
"from": self.from,
"body": self.body,
})
}
}
/// Whether an event is about a pinned message at all.
///
/// Deliberately loose. Kick documents none of this; the names come from the
/// third-party wrappers that have reverse-engineered the socket, and matching
/// one exactly means a rename turns the feature off silently - which is the
/// failure mode that is hardest to notice and hardest to attribute.
pub fn is_pin_event(event: &str) -> bool {
let name = event.rsplit('\\').next().unwrap_or(event);
// Anchored rather than a bare search for "pin". A plain `contains` would
// sweep up anything with the letters in it - a `SpinWheelEvent` would be
// read as a pin - so the test is a name that *begins* with pin or unpin,
// or that carries "Pinned" as its own word.
name.contains("Pinned") || name.starts_with("Pin") || name.starts_with("Unpin")
}
/// Whether that event is taking the pin down rather than putting one up.
///
/// Read from the name rather than from the payload's emptiness, because an
/// unpin may well echo the message being unpinned - and read as a pin, that
/// would put back the thing it was announcing the removal of.
pub fn is_unpin(event: &str) -> bool {
let name = event.rsplit('\\').next().unwrap_or(event);
name.contains("Delete") || name.contains("Remove") || name.contains("Unpin")
}
/// Reads a pin out of whatever shape it arrived in.
///
/// Three of them, and all three are the same message object at a different
/// depth: the Pusher event wraps it in `message`, the HTTP endpoint wraps it
/// in `data`, and one of them occasionally hands it over bare. Rather than
/// guess which, this looks in all three and takes the first that has an id and
/// something to say.
pub fn read(payload: &Value) -> Option<Pin> {
for candidate in [payload.get("message"), payload.get("data").and_then(|d| d.get("message")), payload.get("data"), Some(payload)] {
let Some(message) = candidate else { continue };
let id = message.get("id").and_then(id_text);
let body = message.get("content").and_then(|v| v.as_str()).unwrap_or("").trim();
let Some(id) = id else { continue };
if body.is_empty() {
continue;
}
let from = message
.get("sender")
.and_then(|s| s.get("username"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.unwrap_or("someone")
.to_string();
return Some(Pin { id, from, body: body.to_string() });
}
None
}
/// Kick's message ids are strings in the socket payloads and numbers in some
/// HTTP ones, and a pin keyed on `"42"` in one and `42` in the other is a pin
/// that un-dismisses itself depending on where it was read.
fn id_text(value: &Value) -> Option<String> {
match value {
Value::String(s) if !s.is_empty() => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
_ => None,
}
}
/// Records what is pinned and tells the client, or clears it.
pub fn announce(state: &AppState, buffer_id: &str, pin: Option<Pin>) {
// Only on a change. The endpoint is read on every join and the event can
// repeat, and re-announcing an identical pin would put it back on screen
// for somebody who had just dismissed it.
if state.runtime.kick_pin(buffer_id) == pin {
return;
}
state.runtime.set_kick_pin(buffer_id, pin.clone());
let payload = match &pin {
Some(pin) => pin.to_json(buffer_id),
// A cleared pin is an event carrying no pin rather than no event: the
// client has one on screen and has to be told it is gone.
None => serde_json::json!({ "bufferId": buffer_id, "id": Value::Null }),
};
state.events.emit("pinnedMessage", payload);
}
/// Sends the held pin to a window that has just subscribed.
///
/// Not `announce`, which deliberately says nothing when the pin has not
/// changed - that is right for the socket and wrong here: a second window
/// opening the same channel has never been told, and the pin has not changed
/// for anybody else.
pub fn replay(state: &AppState, buffer_id: &str) {
if let Some(pin) = state.runtime.kick_pin(buffer_id) {
state.events.emit("pinnedMessage", pin.to_json(buffer_id));
}
}
/// Asks what is already pinned, for a channel just joined.
///
/// Best-effort by design. The endpoint answers 401 without a token that Kick
/// still accepts, and a channel whose pin cannot be read is a channel that
/// learns about the pin when it next changes - which is worth strictly more
/// than refusing to join.
pub async fn refresh_pin(state: &AppState, buffer_id: &str) {
let Some(channel) = channel_when_ready(state, buffer_id).await else { return };
let Ok(http) = api::client() else { return };
let token = state
.runtime
.get_buffer(buffer_id)
.and_then(|b| state.accounts.get_kick(&b.account_id))
.and_then(|c| c.token)
.filter(|t| !t.is_empty());
match api::pinned_message(&http, token.as_deref(), &channel.slug).await {
Ok(payload) => announce(state, buffer_id, read(&payload)),
Err(e) => tracing::debug!("kick: reading what {} has pinned: {e:#}", channel.slug),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The socket wraps it in `message`, the endpoint in `data`, and one of
/// them hands it over bare. All three are the same object at a different
/// depth, and a reader that knew only one shape would show a pin on some
/// channels and not others.
#[test]
fn a_pin_is_found_however_deep_it_was_buried() {
let inner = serde_json::json!({
"id": "abc", "content": "read the rules", "sender": { "username": "streamer" }
});
for payload in [
serde_json::json!({ "message": inner.clone(), "duration": "120" }),
serde_json::json!({ "data": { "message": inner.clone() } }),
serde_json::json!({ "data": inner.clone() }),
inner.clone(),
] {
let pin = read(&payload).expect("a pin");
assert_eq!(pin.id, "abc");
assert_eq!(pin.from, "streamer");
assert_eq!(pin.body, "read the rules");
}
}
/// Kick writes an id as a string on the socket and as a number over HTTP.
/// Keyed on one in one place and the other in the other, a dismissal
/// would come undone depending on which path last read it.
#[test]
fn an_id_reads_the_same_whichever_type_it_arrived_as() {
let text = serde_json::json!({ "message": { "id": "42", "content": "x", "sender": {} } });
let number = serde_json::json!({ "message": { "id": 42, "content": "x", "sender": {} } });
assert_eq!(read(&text).unwrap().id, "42");
assert_eq!(read(&number).unwrap().id, "42");
}
/// Nothing rather than something wrong. A payload whose shape has moved
/// on, or an unpin arriving as an empty object, must not draw an empty bar
/// across the top of the chat.
#[test]
fn a_payload_with_nothing_in_it_is_no_pin() {
assert!(read(&serde_json::json!({})).is_none());
assert!(read(&serde_json::json!({ "message": {} })).is_none());
// An id with no text is not a message anybody pinned.
assert!(read(&serde_json::json!({ "message": { "id": "1", "content": " " } })).is_none());
// And text with no id cannot be dismissed, so it is not usable either.
assert!(read(&serde_json::json!({ "message": { "content": "hello" } })).is_none());
}
/// The names are reverse-engineered and could change. Matching one
/// exactly would turn the feature off silently on a rename, which is the
/// hardest kind of failure to notice - so the test is the shape.
#[test]
fn a_pin_event_is_recognised_by_shape_and_its_direction_by_name() {
for up in ["App\\Events\\PinnedMessageCreatedEvent", "PinnedMessageCreatedEvent", "App\\Events\\PinnedMessageUpdatedEvent"] {
assert!(is_pin_event(up), "{up}");
assert!(!is_unpin(up), "{up}");
}
for down in ["App\\Events\\PinnedMessageDeletedEvent", "PinnedMessageRemovedEvent", "UnpinMessageEvent"] {
assert!(is_pin_event(down), "{down}");
assert!(is_unpin(down), "{down}");
}
// And nothing else is swept up with them.
for other in ["App\\Events\\ChatMessageEvent", "PollUpdateEvent", "MessageDeletedEvent", "SpinWheelEvent"] {
assert!(!is_pin_event(other), "{other}");
}
}
/// Whoever pinned it may not be named; the pin is still the point.
#[test]
fn a_pin_with_no_named_sender_is_still_a_pin() {
let pin = read(&serde_json::json!({ "message": { "id": "7", "content": "hi", "sender": {} } })).expect("a pin");
assert_eq!(pin.from, "someone");
}
}
+8
View File
@@ -363,6 +363,10 @@ pub async fn dispatch(
// ids meant the poll and the prediction running right now
// were never asked about at all. The refreshers wait for the
// channel themselves.
// What this channel already has pinned, for a window that has
// just opened it. Held rather than only broadcast, the same
// way a live poll card is.
backend::kick::pins::replay(state, buffer_id);
if state.runtime.get_buffer(buffer_id).is_some_and(|b| b.account_id.starts_with("kick:")) {
let (state, buffer_id) = (state.clone(), buffer_id.to_string());
tokio::spawn(async move {
@@ -372,6 +376,10 @@ pub async fn dispatch(
// broadcast does: what this account has riding on it
// and what it has left to bet.
backend::kick::refresh_prediction(&state, &buffer_id).await;
// And what the channel has pinned, which the socket
// only reports when it changes - a pin set before you
// arrived is the ordinary case.
backend::kick::pins::refresh_pin(&state, &buffer_id).await;
});
}
(Some(ok_node()), None)
+18
View File
@@ -691,6 +691,12 @@ pub struct Runtime {
/// (see `backend::discord::mutes`) since the settings routinely arrive in
/// READY before the channels they are about.
discord_mutes: Mutex<HashMap<(String, String), DiscordMute>>,
/// buffer -> the message that channel has pinned above its chat.
///
/// Kept because it is standing state rather than an event: a window
/// opening an hour after the pin was set still has to be told about it,
/// the same reason a live poll card is held rather than only broadcast.
kick_pins: Mutex<HashMap<String, crate::backend::kick::pins::Pin>>,
/// account -> what its homeserver says it can do.
matrix_server_facts: Mutex<HashMap<String, crate::backend::matrix::server::ServerFacts>>,
/// account -> the RPL_ISUPPORT tokens that server advertised.
@@ -955,6 +961,7 @@ impl Runtime {
silenced: Mutex::new(std::collections::HashSet::new()),
sneedchat_motds: Mutex::new(HashMap::new()),
sneedchat_rooms: Mutex::new(HashMap::new()),
kick_pins: Mutex::new(HashMap::new()),
matrix_server_facts: Mutex::new(HashMap::new()),
irc_isupport: Mutex::new(HashMap::new()),
irc_rostered: Mutex::new(std::collections::HashSet::new()),
@@ -3150,6 +3157,17 @@ impl Runtime {
}
}
pub fn kick_pin(&self, buffer_id: &str) -> Option<crate::backend::kick::pins::Pin> {
self.kick_pins.lock().unwrap().get(buffer_id).cloned()
}
pub fn set_kick_pin(&self, buffer_id: &str, pin: Option<crate::backend::kick::pins::Pin>) {
match pin {
Some(pin) => self.kick_pins.lock().unwrap().insert(buffer_id.to_string(), pin),
None => self.kick_pins.lock().unwrap().remove(buffer_id),
};
}
pub fn set_matrix_server_facts(&self, account_id: &str, facts: crate::backend::matrix::server::ServerFacts) {
self.matrix_server_facts.lock().unwrap().insert(account_id.to_string(), facts);
}