kick_api/live_chat.rs
1use futures_util::{SinkExt, StreamExt};
2use tokio_tungstenite::{connect_async, tungstenite::Message};
3
4use crate::error::{KickApiError, Result};
5use crate::models::ChannelInfo;
6use crate::models::FollowedChannel;
7use crate::models::live_chat::{LiveChatMessage, PusherEvent, PusherMessage};
8
9const PUSHER_URL: &str = "wss://ws-us2.pusher.com/app/32cbd69e4b950bf97679?protocol=7&client=js&version=8.4.0&flash=false";
10
11type WsStream = tokio_tungstenite::WebSocketStream<
12 tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
13>;
14
15/// Client for receiving live chat messages over Kick's Pusher WebSocket.
16///
17/// Connects to the public Pusher channel for a chatroom and yields chat
18/// messages in real time. **No authentication is required.**
19///
20/// # Connecting
21///
22/// There are two ways to connect:
23///
24/// - [`connect_by_username`](Self::connect_by_username) — pass a Kick username
25/// and the chatroom ID is resolved automatically (requires `curl` on PATH).
26/// - [`connect`](Self::connect) — pass a chatroom ID directly.
27///
28/// # Example
29///
30/// ```no_run
31/// use kick_api::LiveChatClient;
32///
33/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
34/// let mut chat = LiveChatClient::connect_by_username("xqc").await?;
35/// while let Some(msg) = chat.next_message().await? {
36/// println!("{}: {}", msg.sender.username, msg.content);
37/// }
38/// # Ok(())
39/// # }
40/// ```
41pub struct LiveChatClient {
42 ws: WsStream,
43}
44
45impl std::fmt::Debug for LiveChatClient {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 f.debug_struct("LiveChatClient").finish_non_exhaustive()
48 }
49}
50
51impl LiveChatClient {
52 /// Connect to a chatroom by the channel's username/slug.
53 ///
54 /// Looks up the chatroom ID via Kick's public API and connects to the
55 /// WebSocket. No authentication is required.
56 ///
57 /// # Example
58 /// ```no_run
59 /// use kick_api::LiveChatClient;
60 ///
61 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
62 /// let mut chat = LiveChatClient::connect_by_username("xqc").await?;
63 /// while let Some(msg) = chat.next_message().await? {
64 /// println!("{}: {}", msg.sender.username, msg.content);
65 /// }
66 /// # Ok(())
67 /// # }
68 /// ```
69 pub async fn connect_by_username(username: &str) -> Result<Self> {
70 let info = fetch_channel_info_inner(username).await?;
71 Self::connect(info.chatroom.id).await
72 }
73
74 /// Connect to a chatroom by its ID.
75 ///
76 /// Opens a WebSocket to Pusher and subscribes to the chatroom's public
77 /// channel. No authentication is required.
78 ///
79 /// To find a channel's chatroom ID, visit
80 /// `https://kick.com/api/v2/channels/{slug}` in a browser and look for
81 /// `"chatroom":{"id":`.
82 pub async fn connect(chatroom_id: u64) -> Result<Self> {
83 let channel = format!("chatrooms.{chatroom_id}.v2");
84
85 let (mut ws, _) = connect_async(PUSHER_URL)
86 .await
87 .map_err(KickApiError::WebSocketError)?;
88
89 // Wait for pusher:connection_established
90 wait_for_event(&mut ws, "pusher:connection_established").await?;
91
92 // Subscribe to the chatroom channel
93 let subscribe = serde_json::json!({
94 "event": "pusher:subscribe",
95 "data": {
96 "auth": "",
97 "channel": channel,
98 }
99 });
100 ws.send(Message::Text(subscribe.to_string().into()))
101 .await
102 .map_err(KickApiError::WebSocketError)?;
103
104 // Wait for subscription confirmation
105 wait_for_event(&mut ws, "pusher_internal:subscription_succeeded").await?;
106
107 Ok(Self { ws })
108 }
109
110 /// Receive the next raw Pusher event.
111 ///
112 /// Returns all events from the subscribed channel (chat messages, pins,
113 /// subs, bans, etc.). Automatically handles Pusher-level pings and
114 /// internal protocol events. Returns `None` if the connection is closed.
115 pub async fn next_event(&mut self) -> Result<Option<PusherEvent>> {
116 loop {
117 let Some(frame) = self.ws.next().await else {
118 return Ok(None);
119 };
120
121 let frame = frame.map_err(KickApiError::WebSocketError)?;
122
123 let text = match frame {
124 Message::Text(t) => t,
125 Message::Close(_) => return Ok(None),
126 Message::Ping(data) => {
127 self.ws
128 .send(Message::Pong(data))
129 .await
130 .map_err(KickApiError::WebSocketError)?;
131 continue;
132 }
133 _ => continue,
134 };
135
136 let pusher_msg: PusherMessage = match serde_json::from_str(&text) {
137 Ok(m) => m,
138 Err(_) => continue,
139 };
140
141 // Handle Pusher-level pings automatically
142 if pusher_msg.event == "pusher:ping" {
143 let pong = serde_json::json!({ "event": "pusher:pong", "data": {} });
144 self.ws
145 .send(Message::Text(pong.to_string().into()))
146 .await
147 .map_err(KickApiError::WebSocketError)?;
148 continue;
149 }
150
151 // Skip internal Pusher protocol events
152 if pusher_msg.event.starts_with("pusher:")
153 || pusher_msg.event.starts_with("pusher_internal:")
154 {
155 continue;
156 }
157
158 return Ok(Some(PusherEvent {
159 event: pusher_msg.event,
160 channel: pusher_msg.channel,
161 data: pusher_msg.data,
162 }));
163 }
164 }
165
166 /// Receive the next chat message.
167 ///
168 /// Blocks until a chat message arrives. Automatically handles Pusher-level
169 /// pings and skips non-chat events. Returns `None` if the connection is
170 /// closed.
171 pub async fn next_message(&mut self) -> Result<Option<LiveChatMessage>> {
172 loop {
173 let Some(event) = self.next_event().await? else {
174 return Ok(None);
175 };
176
177 if event.event != "App\\Events\\ChatMessageEvent" {
178 continue;
179 }
180
181 // Data is double-encoded: outer JSON has `data` as a string
182 let msg: LiveChatMessage = match serde_json::from_str(&event.data) {
183 Ok(m) => m,
184 Err(_) => continue,
185 };
186
187 return Ok(Some(msg));
188 }
189 }
190
191 /// Send a Pusher-level ping to keep the connection alive.
192 pub async fn send_ping(&mut self) -> Result<()> {
193 let ping = serde_json::json!({ "event": "pusher:ping", "data": {} });
194 self.ws
195 .send(Message::Text(ping.to_string().into()))
196 .await
197 .map_err(KickApiError::WebSocketError)?;
198 Ok(())
199 }
200
201 /// Close the WebSocket connection.
202 pub async fn close(&mut self) -> Result<()> {
203 self.ws
204 .close(None)
205 .await
206 .map_err(KickApiError::WebSocketError)?;
207 Ok(())
208 }
209}
210
211/// Fetch public channel information from Kick's v2 API.
212///
213/// Returns chatroom settings, subscriber badges, user profile, and livestream
214/// status for any channel. **No authentication required.**
215///
216/// This uses `curl` as a subprocess because Kick's Cloudflare protection blocks
217/// HTTP libraries based on TLS fingerprinting. `curl` ships with Windows 10+,
218/// macOS, and virtually all Linux distributions.
219///
220/// # Example
221///
222/// ```no_run
223/// use kick_api::fetch_channel_info;
224///
225/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
226/// let info = fetch_channel_info("xqc").await?;
227///
228/// // Chatroom settings
229/// println!("Chatroom ID: {}", info.chatroom.id);
230/// println!("Slow mode: {}", info.chatroom.slow_mode);
231/// println!("Followers only: {}", info.chatroom.followers_mode);
232///
233/// // Subscriber badges
234/// for badge in &info.subscriber_badges {
235/// println!("{}mo badge: {}", badge.months, badge.badge_image.src);
236/// }
237///
238/// // Livestream status
239/// if let Some(stream) = &info.livestream {
240/// println!("{} is live with {} viewers", info.slug, stream.viewer_count);
241/// }
242/// # Ok(())
243/// # }
244/// ```
245pub async fn fetch_channel_info(username: &str) -> Result<ChannelInfo> {
246 fetch_channel_info_inner(username).await
247}
248
249/// Fetch the list of channels the authenticated user follows.
250///
251/// **⚠️ Unofficial API** — This uses Kick's internal v2 API
252/// (`/api/v2/channels/followed`), not the public API. It may change or break
253/// without notice.
254///
255/// Requires a valid session/bearer token (the same token used when logged in
256/// to kick.com). This is **not** an OAuth App Access Token from the public
257/// API — it is the session token from your browser cookies.
258///
259/// Uses `curl` as a subprocess to bypass Cloudflare TLS fingerprinting.
260///
261/// # Example
262///
263/// ```no_run
264/// use kick_api::fetch_followed_channels;
265///
266/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
267/// let token = "your_session_token";
268/// let channels = fetch_followed_channels(token).await?;
269/// for ch in &channels {
270/// let status = match &ch.livestream {
271/// Some(stream) if stream.is_live => format!("🔴 {} viewers", stream.viewer_count),
272/// _ => "Offline".to_string(),
273/// };
274/// println!("{}: {}", ch.slug, status);
275/// }
276/// # Ok(())
277/// # }
278/// ```
279pub async fn fetch_followed_channels(token: &str) -> Result<Vec<FollowedChannel>> {
280 let url = "https://kick.com/api/v2/channels/followed";
281 let auth_header = format!("Bearer {}", token);
282
283 let mut cmd = tokio::process::Command::new("curl");
284 cmd.args([
285 "-s",
286 "-H", "Accept: application/json",
287 "-H", "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
288 "-H", &format!("Authorization: {}", auth_header),
289 url,
290 ]);
291
292 // Prevent a visible console window from flashing on Windows
293 #[cfg(target_os = "windows")]
294 {
295 #[allow(unused_imports)]
296 use std::os::windows::process::CommandExt;
297 cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
298 }
299
300 let output = cmd
301 .output()
302 .await
303 .map_err(|e| KickApiError::UnexpectedError(format!(
304 "Failed to run curl (is it installed?): {}", e
305 )))?;
306
307 if !output.status.success() {
308 return Err(KickApiError::ApiError(format!(
309 "curl failed for followed channels: exit code {:?}",
310 output.status.code()
311 )));
312 }
313
314 let channels: Vec<FollowedChannel> = serde_json::from_slice(&output.stdout)
315 .map_err(|e| KickApiError::ApiError(format!(
316 "Failed to parse followed channels response: {}", e
317 )))?;
318
319 Ok(channels)
320}
321
322async fn fetch_channel_info_inner(username: &str) -> Result<ChannelInfo> {
323 let url = format!("https://kick.com/api/v2/channels/{}", username);
324
325 let mut cmd = tokio::process::Command::new("curl");
326 cmd.args(["-s", "-H", "Accept: application/json", "-H", "User-Agent: Chatterino7", &url]);
327
328 // Prevent a visible console window from flashing on Windows
329 #[cfg(target_os = "windows")]
330 {
331 #[allow(unused_imports)]
332 use std::os::windows::process::CommandExt;
333 cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
334 }
335
336 let output = cmd
337 .output()
338 .await
339 .map_err(|e| KickApiError::UnexpectedError(format!(
340 "Failed to run curl (is it installed?): {}", e
341 )))?;
342
343 if !output.status.success() {
344 return Err(KickApiError::ApiError(format!(
345 "curl failed for channel '{}': exit code {:?}",
346 username,
347 output.status.code()
348 )));
349 }
350
351 let info: ChannelInfo = serde_json::from_slice(&output.stdout)
352 .map_err(|e| KickApiError::ApiError(format!(
353 "Failed to parse channel response for '{}': {}", username, e
354 )))?;
355
356 Ok(info)
357}
358
359/// Wait for a specific Pusher event on the WebSocket.
360async fn wait_for_event(ws: &mut WsStream, event_name: &str) -> Result<()> {
361 loop {
362 let Some(frame) = ws.next().await else {
363 return Err(KickApiError::UnexpectedError(format!(
364 "Connection closed while waiting for '{event_name}'"
365 )));
366 };
367
368 let frame = frame.map_err(KickApiError::WebSocketError)?;
369
370 let text = match frame {
371 Message::Text(t) => t,
372 Message::Ping(data) => {
373 ws.send(Message::Pong(data))
374 .await
375 .map_err(KickApiError::WebSocketError)?;
376 continue;
377 }
378 _ => continue,
379 };
380
381 let msg: PusherMessage = match serde_json::from_str(&text) {
382 Ok(m) => m,
383 Err(_) => continue,
384 };
385
386 if msg.event == event_name {
387 return Ok(());
388 }
389 }
390}