Skip to main content

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}