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/// **⚠️ 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}