mod backoff;
mod session;
pub use backoff::Backoff;
pub use session::{Action, Session, SessionOptions, TERMINAL_RETRY_MS, retry_after_ms};
use crate::http::{self, Endpoints, Error, Method, Request};
use crate::ids::{ChannelId, ChatroomId};
use crate::lenient::string;
use serde::Serialize;
use serde_json::json;
pub const REQUIRED_ORIGIN: &str = "https://kick.com";
pub fn chat_channels(chatroom: ChatroomId, channel: Option<ChannelId>) -> Vec<String> {
let mut names =
vec![format!("chatrooms.{chatroom}.v2"), format!("chatrooms.{chatroom}"), format!("chatroom_{chatroom}")];
if let Some(channel) = channel {
names.push(format!("channel.{channel}"));
names.push(format!("channel_{channel}"));
}
names
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "provider", rename_all = "snake_case")]
#[non_exhaustive]
pub enum Connection {
Centrifugo {
url: String,
},
Pusher {
app_key: String,
cluster: String,
},
Other {
name: String,
},
}
impl Connection {
pub fn centrifugo_url(&self) -> Option<&str> {
match self {
Connection::Centrifugo { url } => Some(url),
_ => None,
}
}
}
fn client_body(client_id: &str) -> serde_json::Value {
json!({
"client": { "id": client_id, "type": "web" },
"capabilities": { "accepted_providers": [{ "provider": "centrifugo" }] }
})
}
fn kick_origin(req: Request) -> Request {
req.header("origin", REQUIRED_ORIGIN).header("referer", format!("{REQUIRED_ORIGIN}/"))
}
pub fn chat_connection_request(endpoints: &Endpoints, channel: ChannelId, client_id: &str) -> Request {
let url = format!("{}/channels/{channel}/chat/connection", endpoints.realtime);
kick_origin(Request::new(Method::Post, url).json(&client_body(client_id)))
}
pub fn connection_request(endpoints: &Endpoints, client_id: &str) -> Request {
let url = format!("{}/connection", endpoints.realtime);
kick_origin(Request::new(Method::Post, url).json(&client_body(client_id)))
}
pub fn parse_connection(status: u16, body: &str) -> Result<Vec<Connection>, Error> {
let data = http::data(status, body)?;
let list = data.get("connections").and_then(|c| c.as_array()).ok_or_else(|| Error::shape("no `connections`"))?;
Ok(list
.iter()
.map(|c| {
let creds = c.get("credentials").cloned().unwrap_or_default();
match string(c, "provider").as_deref() {
Some("centrifugo") => match string(&creds, "url") {
Some(url) => Connection::Centrifugo { url },
None => Connection::Other { name: "centrifugo".into() },
},
Some("pusher") => match (string(&creds, "app_key"), string(&creds, "cluster")) {
(Some(app_key), Some(cluster)) => Connection::Pusher { app_key, cluster },
_ => Connection::Other { name: "pusher".into() },
},
other => Connection::Other { name: other.unwrap_or("").into() },
}
})
.collect())
}
pub fn token_request(endpoints: &Endpoints, client_id: &str) -> Request {
let url = format!("{}/auth/connection", endpoints.realtime);
kick_origin(Request::new(Method::Post, url).json(&json!({ "client_id": client_id })))
}
pub fn parse_token(status: u16, body: &str) -> Result<String, Error> {
let data = http::data(status, body)?;
string(&data, "token").filter(|t| !t.is_empty()).ok_or_else(|| Error::shape("no `token`"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn requests_carry_kicks_origin() {
let e = Endpoints::default();
let r = chat_connection_request(&e, ChannelId(668), "c1");
assert_eq!(r.url, "https://web.kick.com/api/v1/realtime/channels/668/chat/connection");
assert!(r.headers.contains(&("origin".into(), "https://kick.com".into())));
let body: serde_json::Value = serde_json::from_str(r.body.as_deref().unwrap()).unwrap();
assert_eq!(body["client"]["id"], "c1");
let t = token_request(&e, "c1");
assert_eq!(t.body.as_deref(), Some(r#"{"client_id":"c1"}"#));
}
#[test]
fn connections_are_read_in_order() {
let body = r#"{"data":{"connections":[{"credentials":{"url":"wss://a/connection/websocket"},"provider":"centrifugo"},
{"credentials":{"app_key":"k","cluster":"us2"},"provider":"pusher"},{"provider":"ably"}],"mode":"websocket"},"message":"success"}"#;
let c = parse_connection(200, body).unwrap();
assert_eq!(c[0].centrifugo_url(), Some("wss://a/connection/websocket"));
assert_eq!(c[1], Connection::Pusher { app_key: "k".into(), cluster: "us2".into() });
assert_eq!(c[2], Connection::Other { name: "ably".into() });
assert_eq!(parse_token(200, r#"{"data":{"token":"t"},"message":"success"}"#).unwrap(), "t");
assert!(parse_token(200, r#"{"data":{},"message":"success"}"#).is_err());
}
#[test]
fn feeds_for_a_chat() {
assert_eq!(
chat_channels(ChatroomId(1), Some(ChannelId(2))),
["chatrooms.1.v2", "chatrooms.1", "chatroom_1", "channel.2", "channel_2"]
);
assert_eq!(chat_channels(ChatroomId(1), None).len(), 3);
}
}