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::FollowedChannelsResponse;
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 resp = fetch_followed_channels(token).await?;
276/// for ch in &resp.channels {
277/// let status = if ch.is_live {
278/// format!("LIVE ({} viewers)", ch.viewer_count)
279/// } else {
280/// "Offline".to_string()
281/// };
282/// println!("{}: {}",
283/// ch.user_username.as_deref().unwrap_or("?"), status);
284/// }
285/// # Ok(())
286/// # }
287/// ```
288pub async fn fetch_followed_channels(token: &str) -> Result<FollowedChannelsResponse> {
289 let url = "https://kick.com/api/v2/channels/followed";
290 let auth_header = format!("Bearer {}", token);
291
292 let mut cmd = tokio::process::Command::new("curl");
293 cmd.args([
294 "-s",
295 "-H", "Accept: application/json",
296 "-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",
297 "-H", &format!("Authorization: {}", auth_header),
298 url,
299 ]);
300
301 // Prevent a visible console window from flashing on Windows
302 #[cfg(target_os = "windows")]
303 {
304 #[allow(unused_imports)]
305 use std::os::windows::process::CommandExt;
306 cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
307 }
308
309 let output = cmd
310 .output()
311 .await
312 .map_err(|e| KickApiError::UnexpectedError(format!(
313 "Failed to run curl (is it installed?): {}", e
314 )))?;
315
316 if !output.status.success() {
317 return Err(KickApiError::ApiError(format!(
318 "curl failed for followed channels: exit code {:?}",
319 output.status.code()
320 )));
321 }
322
323 let resp: FollowedChannelsResponse = serde_json::from_slice(&output.stdout)
324 .map_err(|e| KickApiError::ApiError(format!(
325 "Failed to parse followed channels response: {}", e
326 )))?;
327
328 Ok(resp)
329}
330
331async fn fetch_channel_info_inner(username: &str) -> Result<ChannelInfo> {
332 let url = format!("https://kick.com/api/v2/channels/{}", username);
333
334 let mut cmd = tokio::process::Command::new("curl");
335 cmd.args(["-s", "-H", "Accept: application/json", "-H", "User-Agent: Chatterino7", &url]);
336
337 // Prevent a visible console window from flashing on Windows
338 #[cfg(target_os = "windows")]
339 {
340 #[allow(unused_imports)]
341 use std::os::windows::process::CommandExt;
342 cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
343 }
344
345 let output = cmd
346 .output()
347 .await
348 .map_err(|e| KickApiError::UnexpectedError(format!(
349 "Failed to run curl (is it installed?): {}", e
350 )))?;
351
352 if !output.status.success() {
353 return Err(KickApiError::ApiError(format!(
354 "curl failed for channel '{}': exit code {:?}",
355 username,
356 output.status.code()
357 )));
358 }
359
360 let info: ChannelInfo = serde_json::from_slice(&output.stdout)
361 .map_err(|e| KickApiError::ApiError(format!(
362 "Failed to parse channel response for '{}': {}", username, e
363 )))?;
364
365 Ok(info)
366}
367
368/// Wait for a specific Pusher event on the WebSocket.
369async fn wait_for_event(ws: &mut WsStream, event_name: &str) -> Result<()> {
370 loop {
371 let Some(frame) = ws.next().await else {
372 return Err(KickApiError::UnexpectedError(format!(
373 "Connection closed while waiting for '{event_name}'"
374 )));
375 };
376
377 let frame = frame.map_err(KickApiError::WebSocketError)?;
378
379 let text = match frame {
380 Message::Text(t) => t,
381 Message::Ping(data) => {
382 ws.send(Message::Pong(data))
383 .await
384 .map_err(KickApiError::WebSocketError)?;
385 continue;
386 }
387 _ => continue,
388 };
389
390 let msg: PusherMessage = match serde_json::from_str(&text) {
391 Ok(m) => m,
392 Err(_) => continue,
393 };
394
395 if msg.event == event_name {
396 return Ok(());
397 }
398 }
399}