use crate::events::{self, ChatMessage, KickEvent};
use crate::http::{self, Endpoints, Error, Method, Request, segment};
use crate::ids::{ChannelId, ChatroomId, UserId};
use crate::lenient::{array, boolean, get, int, string, uint};
use serde::Serialize;
use serde_json::{Value, json};
fn v2(endpoints: &Endpoints, path: &str) -> String {
format!("{}/api/v2{path}", endpoints.website)
}
fn acting(req: Request, session: &str) -> Result<Request, Error> {
Ok(req.bearer(session)?.header("x-app-platform", "web"))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct SubscriberBadge {
pub months: i64,
pub src: String,
}
#[derive(Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct WebsiteChannel {
pub id: ChannelId,
pub user_id: Option<UserId>,
pub slug: Option<String>,
pub chatroom_id: ChatroomId,
pub followers_count: Option<u64>,
pub live: bool,
pub viewer_count: Option<i64>,
pub livestream_id: Option<u64>,
pub session_title: Option<String>,
pub username: Option<String>,
pub profile_pic: Option<String>,
pub playback_url: Option<String>,
pub subscriber_badges: Vec<SubscriberBadge>,
pub verified: Option<bool>,
}
impl std::fmt::Debug for WebsiteChannel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let Self {
id,
user_id,
slug,
chatroom_id,
followers_count,
live,
viewer_count,
livestream_id,
session_title,
username,
profile_pic,
playback_url,
subscriber_badges,
verified,
} = self;
f.debug_struct("WebsiteChannel")
.field("id", id)
.field("user_id", user_id)
.field("slug", slug)
.field("chatroom_id", chatroom_id)
.field("followers_count", followers_count)
.field("live", live)
.field("viewer_count", viewer_count)
.field("livestream_id", livestream_id)
.field("session_title", session_title)
.field("username", username)
.field("profile_pic", profile_pic)
.field("playback_url", &playback_url.as_ref().map(|_| "[redacted]"))
.field("subscriber_badges", subscriber_badges)
.field("verified", verified)
.finish()
}
}
pub fn channel_request(endpoints: &Endpoints, slug: &str) -> Result<Request, Error> {
Ok(Request::new(Method::Get, v2(endpoints, &format!("/channels/{}", segment("slug", slug)?))))
}
pub fn parse_channel(status: u16, body: &str) -> Result<WebsiteChannel, Error> {
let v = http::json(status, body)?;
let chatroom = get(&v, "chatroom").ok_or_else(|| Error::shape("no `chatroom`"))?;
let livestream = get(&v, "livestream");
let mut subscriber_badges: Vec<SubscriberBadge> = array(&v, "subscriber_badges")
.iter()
.filter_map(|b| {
Some(SubscriberBadge {
months: int(b, "months")?,
src: get(b, "badge_image").and_then(|i| string(i, "src"))?,
})
})
.collect();
subscriber_badges.sort_by_key(|b| b.months);
Ok(WebsiteChannel {
id: ChannelId(uint(&v, "id").ok_or_else(|| Error::shape("no channel `id`"))?),
user_id: uint(&v, "user_id").map(UserId),
slug: string(&v, "slug"),
chatroom_id: ChatroomId(uint(chatroom, "id").ok_or_else(|| Error::shape("no `chatroom.id`"))?),
followers_count: uint(&v, "followers_count"),
live: livestream.is_some_and(Value::is_object),
viewer_count: livestream.and_then(|l| int(l, "viewer_count")),
livestream_id: livestream.and_then(|l| uint(l, "id")),
session_title: livestream.and_then(|l| string(l, "session_title")),
username: get(&v, "user").and_then(|u| string(u, "username")),
profile_pic: get(&v, "user").and_then(|u| string(u, "profile_pic")),
playback_url: string(&v, "playback_url").filter(|u| !u.is_empty()),
subscriber_badges,
verified: boolean(&v, "verified").or_else(|| get(&v, "verified").map(Value::is_object)),
})
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[non_exhaustive]
pub struct History {
pub messages: Vec<ChatMessage>,
pub pinned: Option<KickEvent>,
}
pub fn history_request(endpoints: &Endpoints, channel: ChannelId) -> Request {
Request::new(Method::Get, v2(endpoints, &format!("/channels/{channel}/messages")))
}
pub fn parse_history(status: u16, body: &str) -> Result<History, Error> {
let data = http::data(status, body)?;
let mut messages: Vec<(usize, ChatMessage)> =
array(&data, "messages").iter().filter_map(events::chat_message).enumerate().collect();
messages.sort_by(|(ia, a), (ib, b)| a.created_at.cmp(&b.created_at).then(ia.cmp(ib)));
Ok(History {
messages: messages.into_iter().map(|(_, m)| m).collect(),
pinned: get(&data, "pinned_message").and_then(events::pin_from_history),
})
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct ChannelUser {
pub id: Option<UserId>,
pub username: Option<String>,
pub slug: Option<String>,
pub profile_pic: Option<String>,
pub created_at: Option<String>,
pub following_since: Option<String>,
pub subscribed_for: Option<i64>,
pub is_moderator: bool,
pub is_staff: bool,
pub is_channel_owner: bool,
pub banned: Option<Value>,
pub badges: Vec<events::Badge>,
}
pub fn channel_user_request(endpoints: &Endpoints, channel_slug: &str, username: &str) -> Result<Request, Error> {
let path = format!("/channels/{}/users/{}", segment("slug", channel_slug)?, segment("username", username)?);
Ok(Request::new(Method::Get, v2(endpoints, &path)))
}
pub fn parse_channel_user(status: u16, body: &str) -> Result<ChannelUser, Error> {
let v = http::json(status, body)?;
Ok(ChannelUser {
id: uint(&v, "id").map(UserId),
username: string(&v, "username"),
slug: string(&v, "slug"),
profile_pic: string(&v, "profile_pic"),
created_at: string(&v, "created_at"),
following_since: string(&v, "following_since"),
subscribed_for: int(&v, "subscribed_for"),
is_moderator: boolean(&v, "is_moderator").unwrap_or(false),
is_staff: boolean(&v, "is_staff").unwrap_or(false),
is_channel_owner: boolean(&v, "is_channel_owner").unwrap_or(false),
banned: get(&v, "banned").cloned(),
badges: array(&v, "badges")
.iter()
.filter_map(|b| {
Some(events::Badge { kind: string(b, "type")?, text: string(b, "text"), count: int(b, "count") })
})
.collect(),
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct Me {
pub is_moderator: bool,
pub is_broadcaster: bool,
pub is_super_admin: bool,
}
impl Me {
pub fn can_moderate(&self) -> bool {
self.is_moderator || self.is_broadcaster || self.is_super_admin
}
}
pub fn me_request(endpoints: &Endpoints, session: &str, channel_slug: &str) -> Result<Request, Error> {
let url = v2(endpoints, &format!("/channels/{}/me", segment("slug", channel_slug)?));
acting(Request::new(Method::Get, url), session)
}
pub fn parse_me(status: u16, body: &str) -> Result<Me, Error> {
let v = http::json(status, body)?;
Ok(Me {
is_moderator: boolean(&v, "is_moderator").unwrap_or(false),
is_broadcaster: boolean(&v, "is_broadcaster").unwrap_or(false),
is_super_admin: boolean(&v, "is_super_admin").unwrap_or(false),
})
}
pub fn ban_request(endpoints: &Endpoints, session: &str, channel_slug: &str, username: &str) -> Result<Request, Error> {
let url = v2(endpoints, &format!("/channels/{}/bans", segment("slug", channel_slug)?));
Ok(acting(Request::new(Method::Post, url), session)?
.json(&json!({ "permanent": true, "banned_username": username, "reason": null })))
}
pub fn timeout_request(
endpoints: &Endpoints,
session: &str,
channel_slug: &str,
username: &str,
minutes: u32,
) -> Result<Request, Error> {
if !(1..=10_080).contains(&minutes) {
return Err(Error::Invalid(format!("{minutes} minutes; 1 to 10080 (a week)")));
}
let url = v2(endpoints, &format!("/channels/{}/bans", segment("slug", channel_slug)?));
Ok(acting(Request::new(Method::Post, url), session)?
.json(&json!({ "permanent": false, "banned_username": username, "reason": null, "duration": minutes })))
}
pub fn unban_request(
endpoints: &Endpoints,
session: &str,
channel_slug: &str,
username: &str,
) -> Result<Request, Error> {
let path = format!("/channels/{}/bans/{}", segment("slug", channel_slug)?, segment("username", username)?);
acting(Request::new(Method::Delete, v2(endpoints, &path)), session)
}
pub fn delete_message_request(
endpoints: &Endpoints,
session: &str,
chatroom: ChatroomId,
message_id: &str,
) -> Result<Request, Error> {
let url = v2(endpoints, &format!("/chatrooms/{chatroom}/messages/{}", segment("message id", message_id)?));
acting(Request::new(Method::Delete, url), session)
}
pub fn pin_request(
endpoints: &Endpoints,
session: &str,
channel_slug: &str,
message: &Value,
minutes: u32,
) -> Result<Request, Error> {
if !(1..=10_080).contains(&minutes) {
return Err(Error::Invalid(format!("{minutes} minutes; 1 to 10080 (a week)")));
}
let url = v2(endpoints, &format!("/channels/{}/pinned-message", segment("slug", channel_slug)?));
Ok(acting(Request::new(Method::Post, url), session)?.json(&json!({ "message": message, "duration": minutes })))
}
pub fn unpin_request(endpoints: &Endpoints, session: &str, channel_slug: &str) -> Result<Request, Error> {
let url = v2(endpoints, &format!("/channels/{}/pinned-message", segment("slug", channel_slug)?));
acting(Request::new(Method::Delete, url), session)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReplyTarget<'a> {
pub message_id: &'a str,
pub content: &'a str,
pub sender_id: UserId,
pub sender_username: &'a str,
}
pub fn send_request(
endpoints: &Endpoints,
session: &str,
chatroom: ChatroomId,
content: &str,
message_ref: &str,
reply_to: Option<&ReplyTarget<'_>>,
) -> Result<Request, Error> {
if content.trim().is_empty() || content.chars().count() > 500 {
return Err(Error::Invalid("a message is 1 to 500 characters".into()));
}
let body = match reply_to {
None => json!({ "content": content, "type": "message", "message_ref": message_ref }),
Some(r) => json!({
"content": content,
"type": "reply",
"message_ref": message_ref,
"metadata": {
"original_message": { "id": r.message_id, "content": r.content },
"original_sender": { "id": r.sender_id.0, "username": r.sender_username }
},
"thread_parent_id": r.message_id
}),
};
let url = v2(endpoints, &format!("/messages/send/{chatroom}"));
Ok(acting(Request::new(Method::Post, url), session)?.json(&body))
}
pub fn parse_action(status: u16, body: &str) -> Result<(), Error> {
if (200..300).contains(&status) {
Ok(())
} else {
Err(Error::Status { status, excerpt: body.chars().take(200).collect() })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn followers_come_as_numbers_or_strings() {
for n in ["123", "\"123\""] {
let body = format!(
r#"{{"id":5,"user_id":6,"slug":"a","followers_count":{n},"chatroom":{{"id":7}},"livestream":null}}"#
);
let c = parse_channel(200, &body).unwrap();
assert_eq!(c.followers_count, Some(123));
assert_eq!(c.chatroom_id, ChatroomId(7));
assert!(!c.live);
}
assert!(parse_channel(200, r#"{"id":5}"#).is_err());
let body = r#"{"id":5,"chatroom":{"id":7},"playback_url":"https://x/master.m3u8?token=sekrit"}"#;
let c = parse_channel(200, body).unwrap();
assert_eq!(c.playback_url.as_deref(), Some("https://x/master.m3u8?token=sekrit"));
let shown = format!("{c:?}");
assert!(!shown.contains("sekrit") && shown.contains("playback_url: Some(\"[redacted]\")"), "{shown}");
assert_eq!(parse_channel(404, "{}").unwrap_err().status(), Some(404));
}
#[test]
fn acting_carries_the_session() {
let e = Endpoints::default();
let r = timeout_request(&e, "sess", "somechannel", "bob", 5).unwrap();
assert_eq!(r.url, "https://kick.com/api/v2/channels/somechannel/bans");
assert!(r.headers.contains(&("authorization".into(), "Bearer sess".into())));
assert!(timeout_request(&e, "s", "x", "b", 0).is_err());
let reply = ReplyTarget { message_id: "m1", content: "hi", sender_id: UserId(9), sender_username: "Ann" };
let r = send_request(&e, "s", ChatroomId(3), "yo", "123", Some(&reply)).unwrap();
let body: Value = serde_json::from_str(r.body.as_deref().unwrap()).unwrap();
assert_eq!(body["thread_parent_id"], "m1");
assert_eq!(body["metadata"]["original_sender"]["id"], 9);
assert!(send_request(&e, "s", ChatroomId(3), " ", "1", None).is_err());
assert!(send_request(&e, "s\r\nx: y", ChatroomId(3), "yo", "1", None).is_err());
}
#[test]
fn path_segments_cannot_move_the_path() {
let e = Endpoints::default();
for bad in ["", ".", ".."] {
assert!(matches!(channel_request(&e, bad), Err(Error::Invalid(_))), "{bad:?}");
assert!(channel_user_request(&e, bad, "bob").is_err());
assert!(channel_user_request(&e, "somechannel", bad).is_err());
assert!(me_request(&e, "s", bad).is_err());
assert!(ban_request(&e, "s", bad, "bob").is_err());
assert!(timeout_request(&e, "s", bad, "bob", 5).is_err());
assert!(unban_request(&e, "s", bad, "bob").is_err());
assert!(unban_request(&e, "s", "somechannel", bad).is_err());
assert!(delete_message_request(&e, "s", ChatroomId(1), bad).is_err());
assert!(pin_request(&e, "s", bad, &json!({}), 5).is_err());
assert!(unpin_request(&e, "s", bad).is_err());
}
let r = channel_user_request(&e, "some.channel", "a/b").unwrap();
assert_eq!(r.url, "https://kick.com/api/v2/channels/some.channel/users/a%2Fb");
let r = delete_message_request(&e, "s", ChatroomId(1), "m-1").unwrap();
assert_eq!(r.url, "https://kick.com/api/v2/chatrooms/1/messages/m-1");
}
}