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::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}