Kick channel points, and spending them

Rewards are read from `api/v2/channels/<slug>/rewards`, anonymously - it is
the same list anybody sees on the channel's page, and sending credentials
for something public is a way to have them refused for no reason.

The redeem took finding out. `POST .../rewards/<id>/redeem` with an empty
body answers 422 "transaction_id is required"; with a UUID it answers 400
"Invalid request"; with a ULID it answers 201. Kick wants an id shaped like
its own - 48 bits of millisecond timestamp then 80 random, Crockford base32,
twenty-six characters - and it is an idempotency key, which is why one is
generated per call and never reused. Sending `{}`, which is what this did
before the endpoint was asked, would have failed every single time.

Two guards, and they are different guards. Kick's is the transaction id: two
requests carrying one id are one redeem. Ours is two seconds between
redeems per account, which is not tuned to sit under a rate limit - the
failure worth preventing is not a 429, it is a stuck key or a double click
spending a thousand points and shouting in a live chat twice.

A refusal carries Kick's own wording rather than a generic failure, because
that is the half saying why - not enough points, paused, too soon.

Verified against a live channel with one authorised redeem: 201 Created,
and the balance went 18,326 to 17,826, which is the 500 the reward costs.

Towards #204.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-12 11:17:28 -04:00
co-authored by Claude Opus 5
parent 5d408bdc93
commit d4206362d4
2 changed files with 263 additions and 0 deletions
+212
View File
@@ -1722,3 +1722,215 @@ async fn prediction_call(
}
serde_json::from_str(&text).context("reading Kick's answer about the prediction")
}
/// One thing a channel will trade for points.
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Reward {
pub id: String,
pub title: String,
pub cost: i64,
pub description: Option<String>,
/// Turned off by the streamer, or paused while they are away. Both are
/// shown rather than hidden - a reward that exists and cannot be had is a
/// different thing from one that was never offered, and the list is also
/// how somebody learns what this channel does.
pub enabled: bool,
pub paused: bool,
/// Kick asks the redeemer to type something. Not answerable from here
/// yet, so these are shown and refused rather than sent half-filled.
pub needs_input: bool,
pub prompt: Option<String>,
pub background_color: Option<String>,
}
#[derive(serde::Deserialize)]
struct RewardsEnvelope {
#[serde(default)]
data: Vec<RewardJson>,
}
#[derive(serde::Deserialize)]
struct RewardJson {
id: String,
#[serde(default)]
title: String,
#[serde(default)]
cost: i64,
#[serde(default)]
description: Option<String>,
#[serde(default)]
is_enabled: bool,
#[serde(default)]
is_paused: bool,
#[serde(default)]
is_user_input_required: bool,
#[serde(default)]
prompt: Option<String>,
#[serde(default)]
background_color: Option<String>,
}
/// What a channel offers for points.
///
/// Public: no token, because this is the same list anybody reads on the
/// channel's page, and asking with credentials for something anonymous is a
/// way to have them refused for no reason.
pub async fn rewards(http: &reqwest::Client, slug: &str) -> Result<Vec<Reward>> {
let res = http
.get(format!("{API_ROOT}/api/v2/channels/{slug}/rewards"))
.header("Accept", "application/json")
.send()
.await
.context("asking Kick what this channel offers")?;
if !res.status().is_success() {
bail!("Kick answered {} about this channel's rewards", res.status());
}
let body: RewardsEnvelope = res.json().await.context("reading Kick's answer about rewards")?;
Ok(body
.data
.into_iter()
.map(|r| Reward {
id: r.id,
title: r.title,
cost: r.cost,
description: r.description.filter(|d| !d.trim().is_empty()),
enabled: r.is_enabled,
paused: r.is_paused,
needs_input: r.is_user_input_required,
prompt: r.prompt.filter(|p| !p.trim().is_empty()),
background_color: r.background_color.filter(|c| !c.trim().is_empty()),
})
.collect())
}
/// The least time between one redeem and the next, per account.
///
/// Not a guess at Kick's own limit, and deliberately not tuned to sit just
/// under it. A redeem costs points somebody earned by watching, and every one
/// of them is a visible event in a live chat - so the failure this guards
/// against is not a 429, it is a stuck key or a double click spending a
/// thousand points and spamming the channel twice. Two seconds is far longer
/// than a person redeeming deliberately will notice and far shorter than any
/// rate limit worth striking.
pub const REDEEM_MIN_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2);
/// Whether enough time has passed since the last redeem, and records this one.
///
/// Keyed per account rather than per channel: the limit exists to protect an
/// account from spending its own points by accident, and points are earned per
/// channel but the hand on the mouse is the same one.
pub fn redeem_allowed(account_id: &str) -> bool {
use std::collections::HashMap;
use std::sync::Mutex;
static LAST: Mutex<Option<HashMap<String, std::time::Instant>>> = Mutex::new(None);
let mut guard = LAST.lock().unwrap();
let seen = guard.get_or_insert_with(HashMap::new);
let now = std::time::Instant::now();
match seen.get(account_id) {
Some(at) if now.duration_since(*at) < REDEEM_MIN_INTERVAL => false,
_ => {
seen.insert(account_id.to_string(), now);
true
}
}
}
#[cfg(test)]
mod redeem_guard_tests {
use super::*;
/// Twenty-six characters of Crockford base32. A UUID here is answered
/// 400 by Kick, which is how this was found.
#[test]
fn a_transaction_id_is_a_ulid() {
let id = transaction_id();
assert_eq!(id.len(), 26, "{id}");
assert!(id.bytes().all(|c| b"0123456789ABCDEFGHJKMNPQRSTVWXYZ".contains(&c)), "{id}");
// No I, L, O or U - that is the point of Crockford's alphabet.
assert!(!id.bytes().any(|c| b"ILOU".contains(&c)), "{id}");
}
/// An idempotency key that repeated would make two redeems one.
#[test]
fn two_redeems_do_not_share_an_id() {
assert_ne!(transaction_id(), transaction_id());
}
/// A double click costs nothing. The first goes, the second is refused.
#[test]
fn a_second_redeem_straight_away_is_refused() {
assert!(redeem_allowed("kick:guard-a"));
assert!(!redeem_allowed("kick:guard-a"));
}
/// Per account, because the limit protects an account from spending its
/// own points - two accounts are two people's points.
#[test]
fn one_accounts_haste_does_not_block_another() {
assert!(redeem_allowed("kick:guard-b"));
assert!(redeem_allowed("kick:guard-c"));
assert!(!redeem_allowed("kick:guard-b"));
}
}
/// A ULID, which is what Kick wants for a redeem's `transaction_id`.
///
/// Found by asking. The endpoint answers 422 "transaction_id is required" to a
/// body without one, and 400 "Invalid request" to a UUID - it wants the same
/// shape its own ids are in: 48 bits of millisecond timestamp then 80 random,
/// written in Crockford's base32, twenty-six characters.
///
/// It is an idempotency key, which is why it is generated per call and never
/// reused: two requests carrying one id are one redeem, and that is Kick's own
/// guard against a retry spending points twice.
pub fn transaction_id() -> String {
const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
let millis = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or_default();
let mut n: u128 = (millis << 80) | (rand::random::<u128>() & ((1u128 << 80) - 1));
let mut out = [0u8; 26];
for slot in out.iter_mut().rev() {
*slot = ALPHABET[(n & 31) as usize];
n >>= 5;
}
String::from_utf8_lossy(&out).into_owned()
}
/// Spends points on one.
///
/// Kick's own wording is carried out of a refusal rather than replaced: it is
/// the half that says *why* - not enough points, the reward is paused,
/// already redeemed too recently - and a generic failure would send somebody
/// looking for a reason this already has.
pub async fn redeem(http: &reqwest::Client, token: &str, slug: &str, reward_id: &str) -> Result<()> {
let res = http
.post(format!("{API_ROOT}/api/v2/channels/{slug}/rewards/{reward_id}/redeem"))
.header("Accept", "application/json")
.bearer_auth(token)
.json(&serde_json::json!({ "transaction_id": transaction_id() }))
.send()
.await
.context("redeeming")?;
let status = res.status();
if status.is_success() {
return Ok(());
}
let said = res.text().await.unwrap_or_default();
let reason = serde_json::from_str::<serde_json::Value>(&said)
.ok()
.and_then(|v| {
v["message"]
.as_str()
.or_else(|| v["error"].as_str())
.or_else(|| v["errors"][0].as_str())
.map(str::to_string)
})
.filter(|m| !m.trim().is_empty());
match reason {
Some(why) => bail!("{why}"),
None => bail!("Kick answered {status}"),
}
}
+51
View File
@@ -3437,6 +3437,57 @@ pub async fn dispatch(
}
}
// What a Kick channel trades for points, and this account's balance.
//
// Both in one answer because the plaque shows them together and asking
// twice would draw a total that does not match the list beside it.
"listKickRewards" => {
let Some(buffer_id) = p_str_opt(params, "bufferId") else {
return (None, Some("listKickRewards requires \"bufferId\"".to_string()));
};
let Some(channel) = state.runtime.kick_channel(buffer_id) else {
return (None, Some("that is not a Kick channel".to_string()));
};
let (http, token) = match kick_credential(state, &channel_account(state, buffer_id)) {
Ok(pair) => pair,
Err(e) => return (None, Some(e)),
};
let rewards = match backend::kick::api::rewards(&http, &channel.slug).await {
Ok(r) => r,
Err(e) => return (None, Some(format!("{e:#}"))),
};
// Best-effort: a balance that could not be read should not hide
// the list of what it could have bought.
let points = backend::kick::api::points(&http, &token, &channel.slug).await.ok();
(Some(serde_json::json!({ "rewards": rewards, "points": points })), None)
}
"redeemKickReward" => {
let (Some(buffer_id), Some(reward_id)) = (p_str_opt(params, "bufferId"), p_str_opt(params, "rewardId")) else {
return (None, Some("redeemKickReward requires \"bufferId\" and \"rewardId\"".to_string()));
};
let Some(channel) = state.runtime.kick_channel(buffer_id) else {
return (None, Some("that is not a Kick channel".to_string()));
};
let account_id = channel_account(state, buffer_id);
// Before anything is sent. A redeem spends points somebody earned
// and shouts in a live chat, so a double click must cost nothing.
if !backend::kick::api::redeem_allowed(&account_id) {
return (None, Some("that was a moment ago - wait a second before redeeming again".to_string()));
}
let (http, token) = match kick_credential(state, &account_id) {
Ok(pair) => pair,
Err(e) => return (None, Some(e)),
};
match backend::kick::api::redeem(&http, &token, &channel.slug, reward_id).await {
Ok(()) => {
let points = backend::kick::api::points(&http, &token, &channel.slug).await.ok();
(Some(serde_json::json!({ "points": points })), None)
}
Err(e) => (None, Some(format!("{e:#}"))),
}
}
// Taking the poll down, which Kick allows the streamer and their
// moderators and refuses to everybody else.
"endPoll" => {