Kick replies, both directions
`replyToId` was accepted and dropped, so replying sent a bare message and nothing threaded. Kick has a real reply: its own message type carrying the original's id, text and author, which is better than the "@name," convention Sneedchat needs and worth using properly rather than pasting a mention on the front. Incoming, that metadata becomes the same ReplyPreview every other protocol builds - and a better one, because Kick sends the original's text with the reply instead of only its id. Every other backend here has to find the original in scrollback and comes up empty once it has scrolled past the cap; a Kick reply quotes correctly however old the original is. Outgoing, the original is read out of scrollback to fill in what Kick wants. A reply to something that has scrolled out still sends, as an ordinary message, because losing the message would be the worse trade. Half a reply is treated as none: a preview with no author and no text draws as an empty quote above the message, which reads as something failing to load rather than as a reply. Verified live in both directions - a reply sent from moho came back off the wire as a reply, quoting what it answered. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
+36
-2
@@ -320,6 +320,20 @@ pub async fn standing(http: &reqwest::Client, token: &str, slug: &str) -> Result
|
||||
})
|
||||
}
|
||||
|
||||
/// What a message is answering, as Kick's send endpoint wants it.
|
||||
///
|
||||
/// The original's text travels with the reply rather than only its id, which
|
||||
/// is Kick's design and not this client's choice: it is what makes a reply
|
||||
/// quotable in everybody's chat window without each of them looking the
|
||||
/// original up, including people who joined after it was said.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReplyTo {
|
||||
pub message_id: String,
|
||||
pub body: String,
|
||||
pub sender_id: Option<u64>,
|
||||
pub sender_name: String,
|
||||
}
|
||||
|
||||
/// Posts a line to a channel's chat.
|
||||
///
|
||||
/// The CSRF token is asked for at send time rather than stored. Kick's API is
|
||||
@@ -327,12 +341,32 @@ pub async fn standing(http: &reqwest::Client, token: &str, slug: &str) -> Result
|
||||
/// POST; fetching it immediately before means it cannot be stale, and means
|
||||
/// the sign-in window has only one thing to capture rather than two that could
|
||||
/// expire independently.
|
||||
pub async fn send_message(http: &reqwest::Client, token: &str, chatroom_id: u64, body: &str) -> Result<()> {
|
||||
pub async fn send_message(
|
||||
http: &reqwest::Client,
|
||||
token: &str,
|
||||
chatroom_id: u64,
|
||||
body: &str,
|
||||
reply_to: Option<&ReplyTo>,
|
||||
) -> Result<()> {
|
||||
// A reply is its own message type carrying the original, not a mention
|
||||
// pasted on the front. Sent as Kick's own client sends it, so it threads
|
||||
// in everybody's chat window rather than only reading like a reply here.
|
||||
let payload = match reply_to {
|
||||
None => serde_json::json!({ "content": body, "type": "message" }),
|
||||
Some(r) => serde_json::json!({
|
||||
"content": body,
|
||||
"type": "reply",
|
||||
"metadata": {
|
||||
"original_message": { "id": r.message_id, "content": r.body },
|
||||
"original_sender": { "id": r.sender_id, "username": r.sender_name }
|
||||
}
|
||||
}),
|
||||
};
|
||||
let mut req = http
|
||||
.post(format!("{API_ROOT}/api/v2/messages/send/{chatroom_id}"))
|
||||
.header("Accept", "application/json")
|
||||
.bearer_auth(token)
|
||||
.json(&serde_json::json!({ "content": body, "type": "message" }));
|
||||
.json(&payload);
|
||||
if let Some(xsrf) = xsrf_token(http, token).await {
|
||||
req = req.header("X-XSRF-TOKEN", xsrf);
|
||||
}
|
||||
|
||||
+117
-1
@@ -360,6 +360,29 @@ struct ChatMessage {
|
||||
#[serde(default)]
|
||||
created_at: Option<String>,
|
||||
sender: Sender,
|
||||
/// Present on a reply, carrying the whole of what was replied to.
|
||||
///
|
||||
/// Kick sends the original's text along with its id, which is worth more
|
||||
/// than it sounds: every other protocol here has to look the original up
|
||||
/// in scrollback and shows a bare "in reply to" when it has scrolled past
|
||||
/// the cap. Kick's replies are quotable however old the original is.
|
||||
#[serde(default)]
|
||||
metadata: Option<ReplyMetadata>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ReplyMetadata {
|
||||
#[serde(default)]
|
||||
original_sender: Option<Sender>,
|
||||
#[serde(default)]
|
||||
original_message: Option<OriginalMessage>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OriginalMessage {
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -401,6 +424,7 @@ async fn handle_frame(
|
||||
let Ok(msg) = serde_json::from_value::<ChatMessage>(payload) else { return Ok(()) };
|
||||
let Some(slug) = watched.chatroom(msg.chatroom_id) else { return Ok(()) };
|
||||
let from = msg.sender.username.unwrap_or_else(|| "someone".to_string());
|
||||
let reply_to = reply_preview(msg.metadata.as_ref());
|
||||
state.runtime.record_message_at(
|
||||
state,
|
||||
account_id,
|
||||
@@ -410,7 +434,7 @@ async fn handle_frame(
|
||||
&msg.content,
|
||||
false,
|
||||
"chat",
|
||||
None,
|
||||
reply_to,
|
||||
// Kick's own message id, so the same message replayed after a
|
||||
// reconnect is recognised rather than shown twice.
|
||||
Some(msg.id),
|
||||
@@ -447,6 +471,23 @@ async fn handle_frame(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What a reply was replying to, out of the metadata Kick attaches to it.
|
||||
///
|
||||
/// Both halves are required rather than filled in with blanks: a preview with
|
||||
/// no author and no text is drawn as an empty quote above the message, which
|
||||
/// reads as something failing to load rather than as a reply. Better to show
|
||||
/// the message plainly than to show a hole above it.
|
||||
fn reply_preview(metadata: Option<&ReplyMetadata>) -> Option<crate::model::ReplyPreview> {
|
||||
let metadata = metadata?;
|
||||
let original = metadata.original_message.as_ref()?;
|
||||
let from = metadata.original_sender.as_ref()?.username.clone()?;
|
||||
Some(crate::model::ReplyPreview {
|
||||
id: original.id.clone(),
|
||||
from,
|
||||
body: original.content.clone().unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// One line for something somebody did in a channel, or None if this event is
|
||||
/// not one of those.
|
||||
///
|
||||
@@ -694,6 +735,81 @@ mod tests {
|
||||
assert!(w.remove("odablock").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_what_a_reply_was_answering() {
|
||||
// Copied from the wire. Kick sends the original's text alongside its
|
||||
// id, so a reply quotes properly however long ago the original was
|
||||
// said - unlike every other protocol here, which has to find it in
|
||||
// scrollback and comes up empty once it has scrolled past the cap.
|
||||
let metadata: ReplyMetadata = serde_json::from_value(serde_json::json!({
|
||||
"original_sender": { "id": 1150141, "username": "Greg" },
|
||||
"original_message": { "id": "ddeccb11-29bc-4cf1-ab2a-9b409293de76", "content": "L0L0WERFLGEWRUGHBER" }
|
||||
}))
|
||||
.unwrap();
|
||||
let preview = reply_preview(Some(&metadata)).unwrap();
|
||||
assert_eq!(preview.from, "Greg");
|
||||
assert_eq!(preview.id, "ddeccb11-29bc-4cf1-ab2a-9b409293de76");
|
||||
assert_eq!(preview.body, "L0L0WERFLGEWRUGHBER");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_whole_reply_frame_off_the_wire_parses() {
|
||||
// The exact payload Kick sent, badges and all - not a hand-written
|
||||
// subset. The parse is the part that can silently stop working when
|
||||
// Kick adds a field, and the reply half is nested three deep inside a
|
||||
// message whose other half is decoration.
|
||||
// r##, not r#: the colour in the payload is `"#75FD46"`, and `"#`
|
||||
// closes a single-hash raw string right in the middle of the JSON.
|
||||
let raw = r##"{
|
||||
"id": "026bd0f5-085c-4571-962e-556371b63ded",
|
||||
"chatroom_id": 2393554,
|
||||
"content": "[emote:37226:KEKW]",
|
||||
"type": "reply",
|
||||
"created_at": "2026-09-02T00:16:25+00:00",
|
||||
"sender": { "id": 15383321, "username": "L3galizeWee", "slug": "l3galizewee",
|
||||
"identity": { "color": "#75FD46",
|
||||
"badges": [{ "type": "subscriber", "text": "Subscriber", "count": 11, "sort_order": 9 }],
|
||||
"badges_v2": [{ "name": "level", "badge_type": "global", "image_url": "https://ext.cdn.kick.com/x.png",
|
||||
"metadata": { "level": 47 }, "selected": true, "sort_order": 1 }] } },
|
||||
"metadata": {
|
||||
"original_sender": { "id": 1150141, "username": "Greg" },
|
||||
"original_message": { "id": "ddeccb11-29bc-4cf1-ab2a-9b409293de76", "content": "L0L0WERFLGEWRUGHBER" } }
|
||||
}"##;
|
||||
let msg: ChatMessage = serde_json::from_str(raw).expect("a real reply frame must parse");
|
||||
assert_eq!(msg.chatroom_id, 2393554);
|
||||
assert_eq!(msg.sender.username.as_deref(), Some("L3galizeWee"));
|
||||
let preview = reply_preview(msg.metadata.as_ref()).expect("and carry what it answered");
|
||||
assert_eq!(preview.from, "Greg");
|
||||
assert_eq!(preview.body, "L0L0WERFLGEWRUGHBER");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ordinary_messages_metadata_is_not_mistaken_for_a_reply() {
|
||||
// Plain messages carry metadata too - a `message_ref` and nothing
|
||||
// else - so "has metadata" is not the same question as "is a reply".
|
||||
let raw = r#"{ "id": "x", "chatroom_id": 1, "content": "hi", "type": "message",
|
||||
"sender": { "id": 2, "username": "someone" }, "metadata": { "message_ref": "1788305786864" } }"#;
|
||||
let msg: ChatMessage = serde_json::from_str(raw).unwrap();
|
||||
assert!(reply_preview(msg.metadata.as_ref()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ordinary_message_is_not_a_reply() {
|
||||
assert!(reply_preview(None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_a_reply_is_no_reply() {
|
||||
// An empty quote above a message reads as something failing to load,
|
||||
// which is worse than showing the message plainly.
|
||||
let no_sender: ReplyMetadata =
|
||||
serde_json::from_value(serde_json::json!({ "original_message": { "id": "x", "content": "y" } })).unwrap();
|
||||
assert!(reply_preview(Some(&no_sender)).is_none());
|
||||
let no_message: ReplyMetadata =
|
||||
serde_json::from_value(serde_json::json!({ "original_sender": { "username": "Greg" } })).unwrap();
|
||||
assert!(reply_preview(Some(&no_message)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_a_redemption() {
|
||||
let p = serde_json::json!({ "username": "GayHayride", "reward_title": "CAT PATS", "user_input": "" });
|
||||
|
||||
+19
-4
@@ -1288,14 +1288,29 @@ pub async fn dispatch(
|
||||
let Some(channel) = state.runtime.kick_channel(buffer_id) else {
|
||||
return (None, Some("that channel is not being watched".to_string()));
|
||||
};
|
||||
// Kick has no attachments in chat and no threaded replies;
|
||||
// both are ignored rather than refused, the same way every
|
||||
// other protocol-specific field is where it does not apply.
|
||||
// A reply needs the whole of what it answers, not just an
|
||||
// id: Kick carries the original's text and author in the
|
||||
// message so it quotes in everybody's window. All of it is
|
||||
// in scrollback already, so this is a lookup rather than a
|
||||
// round trip - and a reply to something that has scrolled
|
||||
// out of the cap still sends, as an ordinary message,
|
||||
// because losing the message would be the worse trade.
|
||||
let reply_to = reply_to_id
|
||||
.and_then(|id| state.store.get_message(buffer_id, id).ok().flatten())
|
||||
.map(|m| backend::kick::api::ReplyTo {
|
||||
message_id: m.id,
|
||||
body: m.body,
|
||||
sender_id: m.sender_id.as_deref().and_then(|s| s.parse().ok()),
|
||||
sender_name: m.from,
|
||||
});
|
||||
// Kick chat has no attachments, so one is ignored rather
|
||||
// than refused - the same way every other protocol-specific
|
||||
// field is where it does not apply.
|
||||
let http = match backend::kick::api::client() {
|
||||
Ok(c) => c,
|
||||
Err(e) => return (None, Some(e.to_string())),
|
||||
};
|
||||
match backend::kick::api::send_message(&http, token, channel.chatroom_id, body).await {
|
||||
match backend::kick::api::send_message(&http, token, channel.chatroom_id, body, reply_to.as_ref()).await {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user