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