Kick: moderation, and saying how a chat is restricted

Closes #76.

Timeouts, bans, unbans, deleting a message, and setting who may talk.
Kick is the authority on every one and re-checks them; what it refuses
comes back in its own words, which say far more than a status code - not a
moderator, no longer signed in, no such person.

A timeout is a ban with an end on it, which is Kick's own model and the
distinction a moderator actually means. "Kick" is a timeout here too:
there is nobody to remove from a livestream chat.

The chat's restrictions were the more embarrassing half. `subscribers_only`
and `followers_only` were parsed on join and read by nothing, so moho knew
a channel was followers-only and let somebody type into it anyway, letting
them find out from a refused send. They are now stated on the buffer at
join and kept current by the chatroom-updated event, which is how
followers-only going on mid-stream stops being a mystery. Eight of the
twenty-six channels here turn out to be followers-only.

Worded rather than lettered. IRC writes "+mnt" because that is what its
server says; Kick has no such spelling, and inventing one would be a code
only this client knew.

Setting them sends the whole set because that is what the endpoint takes,
so the values not being changed are read back from what the channel
already is - turning on slow mode must not silently turn off
followers-only on the way past.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-02 02:16:37 -04:00
co-authored by Claude Opus 5
parent f78d18c85c
commit 0a3135a11a
4 changed files with 286 additions and 0 deletions
+101
View File
@@ -571,6 +571,107 @@ pub struct ReplyTo {
pub sender_name: String,
}
/// Times somebody out, or bans them outright.
///
/// `minutes` of None is a ban with no end. Kick treats the two as one endpoint
/// with a flag, and the difference is the whole of what the moderator meant.
pub async fn ban(
http: &reqwest::Client,
token: &str,
slug: &str,
username: &str,
minutes: Option<u32>,
) -> Result<()> {
let body = match minutes {
Some(duration) => serde_json::json!({ "banned_username": username, "duration": duration, "permanent": false }),
None => serde_json::json!({ "banned_username": username, "permanent": true }),
};
moderate(http, token, reqwest::Method::POST, &format!("/api/v2/channels/{slug}/bans"), Some(body)).await
}
pub async fn unban(http: &reqwest::Client, token: &str, slug: &str, username: &str) -> Result<()> {
moderate(http, token, reqwest::Method::DELETE, &format!("/api/v2/channels/{slug}/bans/{username}"), None).await
}
/// Takes one message down.
///
/// Keyed by the chatroom rather than the channel, which is the same split the
/// websocket uses: messages belong to the room, bans belong to the channel.
pub async fn delete_message(http: &reqwest::Client, token: &str, chatroom_id: u64, message_id: &str) -> Result<()> {
moderate(
http,
token,
reqwest::Method::DELETE,
&format!("/api/v2/chatrooms/{chatroom_id}/messages/{message_id}"),
None,
)
.await
}
/// How the chat is restricted: followers only, subscribers only, slow mode.
///
/// Sent whole rather than as a patch, because that is what the endpoint takes
/// - so a caller changing one setting must pass the others as they are, and
/// the RPC reads the current values before sending.
pub async fn set_chat_mode(
http: &reqwest::Client,
token: &str,
slug: &str,
followers_only: bool,
subscribers_only: bool,
slow_seconds: Option<u32>,
) -> Result<()> {
let body = serde_json::json!({
"followers_mode": followers_only,
"subscribers_mode": subscribers_only,
"slow_mode": slow_seconds.is_some(),
"message_interval": slow_seconds.unwrap_or(0),
});
moderate(http, token, reqwest::Method::PUT, &format!("/api/v2/channels/{slug}/chatroom"), Some(body)).await
}
/// One moderation request, with the CSRF token and Kick's own refusal.
///
/// Shared because every one of these fails the same interesting ways - not a
/// moderator, not signed in any more, the person is not there - and Kick
/// explains each of them better than a status code can.
async fn moderate(
http: &reqwest::Client,
token: &str,
method: reqwest::Method,
path: &str,
body: Option<serde_json::Value>,
) -> Result<()> {
let mut req = http
.request(method, format!("{API_ROOT}{path}"))
.header("Accept", "application/json")
.bearer_auth(token);
if let Some(xsrf) = xsrf_token(http, token).await {
req = req.header("X-XSRF-TOKEN", xsrf);
}
if let Some(body) = body {
req = req.json(&body);
}
let res = req.send().await.context("asking Kick to do that")?;
let status = res.status();
if status.is_success() {
return Ok(());
}
let detail = res
.json::<serde_json::Value>()
.await
.ok()
.and_then(|v| v.get("message").and_then(|m| m.as_str()).map(str::to_string))
.filter(|s| !s.is_empty());
match detail {
Some(message) => Err(anyhow!("{message}")),
None if status == reqwest::StatusCode::FORBIDDEN => {
Err(anyhow!("Kick refused - this account is not a moderator of that channel"))
}
None => Err(anyhow!("Kick refused ({status})")),
}
}
/// Posts a line to a channel's chat.
///
/// The CSRF token is asked for at send time rather than stored. Kick's API is
+34
View File
@@ -397,10 +397,25 @@ async fn prepare(
history_cursor: None,
chatroom_id: channel.chatroom_id,
subscribed: false,
followers_only: channel.followers_only,
subscribers_only: channel.subscribers_only,
slow_seconds: None,
emotes: Vec::new(),
},
);
// How the chat is restricted, said before anybody tries to speak into it.
// Both values were already being parsed and then read by nothing, so moho
// knew a channel was subscribers-only and let somebody type into it
// anyway, learning otherwise from a refused send.
state.runtime.set_kick_chat_mode(
state,
&buffer.id,
channel.followers_only,
channel.subscribers_only,
None,
);
// The past, before the present starts arriving. Backgrounded with the
// rest so a channel's first messages are not held up by it, and recorded
// with their own timestamps so they sort into place whichever lands first.
@@ -655,6 +670,25 @@ async fn handle_frame(
}
}
// The chat's rules changing under everybody - followers-only going
// on mid-stream is the usual reason a message stops sending.
e if e.ends_with("ChatroomUpdatedEvent") => {
let Some(slug) = channel_of(watched, &frame.channel, &payload) else { return Ok(()) };
let flag = |name: &str| payload.get(name).and_then(|v| v.get("enabled")).and_then(|v| v.as_bool()).unwrap_or(false);
let slow = payload
.get("slow_mode")
.filter(|m| m.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false))
.and_then(|m| m.get("message_interval").and_then(|v| v.as_u64()))
.map(|s| s as u32);
state.runtime.set_kick_chat_mode(
state,
&crate::model::buffer_id(account_id, &slug),
flag("followers_mode"),
flag("subscribers_mode"),
slow,
);
}
// A moderator emptying the room.
e if e.ends_with("ChatroomClearEvent") => {
if let Some(slug) = channel_of(watched, &frame.channel, &payload) {
+103
View File
@@ -8,6 +8,21 @@ use std::collections::HashSet;
/// One handler per JSON-RPC method (daemon/nobilis/api.c's handle_request,
/// split out of the socket-framing code). Returns (result, error) - exactly
/// one is Some, matching the wire contract's response shape.
/// An HTTP client and a token for a signed-in Kick account.
///
/// One place, because every moderation call needs both and the signed-out
/// case needs saying rather than reporting as "not connected" - the account
/// is connected and reading fine, it simply cannot act.
fn kick_credential(state: &AppState, account_id: &str) -> std::result::Result<(reqwest::Client, String), String> {
let cfg = state.accounts.get_kick(account_id).ok_or_else(|| "no such account".to_string())?;
let token = cfg
.token
.filter(|t| !t.is_empty())
.ok_or_else(|| "sign in to Kick from Accounts to moderate".to_string())?;
let http = backend::kick::api::client().map_err(|e| e.to_string())?;
Ok((http, token))
}
pub async fn dispatch(
state: &AppState,
method: &str,
@@ -1512,6 +1527,18 @@ pub async fn dispatch(
Err(e) => (None, Some(e.to_string())),
},
},
Some(buffer) if buffer.account_id.starts_with("kick:") => {
match (kick_credential(state, &buffer.account_id), state.runtime.kick_channel(buffer_id)) {
(Err(e), _) => (None, Some(e)),
(_, None) => (None, Some("that channel is not being watched".to_string())),
(Ok((http, token)), Some(channel)) => {
match backend::kick::api::delete_message(&http, &token, channel.chatroom_id, msg_id).await {
Ok(()) => (Some(ok_node()), None),
Err(e) => (None, Some(format!("{e:#}"))),
}
}
}
}
Some(buffer) if buffer.account_id.starts_with("sockchat:") => match backend::sockchat::delete_message(state, &buffer.account_id, &buffer.name, msg_id) {
Ok(()) => (Some(ok_node()), None),
Err(e) => (None, Some(e.to_string())),
@@ -1786,6 +1813,82 @@ pub async fn dispatch(
}
}
// Moderation, for a Kick account that is a moderator of the channel.
//
// Kick is the authority and re-checks every one of these; what it
// refuses comes back in its own words, which say far more than a
// status code - not a moderator, no longer signed in, no such person.
"moderateKickUser" => {
let (account_id, buffer_id, username, action) = match (
p_str_opt(params, "accountId"),
p_str_opt(params, "bufferId"),
p_str_opt(params, "username"),
p_str_opt(params, "action"),
) {
(Some(a), Some(b), Some(u), Some(action)) => (a, b, u, action),
_ => {
return (
None,
Some("moderateKickUser requires \"accountId\", \"bufferId\", \"username\" and \"action\"".to_string()),
)
}
};
let (http, token) = match kick_credential(state, account_id) {
Ok(pair) => pair,
Err(e) => return (None, Some(e)),
};
let Some(channel) = state.runtime.kick_channel(buffer_id) else {
return (None, Some("that channel is not being watched".to_string()));
};
// A timeout is a ban with an end on it, which is Kick's own model
// and the distinction a moderator actually means.
let minutes = params.get("minutes").and_then(|v| v.as_u64()).map(|m| m as u32);
let result = match action {
"ban" => backend::kick::api::ban(&http, &token, &channel.slug, username, None).await,
"timeout" => {
backend::kick::api::ban(&http, &token, &channel.slug, username, Some(minutes.unwrap_or(10))).await
}
"unban" => backend::kick::api::unban(&http, &token, &channel.slug, username).await,
other => return (None, Some(format!("moderateKickUser does not know \"{other}\""))),
};
match result {
Ok(()) => (Some(ok_node()), None),
Err(e) => (None, Some(format!("{e:#}"))),
}
}
// Who may talk in a channel: followers only, subscribers only, and
// how long between messages.
//
// Sent whole because that is what the endpoint takes, so the values
// not being changed are read back from what the channel already is
// rather than guessed - a caller turning on slow mode must not
// silently turn off followers-only on the way.
"setKickChatMode" => {
let (account_id, buffer_id) = match (p_str_opt(params, "accountId"), p_str_opt(params, "bufferId")) {
(Some(a), Some(b)) => (a, b),
_ => return (None, Some("setKickChatMode requires \"accountId\" and \"bufferId\"".to_string())),
};
let (http, token) = match kick_credential(state, account_id) {
Ok(pair) => pair,
Err(e) => return (None, Some(e)),
};
let Some(channel) = state.runtime.kick_channel(buffer_id) else {
return (None, Some("that channel is not being watched".to_string()));
};
let followers = p_bool(params, "followersOnly", channel.followers_only);
let subscribers = p_bool(params, "subscribersOnly", channel.subscribers_only);
let slow = match params.get("slowSeconds") {
Some(serde_json::Value::Null) => None,
Some(v) => v.as_u64().map(|s| s as u32).filter(|s| *s > 0),
None => channel.slow_seconds,
};
match backend::kick::api::set_chat_mode(&http, &token, &channel.slug, followers, subscribers, slow).await {
Ok(()) => (Some(ok_node()), None),
Err(e) => (None, Some(format!("{e:#}"))),
}
}
// Reads this account's Kick follows again, and starts watching any
// that are new.
//
+48
View File
@@ -153,6 +153,13 @@ pub struct KickChannel {
/// Whether this account is subscribed to this streamer, which decides
/// only which emotes the picker offers - see backend::kick::emotes.
pub subscribed: bool,
/// How the chat is restricted right now. Parsed from the channel on join
/// and kept current by the chatroom-updated event, so a refused message
/// can be explained before it is written rather than after.
pub followers_only: bool,
pub subscribers_only: bool,
/// Seconds somebody must wait between messages, where slow mode is on.
pub slow_seconds: Option<u32>,
pub emotes: Vec<crate::backend::kick::api::Emote>,
}
@@ -1934,6 +1941,47 @@ impl Runtime {
self.kick_channels.lock().unwrap().get(buffer_id).cloned()
}
/// Records how a channel's chat is currently restricted, and says so on
/// the buffer where a client can show it.
pub fn set_kick_chat_mode(
&self,
state: &AppState,
buffer_id: &str,
followers_only: bool,
subscribers_only: bool,
slow_seconds: Option<u32>,
) {
if let Some(channel) = self.kick_channels.lock().unwrap().get_mut(buffer_id) {
channel.followers_only = followers_only;
channel.subscribers_only = subscribers_only;
channel.slow_seconds = slow_seconds;
}
// Worded rather than lettered. IRC writes its modes as "+mnt" because
// that is what its server says; Kick has no such spelling, and
// inventing one would be a code only this client knew.
let mut said: Vec<String> = Vec::new();
if subscribers_only {
said.push("subscribers only".into());
} else if followers_only {
said.push("followers only".into());
}
if let Some(seconds) = slow_seconds {
said.push(format!("slow mode {seconds}s"));
}
let modes = (!said.is_empty()).then(|| said.join(", "));
let updated = {
let mut buffers = self.buffers.lock().unwrap();
let Some(buffer) = buffers.get_mut(buffer_id) else { return };
if buffer.channel_modes == modes {
return;
}
buffer.channel_modes = modes;
buffer.clone()
};
state.events.emit("bufferListChange", serde_json::to_value(&updated).unwrap());
}
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;