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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
pub mod cards;
|
||||
pub mod chat;
|
||||
pub mod live;
|
||||
pub mod pins;
|
||||
pub mod socket;
|
||||
#[cfg(test)]
|
||||
mod testkit;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user