From 2393f39eb2fb44f060e73baee7bcee8e9932666f Mon Sep 17 00:00:00 2001 From: retconned Date: Sun, 15 Dec 2024 22:31:21 +0100 Subject: [PATCH 1/7] feat: enforce message length limit in client API to match kick api --- src/client/client.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/client/client.ts b/src/client/client.ts index eea2741..da49c91 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -197,6 +197,10 @@ export const createClient = ( checkAuth(); + if (messageContent.length > 500) { + throw new Error("Message content must be less than 500 characters"); + } + try { const response = await axios.post( `https://kick.com/api/v2/messages/send/${channelInfo.id}`, From cc16de1f1c0e16037e1b00ca73ae94de966f576f Mon Sep 17 00:00:00 2001 From: retconned Date: Sun, 15 Dec 2024 23:21:05 +0100 Subject: [PATCH 2/7] refactor: enhance message parsing and handling in client. Temporary placeholder for other events --- src/client/client.ts | 33 +++++++++++++------ src/utils/messageHandling.ts | 61 ++++++++++++++++++++++++++++-------- 2 files changed, 72 insertions(+), 22 deletions(-) diff --git a/src/client/client.ts b/src/client/client.ts index da49c91..617bdc9 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -116,15 +116,30 @@ export const createClient = ( socket.on("message", (data: WebSocket.Data) => { const parsedMessage = parseMessage(data.toString()); if (parsedMessage) { - if ( - mergedOptions.plainEmote && - parsedMessage.type === "ChatMessage" - ) { - const messageData = parsedMessage.data as MessageData; - messageData.content = messageData.content.replace( - /\[emote:(\d+):(\w+)\]/g, - (_, __, emoteName) => emoteName, - ); + switch (parsedMessage.type) { + case "ChatMessage": + if (mergedOptions.plainEmote) { + const messageData: MessageData = parsedMessage.data; + messageData.content = messageData.content.replace( + /\[emote:(\d+):(\w+)\]/g, + (_, __, emoteName) => emoteName, + ); + } + break; + case "Subscription": + break; + case "HostEvent": + break; + case "GiftedSubscriptions": + break; + case "UserBannedEvent": + break; + case "UserUnbannedEvent": + break; + case "PinnedMessageCreatedEvent": + break; + case "StreamHostEvent": + break; } emitter.emit(parsedMessage.type, parsedMessage.data); } diff --git a/src/utils/messageHandling.ts b/src/utils/messageHandling.ts index 35bda17..8abcee5 100644 --- a/src/utils/messageHandling.ts +++ b/src/utils/messageHandling.ts @@ -4,24 +4,59 @@ import type { Subscription, RaidEvent, } from "../types/events"; +import { parseJSON } from "./utils"; export const parseMessage = (message: string) => { try { - const messageEventJSON = JSON.parse(message) as MessageEvent; + const messageEventJSON = parseJSON(message); - if (messageEventJSON.event === "App\\Events\\ChatMessageEvent") { - const data = JSON.parse(messageEventJSON.data) as ChatMessage; - return { type: "ChatMessage", data }; - } else if (messageEventJSON.event === "App\\Events\\SubscriptionEvent") { - // TODO: Add SubscriptionEvent - // const data = JSON.parse(messageEventJSON.data) as Subscription; - // return { type: "Subscription", data }; - } else if (messageEventJSON.event === "App\\Events\\RaidEvent") { - // TODO: Add RaidEvent - // const data = JSON.parse(messageEventJSON.data) as RaidEvent; - // return { type: "RaidEvent", data }; + // switch event type + switch (messageEventJSON.event) { + case "App\\Events\\ChatMessageEvent": { + const data = parseJSON(messageEventJSON.data); + return { type: "ChatMessage", data }; + } + case "App\\Events\\SubscriptionEvent": { + // TODO: Add SubscriptionEvent + // const data = parseJSON(messageEventJSON.data); + // return { type: "Subscription", data }; + } + case "App\\Events\\GiftedSubscriptionsEvent": { + // TODO: Add GiftedSubscriptionsEvent + // const data = parseJSON(messageEventJSON.data); + // return { type: "GiftedSubscriptions", data }; + } + case "App\\Events\\UserBannedEvent": { + // TODO: Add UserBannedEvent + // const data = parseJSON(messageEventJSON.data); + // return { type: "UserBannedEvent", data }; + } + case "App\\Events\\UserUnbannedEvent": { + // TODO: Add UserUnbannedEvent + // const data = parseJSON(messageEventJSON.data); + // return { type: "UserUnbannedEvent", data }; + } + + case "App\\Events\\PinnedMessageCreatedEvent": { + // TODO: Add PinnedMessageCreatedEvent + // const data = parseJSON(messageEventJSON.data); + // return { type: "PinnedMessageCreatedEvent", data }; + } + case "App\\Events\\PinnedMessageDeletedEvent": { + // TODO: Add RaidEvent + // const data = parseJSON(messageEventJSON.data); + // return { type: "PinnedMessageDeletedEvent", data }; + } + case "App\\Events\\StreamHostEvent": { + // TODO: Add StreamHostEvent + // const data = parseJSON(messageEventJSON.data); + // return { type: "StreamHostEvent", data }; + } + default: { + console.log("Unknown event type:", messageEventJSON.event); + return null; + } } - // Add more event types as needed return null; } catch (error) { From e644a4e349b99cfd52b9a3b0934fabee6e23aa82 Mon Sep 17 00:00:00 2001 From: retconned Date: Mon, 16 Dec 2024 13:50:07 +0100 Subject: [PATCH 3/7] refactor: move message handling to core module and update client integration. --- src/client/client.ts | 8 +++----- src/{utils => core}/messageHandling.ts | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) rename src/{utils => core}/messageHandling.ts (98%) diff --git a/src/client/client.ts b/src/client/client.ts index 617bdc9..4c0cc9d 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -2,7 +2,7 @@ import WebSocket from "ws"; import EventEmitter from "events"; import { authentication, getChannelData, getVideoData } from "../core/kickApi"; import { createWebSocket } from "../core/websocket"; -import { parseMessage } from "../utils/messageHandling"; +import { parseMessage } from "../core/messageHandling"; import type { KickChannelInfo } from "../types/channels"; import type { VideoInfo } from "../types/video"; import type { @@ -128,18 +128,16 @@ export const createClient = ( break; case "Subscription": break; - case "HostEvent": - break; case "GiftedSubscriptions": break; + case "StreamHostEvent": + break; case "UserBannedEvent": break; case "UserUnbannedEvent": break; case "PinnedMessageCreatedEvent": break; - case "StreamHostEvent": - break; } emitter.emit(parsedMessage.type, parsedMessage.data); } diff --git a/src/utils/messageHandling.ts b/src/core/messageHandling.ts similarity index 98% rename from src/utils/messageHandling.ts rename to src/core/messageHandling.ts index 8abcee5..cbd3087 100644 --- a/src/utils/messageHandling.ts +++ b/src/core/messageHandling.ts @@ -4,7 +4,7 @@ import type { Subscription, RaidEvent, } from "../types/events"; -import { parseJSON } from "./utils"; +import { parseJSON } from "../utils/utils"; export const parseMessage = (message: string) => { try { From bb5289243fff43d66e529b1c94efff145427577a Mon Sep 17 00:00:00 2001 From: retconned Date: Sun, 22 Dec 2024 08:24:54 +0100 Subject: [PATCH 4/7] feat: implement user ban and unban functionality, enhance message handling, and add API request helper --- src/client/client.ts | 403 ++++++++++++++---------------------- src/core/kickApi.ts | 70 ++++--- src/core/messageHandling.ts | 60 +++--- src/core/requestHelper.ts | 60 ++++++ src/types/client.ts | 5 +- src/types/events.ts | 84 +++++++- 6 files changed, 369 insertions(+), 313 deletions(-) create mode 100644 src/core/requestHelper.ts diff --git a/src/client/client.ts b/src/client/client.ts index 4c0cc9d..4e48324 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -13,9 +13,10 @@ import type { Leaderboard, } from "../types/client"; import type { MessageData } from "../types/events"; -import axios from "axios"; import { validateAuthSettings } from "../utils/utils"; +import { createHeaders, makeRequest } from "../core/requestHelper"; + export const createClient = ( channelName: string, options: ClientOptions = {}, @@ -119,13 +120,15 @@ export const createClient = ( switch (parsedMessage.type) { case "ChatMessage": if (mergedOptions.plainEmote) { - const messageData: MessageData = parsedMessage.data; + const messageData = parsedMessage.data as MessageData; messageData.content = messageData.content.replace( /\[emote:(\d+):(\w+)\]/g, (_, __, emoteName) => emoteName, ); } break; + + // TODO: Implement other event types case "Subscription": break; case "GiftedSubscriptions": @@ -214,122 +217,98 @@ export const createClient = ( throw new Error("Message content must be less than 500 characters"); } + const headers = createHeaders({ + bearerToken: clientBearerToken!, + xsrfToken: clientToken!, + cookies: clientCookies!, + channelSlug: channelInfo.slug, + }); + try { - const response = await axios.post( + const result = await makeRequest<{ success: boolean }>( + "post", `https://kick.com/api/v2/messages/send/${channelInfo.id}`, + headers, { content: messageContent, type: "message", }, - { - headers: { - accept: "application/json, text/plain, */*", - authorization: `Bearer ${clientBearerToken}`, - "content-type": "application/json", - "x-xsrf-token": clientToken, - cookie: clientCookies, - Referer: `https://kick.com/${channelInfo.slug}`, - }, - }, ); - if (response.status === 200) { + if (result) { console.log(`Message sent successfully: ${messageContent}`); } else { - console.error(`Failed to send message. Status: ${response.status}`); + console.error(`Failed to send message.`); } } catch (error) { console.error("Error sending message:", error); } }; - const timeOut = async (targetUser: string, durationInMinutes: number) => { + const banUser = async ( + targetUser: string, + durationInMinutes?: number, + permanent: boolean = false, + ) => { if (!channelInfo) { throw new Error("Channel info not available"); } - if (!durationInMinutes) { - throw new Error("Specify a duration in minutes"); - } - - if (durationInMinutes < 1) { - throw new Error("Duration must be more than 0 minutes"); - } - checkAuth(); if (!targetUser) { throw new Error("Specify a user to ban"); } - try { - const response = await axios.post( - `https://kick.com/api/v2/channels/${channelInfo.id}/bans`, - { - banned_username: targetUser, - duration: durationInMinutes, - permanent: false, - }, - { - headers: { - accept: "application/json, text/plain, */*", - authorization: `Bearer ${clientBearerToken}`, - "content-type": "application/json", - "x-xsrf-token": clientToken, - cookie: clientCookies, - Referer: `https://kick.com/${channelInfo.slug}`, - }, - }, - ); - - if (response.status === 200) { - console.log(`User ${targetUser} timed out successfully`); - } else { - console.error(`Failed to time out user. Status: ${response.status}`); + if (!permanent) { + if (!durationInMinutes) { + throw new Error("Specify a duration in minutes"); } - } catch (error) { - console.error("Error sending message:", error); - } - }; - const permanentBan = async (targetUser: string) => { - if (!channelInfo) { - throw new Error("Channel info not available"); + if (durationInMinutes < 1) { + throw new Error("Duration must be more than 0 minutes"); + } } - checkAuth(); - - if (!targetUser) { - throw new Error("Specify a user to ban"); - } + const headers = createHeaders({ + bearerToken: clientBearerToken!, + xsrfToken: clientToken!, + cookies: clientCookies!, + channelSlug: channelInfo.slug, + }); try { - const response = await axios.post( + const data = permanent + ? { banned_username: targetUser, permanent: true } + : { + banned_username: targetUser, + duration: durationInMinutes, + permanent: false, + }; + + const result = await makeRequest<{ success: boolean }>( + "post", `https://kick.com/api/v2/channels/${channelInfo.id}/bans`, - { banned_username: targetUser, permanent: true }, - { - headers: { - accept: "application/json, text/plain, */*", - authorization: `Bearer ${clientBearerToken}`, - "content-type": "application/json", - "x-xsrf-token": clientToken, - cookie: clientCookies, - Referer: `https://kick.com/${channelInfo.slug}`, - }, - }, + headers, + data, ); - if (response.status === 200) { - console.log(`User ${targetUser} banned successfully`); + if (result) { + console.log( + `User ${targetUser} ${permanent ? "banned" : "timed out"} successfully`, + ); } else { - console.error(`Failed to ban user. Status: ${response.status}`); + console.error(`Failed to ${permanent ? "ban" : "time out"} user.`); } } catch (error) { - console.error("Error sending message:", error); + console.error( + `Error ${permanent ? "banning" : "timing out"} user:`, + error, + ); } }; - const unban = async (targetUser: string) => { + const unbanUser = async (targetUser: string) => { if (!channelInfo) { throw new Error("Channel info not available"); } @@ -340,28 +319,27 @@ export const createClient = ( throw new Error("Specify a user to unban"); } + const headers = createHeaders({ + bearerToken: clientBearerToken!, + xsrfToken: clientToken!, + cookies: clientCookies!, + channelSlug: channelInfo.slug, + }); + try { - const response = await axios.delete( + const result = await makeRequest<{ success: boolean }>( + "delete", `https://kick.com/api/v2/channels/${channelInfo.id}/bans/${targetUser}`, - { - headers: { - accept: "application/json, text/plain, */*", - authorization: `Bearer ${clientBearerToken}`, - "content-type": "application/json", - "x-xsrf-token": clientToken, - cookie: clientCookies, - Referer: `https://kick.com/${channelInfo.slug}`, - }, - }, + headers, ); - if (response.status === 200) { + if (result) { console.log(`User ${targetUser} unbanned successfully`); } else { - console.error(`Failed to unban user. Status: ${response.status}`); + console.error(`Failed to unban user.`); } } catch (error) { - console.error("Error sending message:", error); + console.error("Error unbanning user:", error); } }; @@ -376,28 +354,27 @@ export const createClient = ( throw new Error("Specify a messageId to delete"); } + const headers = createHeaders({ + bearerToken: clientBearerToken!, + xsrfToken: clientToken!, + cookies: clientCookies!, + channelSlug: channelInfo.slug, + }); + try { - const response = await axios.delete( + const result = await makeRequest<{ success: boolean }>( + "delete", `https://kick.com/api/v2/channels/${channelInfo.id}/messages/${messageId}`, - { - headers: { - accept: "application/json, text/plain, */*", - authorization: `Bearer ${clientBearerToken}`, - "content-type": "application/json", - "x-xsrf-token": clientToken, - cookie: clientCookies, - Referer: `https://kick.com/${channelInfo.slug}`, - }, - }, + headers, ); - if (response.status === 200) { + if (result) { console.log(`Message ${messageId} deleted successfully`); } else { - console.error(`Failed to delete message. Status: ${response.status}`); + console.error(`Failed to delete message.`); } } catch (error) { - console.error("Error sending message:", error); + console.error("Error deleting message:", error); } }; @@ -409,191 +386,118 @@ export const createClient = ( checkAuth(); if (mode !== "on" && mode !== "off") { - throw new Error("Invalid mode, must be 'on' or 'off'"); + throw new Error("Invalid mode, must be either 'on' or 'off'"); } - if (mode === "on" && durationInSeconds && durationInSeconds < 1) { + + if (mode === "on" && (!durationInSeconds || durationInSeconds < 1)) { throw new Error( "Invalid duration, must be greater than 0 if mode is 'on'", ); } + const headers = createHeaders({ + bearerToken: clientBearerToken!, + xsrfToken: clientToken!, + cookies: clientCookies!, + channelSlug: channelInfo.slug, + }); + try { - if (mode === "off") { - const response = await await axios.put( - `https://kick.com/api/v2/channels/${channelInfo.slug}/chatroom`, - { slow_mode: false }, - { - headers: { - accept: "application/json, text/plain, */*", - authorization: `Bearer ${clientBearerToken}`, - "content-type": "application/json", - "x-xsrf-token": clientToken, - cookie: clientCookies, - Referer: `https://kick.com/${channelInfo.slug}`, - }, - }, - ); + const data = + mode === "off" + ? { slow_mode: false } + : { slow_mode: true, message_interval: durationInSeconds }; + + const result = await makeRequest<{ success: boolean }>( + "put", + `https://kick.com/api/v2/channels/${channelInfo.slug}/chatroom`, + headers, + data, + ); - if (response.status === 200) { - console.log("Slow mode disabled successfully"); - } else { - console.error( - `Failed to disable slow mode. Status: ${response.status}`, - ); - } + if (result?.success) { + console.log( + mode === "off" + ? "Slow mode disabled successfully" + : `Slow mode enabled with ${durationInSeconds} second interval`, + ); } else { - const response = await await axios.put( - `https://kick.com/api/v2/channels/${channelInfo.slug}/chatroom`, - { slow_mode: true, message_interval: durationInSeconds }, - { - headers: { - accept: "application/json, text/plain, */*", - authorization: `Bearer ${clientBearerToken}`, - "content-type": "application/json", - "x-xsrf-token": clientToken, - cookie: clientCookies, - Referer: `https://kick.com/${channelInfo.slug}`, - }, - }, + console.error( + `Failed to ${mode === "off" ? "disable" : "enable"} slow mode.`, ); - - if (response.status === 200) { - console.log( - `Slow mode enabled with ${durationInSeconds} second interval`, - ); - } else { - console.error( - `Failed to enable slow mode. Status: ${response.status}`, - ); - } } } catch (error) { - console.error("Error sending message:", error); + console.error( + `Error ${mode === "off" ? "disabling" : "enabling"} slow mode:`, + error, + ); } }; const getPoll = async (targetChannel?: string) => { - if (targetChannel) { - try { - const response = await axios.get( - `https://kick.com/api/v2/channels/${targetChannel}/polls`, - { - headers: { - accept: "application/json, text/plain, */*", - authorization: `Bearer ${clientBearerToken}`, - "content-type": "application/json", - "x-xsrf-token": clientToken, - cookie: clientCookies, - Referer: `https://kick.com/${targetChannel}`, - }, - }, - ); + const channel = targetChannel || channelName; - if (response.status === 200) { - console.log( - `Poll retrieved successfully for channel: ${targetChannel}`, - ); - return response.data as Poll; - } - } catch (error) { - console.error( - `Error retrieving poll for channel ${targetChannel}:`, - error, - ); - return null; - } - } - if (!channelInfo) { + if (!targetChannel && !channelInfo) { throw new Error("Channel info not available"); } + const headers = createHeaders({ + bearerToken: clientBearerToken!, + xsrfToken: clientToken!, + cookies: clientCookies!, + channelSlug: channel, + }); + try { - const response = await axios.get( - `https://kick.com/api/v2/channels/${channelName}/polls`, - { - headers: { - accept: "application/json, text/plain, */*", - authorization: `Bearer ${clientBearerToken}`, - "content-type": "application/json", - "x-xsrf-token": clientToken, - cookie: clientCookies, - Referer: `https://kick.com/${channelName}`, - }, - }, + const result = await makeRequest( + "get", + `https://kick.com/api/v2/channels/${channel}/polls`, + headers, ); - if (response.status === 200) { - console.log(`Poll retrieved successfully for current channel`); - return response.data as Poll; + if (result) { + console.log(`Poll retrieved successfully for channel: ${channel}`); + return result; } } catch (error) { - console.error("Error retrieving poll for current channel:", error); - return null; + console.error(`Error retrieving poll for channel ${channel}:`, error); } return null; }; const getLeaderboards = async (targetChannel?: string) => { - if (targetChannel) { - try { - const response = await axios.get( - `https://kick.com/api/v2/channels/${targetChannel}/leaderboards`, - { - headers: { - accept: "application/json, text/plain, */*", - authorization: `Bearer ${clientBearerToken}`, - "content-type": "application/json", - "x-xsrf-token": clientToken, - cookie: clientCookies, - Referer: `https://kick.com/${targetChannel}`, - }, - }, - ); + const channel = targetChannel || channelName; - if (response.status === 200) { - console.log( - `Leaderboards retrieved successfully for channel: ${targetChannel}`, - ); - return response.data as Leaderboard; - } - } catch (error) { - console.error( - `Error retrieving leaderboards for channel ${targetChannel}:`, - error, - ); - return null; - } - } - if (!channelInfo) { + if (!targetChannel && !channelInfo) { throw new Error("Channel info not available"); } + const headers = createHeaders({ + bearerToken: clientBearerToken!, + xsrfToken: clientToken!, + cookies: clientCookies!, + channelSlug: channel, + }); + try { - const response = await axios.get( - `https://kick.com/api/v2/channels/${channelName}/leaderboards`, - { - headers: { - accept: "application/json, text/plain, */*", - authorization: `Bearer ${clientBearerToken}`, - "content-type": "application/json", - "x-xsrf-token": clientToken, - cookie: clientCookies, - Referer: `https://kick.com/${channelName}`, - }, - }, + const result = await makeRequest( + "get", + `https://kick.com/api/v2/channels/${channel}/leaderboards`, + headers, ); - if (response.status === 200) { - console.log(`Leaderboards retrieved successfully for current channel`); - return response.data as Leaderboard; + if (result) { + console.log( + `Leaderboards retrieved successfully for channel: ${channel}`, + ); + return result; } } catch (error) { console.error( - "Error retrieving leaderboards for current channel:", + `Error retrieving leaderboards for channel ${channel}:`, error, ); - return null; } return null; @@ -607,9 +511,8 @@ export const createClient = ( }, vod, sendMessage, - timeOut, - permanentBan, - unban, + banUser, + unbanUser, deleteMessage, slowMode, getPoll, diff --git a/src/core/kickApi.ts b/src/core/kickApi.ts index 98a95a7..4044bfc 100644 --- a/src/core/kickApi.ts +++ b/src/core/kickApi.ts @@ -3,21 +3,33 @@ import StealthPlugin from "puppeteer-extra-plugin-stealth"; import type { KickChannelInfo } from "../types/channels"; import type { VideoInfo } from "../types/video"; import { authenticator } from "otplib"; +import type { AuthenticationSettings } from "../types/client"; -export const getChannelData = async ( - channel: string, -): Promise => { +/** + * Helper function to setup Puppeteer with Stealth Plugin + */ +const setupPuppeteer = async () => { const puppeteerExtra = puppeteer.use(StealthPlugin()); const browser = await puppeteerExtra.launch({ headless: true }); - const page = await browser.newPage(); + return { browser, page }; +}; + +/** + * Fetches channel data from Kick API + * @param channel - Channel name + * @returns KickChannelInfo or null + */ +export const getChannelData = async ( + channel: string, +): Promise => { + const { browser, page } = await setupPuppeteer(); try { const response = await page.goto( `https://kick.com/api/v2/channels/${channel}`, ); - // Check if blocked by Cloudflare if (response?.status() === 403) { throw new Error( "Request blocked by Cloudflare protection. Please try again later.", @@ -31,35 +43,33 @@ export const getChannelData = async ( if (!bodyElement || !bodyElement.textContent) { throw new Error("Unable to fetch channel data"); } - return JSON.parse(bodyElement.textContent); }); - await browser.close(); return jsonContent; } catch (error) { - await browser.close(); - if (error instanceof Error && error.message.includes("Cloudflare")) { - throw error; // Re-throw Cloudflare-specific error - } console.error("Error getting channel data:", error); return null; + } finally { + await browser.close(); } }; +/** + * Fetches video data from Kick API + * @param video_id - Video ID + * @returns VideoInfo or null + */ export const getVideoData = async ( video_id: string, ): Promise => { - const puppeteerExtra = puppeteer.use(StealthPlugin()); - const browser = await puppeteerExtra.launch({ headless: true }); - const page = await browser.newPage(); + const { browser, page } = await setupPuppeteer(); try { const response = await page.goto( `https://kick.com/api/v1/video/${video_id}`, ); - // Check if blocked by Cloudflare if (response?.status() === 403) { throw new Error( "Request blocked by Cloudflare protection. Please try again later.", @@ -76,27 +86,32 @@ export const getVideoData = async ( return JSON.parse(bodyElement.textContent); }); - await browser.close(); return jsonContent; } catch (error) { - await browser.close(); - if (error instanceof Error && error.message.includes("Cloudflare")) { - throw error; // Re-throw Cloudflare-specific error - } console.error("Error getting video data:", error); return null; + } finally { + await browser.close(); } }; +/** + * Authenticates a user and retrieves authentication tokens + * @param username - Username + * @param password - Password + * @param otp_secret - OTP Secret + * @returns Authentication tokens and status + */ export const authentication = async ({ username, password, otp_secret, -}: { - username: string; - password: string; - otp_secret: string; -}) => { +}: AuthenticationSettings): Promise<{ + bearerToken: string; + xsrfToken: string; + cookies: string; + isAuthenticated: boolean; +}> => { let bearerToken = ""; let xsrfToken = ""; let cookieString = ""; @@ -210,8 +225,6 @@ export const authentication = async ({ isAuthenticated = true; - await browser.close(); - return { bearerToken, xsrfToken, @@ -219,7 +232,8 @@ export const authentication = async ({ isAuthenticated, }; } catch (error: any) { - await browser.close(); throw error; + } finally { + await browser.close(); } }; diff --git a/src/core/messageHandling.ts b/src/core/messageHandling.ts index cbd3087..25a4191 100644 --- a/src/core/messageHandling.ts +++ b/src/core/messageHandling.ts @@ -2,7 +2,12 @@ import type { MessageEvent, ChatMessage, Subscription, - RaidEvent, + GiftedSubscriptionsEvent, + StreamHostEvent, + UserBannedEvent, + UserUnbannedEvent, + PinnedMessageCreatedEvent, + MessageDeletedEvent, } from "../types/events"; import { parseJSON } from "../utils/utils"; @@ -17,41 +22,48 @@ export const parseMessage = (message: string) => { return { type: "ChatMessage", data }; } case "App\\Events\\SubscriptionEvent": { - // TODO: Add SubscriptionEvent - // const data = parseJSON(messageEventJSON.data); - // return { type: "Subscription", data }; + const data = parseJSON(messageEventJSON.data); + return { type: "Subscription", data }; } case "App\\Events\\GiftedSubscriptionsEvent": { - // TODO: Add GiftedSubscriptionsEvent - // const data = parseJSON(messageEventJSON.data); - // return { type: "GiftedSubscriptions", data }; + const data = parseJSON(messageEventJSON.data); + return { type: "GiftedSubscriptions", data }; + } + case "App\\Events\\StreamHostEvent": { + const data = parseJSON(messageEventJSON.data); + return { type: "StreamHost", data }; + } + case "App\\Events\\MessageDeletedEvent": { + const data = parseJSON(messageEventJSON.data); + return { type: "MessageDeleted", data }; } case "App\\Events\\UserBannedEvent": { - // TODO: Add UserBannedEvent - // const data = parseJSON(messageEventJSON.data); - // return { type: "UserBannedEvent", data }; + const data = parseJSON(messageEventJSON.data); + return { type: "UserBanned", data }; } case "App\\Events\\UserUnbannedEvent": { - // TODO: Add UserUnbannedEvent - // const data = parseJSON(messageEventJSON.data); - // return { type: "UserUnbannedEvent", data }; + const data = parseJSON(messageEventJSON.data); + return { type: "UserUnbanned", data }; } - case "App\\Events\\PinnedMessageCreatedEvent": { - // TODO: Add PinnedMessageCreatedEvent - // const data = parseJSON(messageEventJSON.data); - // return { type: "PinnedMessageCreatedEvent", data }; + const data = parseJSON( + messageEventJSON.data, + ); + return { type: "PinnedMessageCreated", data }; } case "App\\Events\\PinnedMessageDeletedEvent": { - // TODO: Add RaidEvent - // const data = parseJSON(messageEventJSON.data); - // return { type: "PinnedMessageDeletedEvent", data }; + const data = parseJSON(messageEventJSON.data); + return { type: "PinnedMessageDeleted", data }; } - case "App\\Events\\StreamHostEvent": { - // TODO: Add StreamHostEvent - // const data = parseJSON(messageEventJSON.data); - // return { type: "StreamHostEvent", data }; + case "App\\Events\\PollUpdateEvent": { + const data = parseJSON(messageEventJSON.data); + return { type: "PollUpdate", data }; + } + case "App\\Events\\PollDeleteEvent": { + const data = parseJSON(messageEventJSON.data); + return { type: "PollDelete", data }; } + default: { console.log("Unknown event type:", messageEventJSON.event); return null; diff --git a/src/core/requestHelper.ts b/src/core/requestHelper.ts new file mode 100644 index 0000000..f56b8c3 --- /dev/null +++ b/src/core/requestHelper.ts @@ -0,0 +1,60 @@ +import axios, { type AxiosResponse } from "axios"; + +import { AxiosHeaders } from "axios"; + +export interface ApiHeaders extends AxiosHeaders { + accept: string; + authorization: string; + "content-type": string; + "x-xsrf-token": string; + cookie: string; + Referer: string; +} + +export interface RequestConfig { + bearerToken: string; + xsrfToken: string; + cookies: string; + channelSlug: string; +} + +export const createHeaders = ({ + bearerToken, + xsrfToken, + cookies, + channelSlug, +}: RequestConfig): AxiosHeaders => { + const headers = new AxiosHeaders(); + headers.set("accept", "application/json, text/plain, */*"); + headers.set("authorization", `Bearer ${bearerToken}`); + headers.set("content-type", "application/json"); + headers.set("x-xsrf-token", xsrfToken); + headers.set("cookie", cookies); + headers.set("Referer", `https://kick.com/${channelSlug}`); + return headers; +}; + +export const makeRequest = async ( + method: "get" | "post" | "put" | "delete", + url: string, + headers: AxiosHeaders, + data?: unknown, +): Promise => { + try { + const response: AxiosResponse = await axios({ + method, + url, + headers, + data, + }); + + if (response.status === 200) { + return response.data; + } + console.error(`Request failed with status: ${response.status}`); + return null; + } catch (error) { + console.error(`Request error for ${url}:`, error); + return null; + } +}; diff --git a/src/types/client.ts b/src/types/client.ts index 7db7920..298fb30 100644 --- a/src/types/client.ts +++ b/src/types/client.ts @@ -35,9 +35,8 @@ export interface KickClient { tag: string; } | null; sendMessage: (messageContent: string) => Promise; - timeOut: (targetUser: string, durationInMinutes: number) => Promise; - permanentBan: (targetUser: string) => Promise; - unban: (targetUser: string) => Promise; + banUser: (targetUser: string) => Promise; + unbanUser: (targetUser: string) => Promise; deleteMessage: (messageId: string) => Promise; slowMode: (mode: "on" | "off", durationInSeconds?: number) => Promise; getPoll: (targetChannel?: string) => Promise; diff --git a/src/types/events.ts b/src/types/events.ts index 02addce..f7bd23a 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -44,16 +44,84 @@ export interface ChatMessage { }; } -// these are not implemented yet export interface Subscription { - id: string; - user_id: number; + chatroom_id: number; username: string; - // Add more properties as needed + months: number; +} + +export interface GiftedSubscriptionsEvent { + chatroom_id: number; + gifted_usernames: string[]; + gifter_username: string; +} + +export interface StreamHostEvent { + chatroom_id: number; + optional_message: string; + number_viewers: number; + host_username: string; +} + +export interface MessageDeletedEvent { + id: string; + message: { + id: string; + }; } -export interface RaidEvent { - raider_username: string; - viewer_count: number; - // Add more properties as needed +export interface UserBannedEvent { + id: string; + user: { + id: number; + username: string; + slug: string; + }; + + banned_by: { + id: number; + username: string; + slug: string; + }; + + expires_at?: Date; +} + +export interface UserUnbannedEvent { + id: string; + user: { + id: number; + username: string; + slug: string; + }; + unbanned_by: { + id: number; + username: string; + slug: string; + }; +} + +export interface PinnedMessageCreatedEvent { + message: { + id: string; + chatroom_id: number; + content: string; + type: string; + created_at: Date; + sender: { + id: number; + username: string; + slug: string; + identity: { + color: string; + badges: Array<{ + type: string; + text: string; + count?: number; + }>; + }; + }; + metadata: null; + }; + duration: string; } From 8be4832a883b15b52de70bd5bc4e5760bee77034 Mon Sep 17 00:00:00 2001 From: retconned Date: Tue, 24 Dec 2024 23:13:48 +0100 Subject: [PATCH 5/7] fix: fixes type in banUser docs: added all new endpoints to bot example --- examples/basic-bot.ts | 33 ++++++++++++++++++++++++++------- src/types/client.ts | 6 +++++- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/examples/basic-bot.ts b/examples/basic-bot.ts index de151a4..0c3da32 100644 --- a/examples/basic-bot.ts +++ b/examples/basic-bot.ts @@ -23,8 +23,8 @@ client.on("ChatMessage", async (message: MessageData) => { const splitMessage = message.content.split(" "); const duration = splitMessage[1]; if (duration) { - const durationNumber = parseInt(duration); - client.slowMode("on", durationNumber); + const durationInSeconds = parseInt(duration); + client.slowMode("on", durationInSeconds); } } if (message.content.match("!slowmode off")) { @@ -33,9 +33,13 @@ client.on("ChatMessage", async (message: MessageData) => { if (message.content.match("!ban")) { const splitMessage = message.content.split(" "); - const bannedUser = splitMessage[1]; - if (bannedUser) { - client.permanentBan(bannedUser); + const targetUser = splitMessage[1]; + const duration = splitMessage[2]; + if (targetUser && duration) { + client.banUser(targetUser, parseInt(duration)); + } + if (targetUser && duration && duration === "9999") { + client.banUser(targetUser, 0, true); } } }); @@ -50,9 +54,24 @@ const { title, duration, thumbnail, views } = await client.vod("your-video-id"); // to get the current poll in a channel in the channel the bot is in const poll = await client.getPoll(); // or you can pass a specific channel to get the poll in that channel. -// example: const poll = await client.getPoll("xqc"); +// example: +const channelPoll = await client.getPoll("xqc"); // get leaderboards for the channel the bot is in const leaderboards = await client.getLeaderboards(); // or you can pass a specific channel to get the leaderboards in that channel. -// example: const leaderboards = await client.getLeaderboards("xqc"); + +// example: +const channelLeaderboards = await client.getLeaderboards("xqc"); + +// permanent ban a user +client.banUser("user-to-ban", 0, true); + +// temporary ban a user for 10 minutes +client.banUser("user-to-ban", 10); + +// unban a user +client.unbanUser("user-to-unban"); + +// delete a message +client.deleteMessage("message-id"); diff --git a/src/types/client.ts b/src/types/client.ts index 298fb30..0fb26e6 100644 --- a/src/types/client.ts +++ b/src/types/client.ts @@ -35,7 +35,11 @@ export interface KickClient { tag: string; } | null; sendMessage: (messageContent: string) => Promise; - banUser: (targetUser: string) => Promise; + banUser: ( + targetUser: string, + durationInMinutes?: number, + permanent?: boolean, + ) => Promise; unbanUser: (targetUser: string) => Promise; deleteMessage: (messageId: string) => Promise; slowMode: (mode: "on" | "off", durationInSeconds?: number) => Promise; From b12161b63f7958c4fe8af38f85c387e929caa762 Mon Sep 17 00:00:00 2001 From: retconned Date: Sat, 1 Feb 2025 03:08:06 +0100 Subject: [PATCH 6/7] refactor: update login functionality to support tokens auth, fixes sendMessage function --- examples/basic-bot.ts | 63 +++++---------- src/client/client.ts | 158 +++++++++++++++++++++++++------------- src/core/kickApi.ts | 30 ++------ src/core/requestHelper.ts | 11 ++- src/types/client.ts | 17 +++- src/utils/utils.ts | 46 ++++++++--- 6 files changed, 188 insertions(+), 137 deletions(-) diff --git a/examples/basic-bot.ts b/examples/basic-bot.ts index 0c3da32..adf81bc 100644 --- a/examples/basic-bot.ts +++ b/examples/basic-bot.ts @@ -1,11 +1,24 @@ import { createClient, type MessageData } from "@retconned/kick-js"; +import "dotenv/config"; -const client = createClient("xqc", { logger: true }); +const client = createClient("xqc", { logger: true, readOnly: false }); + +// client.login({ +// type: "login", +// credentials: { +// username: process.env.USERNAME!, +// password: process.env.PASSWORD!, +// otp_secret: process.env.OTP_SECRET!, +// }, +// }); client.login({ - username: process.env.USERNAME!, - password: process.env.PASSWORD!, - otp_secret: process.env.OTP_SECRET!, + type: "tokens", + credentials: { + bearerToken: process.env.BEARER_TOKEN!, + xsrfToken: process.env.XSRF_TOKEN!, + cookies: process.env.COOKIES!, + }, }); client.on("ready", () => { @@ -16,7 +29,7 @@ client.on("ChatMessage", async (message: MessageData) => { console.log(`${message.sender.username}: ${message.content}`); if (message.content.match("!ping")) { - client.sendMessage("pong"); + client.sendMessage(Math.random().toString(36).substring(7)); } if (message.content.match("!slowmode on")) { @@ -30,48 +43,8 @@ client.on("ChatMessage", async (message: MessageData) => { if (message.content.match("!slowmode off")) { client.slowMode("off"); } - - if (message.content.match("!ban")) { - const splitMessage = message.content.split(" "); - const targetUser = splitMessage[1]; - const duration = splitMessage[2]; - if (targetUser && duration) { - client.banUser(targetUser, parseInt(duration)); - } - if (targetUser && duration && duration === "9999") { - client.banUser(targetUser, 0, true); - } - } }); client.on("Subscription", async (subscription) => { console.log(`New subscription 💰 : ${subscription.username}`); }); - -// get information about a vod -const { title, duration, thumbnail, views } = await client.vod("your-video-id"); - -// to get the current poll in a channel in the channel the bot is in -const poll = await client.getPoll(); -// or you can pass a specific channel to get the poll in that channel. -// example: -const channelPoll = await client.getPoll("xqc"); - -// get leaderboards for the channel the bot is in -const leaderboards = await client.getLeaderboards(); -// or you can pass a specific channel to get the leaderboards in that channel. - -// example: -const channelLeaderboards = await client.getLeaderboards("xqc"); - -// permanent ban a user -client.banUser("user-to-ban", 0, true); - -// temporary ban a user for 10 minutes -client.banUser("user-to-ban", 10); - -// unban a user -client.unbanUser("user-to-unban"); - -// delete a message -client.deleteMessage("message-id"); diff --git a/src/client/client.ts b/src/client/client.ts index 4e48324..39e2c83 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -11,9 +11,10 @@ import type { AuthenticationSettings, Poll, Leaderboard, + LoginOptions, } from "../types/client"; import type { MessageData } from "../types/events"; -import { validateAuthSettings } from "../utils/utils"; +import { validateCredentials } from "../utils/utils"; import { createHeaders, makeRequest } from "../core/requestHelper"; @@ -43,40 +44,78 @@ export const createClient = ( if (!isLoggedIn) { throw new Error("Authentication required. Please login first."); } - if (!clientBearerToken || !clientToken || !clientCookies) { - throw new Error("Missing authentication tokens"); + if (!clientBearerToken) { + throw new Error("Missing bearer token"); + } + + if (!clientCookies) { + throw new Error("Missing cookies"); } }; - const login = async (credentials: AuthenticationSettings) => { + const login = async (options: LoginOptions) => { + const { type, credentials } = options; + try { - validateAuthSettings(credentials); + switch (type) { + case "login": + if (!credentials) { + throw new Error("Credentials are required for login"); + } + validateCredentials(options); - if (mergedOptions.logger) { - console.log("Starting authentication process..."); - } + if (mergedOptions.logger) { + console.log("Starting authentication process with login ..."); + } - const { bearerToken, xsrfToken, cookies, isAuthenticated } = - await authentication(credentials); + const { bearerToken, xsrfToken, cookies, isAuthenticated } = + await authentication({ + username: credentials.username, + password: credentials.password, + otp_secret: credentials.otp_secret, + }); - if (mergedOptions.logger) { - console.log("Authentication tokens received, validating..."); - } + if (mergedOptions.logger) { + console.log("Authentication tokens received, validating..."); + } - clientBearerToken = bearerToken; - clientToken = xsrfToken; - clientCookies = cookies; - isLoggedIn = isAuthenticated; + clientBearerToken = bearerToken; + clientToken = xsrfToken; + clientCookies = cookies; + isLoggedIn = isAuthenticated; - if (!isAuthenticated) { - throw new Error("Authentication failed"); - } + if (!isAuthenticated) { + throw new Error("Authentication failed"); + } - if (mergedOptions.logger) { - console.log("Authentication successful, initializing client..."); + if (mergedOptions.logger) { + console.log("Authentication successful, initializing client..."); + } + + await initialize(); + break; + + case "tokens": + if (!credentials) { + throw new Error("Tokens are required for login"); + } + + if (mergedOptions.logger) { + console.log("Starting authentication process with tokens ..."); + } + + clientBearerToken = credentials.bearerToken; + clientToken = credentials.xsrfToken; + clientCookies = credentials.cookies; + + isLoggedIn = true; + + await initialize(); + break; + default: + throw new Error("Invalid authentication type"); } - await initialize(); // Initialize after successful login return true; } catch (error) { console.error("Login failed:", error); @@ -86,7 +125,7 @@ export const createClient = ( const initialize = async () => { try { - if (!mergedOptions.readOnly && !isLoggedIn) { + if (mergedOptions.readOnly === false && !isLoggedIn) { throw new Error("Authentication required. Please login first."); } @@ -127,8 +166,6 @@ export const createClient = ( ); } break; - - // TODO: Implement other event types case "Subscription": break; case "GiftedSubscriptions": @@ -163,8 +200,7 @@ export const createClient = ( } }; - // Only initialize immediately if readOnly is true - if (mergedOptions.readOnly) { + if (mergedOptions.readOnly === true) { void initialize(); } @@ -211,38 +247,50 @@ export const createClient = ( throw new Error("Channel info not available"); } - checkAuth(); - if (messageContent.length > 500) { throw new Error("Message content must be less than 500 characters"); } - const headers = createHeaders({ - bearerToken: clientBearerToken!, - xsrfToken: clientToken!, - cookies: clientCookies!, - channelSlug: channelInfo.slug, - }); - - try { - const result = await makeRequest<{ success: boolean }>( - "post", - `https://kick.com/api/v2/messages/send/${channelInfo.id}`, - headers, - { - content: messageContent, - type: "message", - }, - ); - - if (result) { - console.log(`Message sent successfully: ${messageContent}`); - } else { - console.error(`Failed to send message.`); - } - } catch (error) { - console.error("Error sending message:", error); + if (!clientCookies) { + throw new Error("WebSocket connection not established"); } + if (!clientBearerToken) { + throw new Error("WebSocket connection not established"); + } + // this is a temp thing till i figure out whats the axios issue + + const res = fetch( + `https://kick.com/api/v2/messages/send/${channelInfo.id}`, + { + headers: { + accept: "application/json", + "accept-language": "en-US,en;q=0.9", + authorization: `Bearer ${clientBearerToken}`, + "cache-control": "max-age=0", + cluster: "v2", + "content-type": "application/json", + priority: "u=1, i", + "sec-ch-ua": '"Not A(Brand";v="8", "Chromium";v="132"', + "sec-ch-ua-arch": '"arm"', + "sec-ch-ua-bitness": '"64"', + "sec-ch-ua-full-version": '"132.0.6834.111"', + "sec-ch-ua-full-version-list": + '"Not A(Brand";v="8.0.0.0", "Chromium";v="132.0.6834.111"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-model": '""', + "sec-ch-ua-platform": '"macOS"', + "sec-ch-ua-platform-version": '"15.0.1"', + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + cookie: clientCookies, + Referer: `https://kick.com/${channelInfo.slug}`, + "Referrer-Policy": "strict-origin-when-cross-origin", + }, + body: `{"content":"${messageContent}","type":"message"}`, + method: "POST", + }, + ); }; const banUser = async ( diff --git a/src/core/kickApi.ts b/src/core/kickApi.ts index 4044bfc..5dbc46c 100644 --- a/src/core/kickApi.ts +++ b/src/core/kickApi.ts @@ -5,9 +5,6 @@ import type { VideoInfo } from "../types/video"; import { authenticator } from "otplib"; import type { AuthenticationSettings } from "../types/client"; -/** - * Helper function to setup Puppeteer with Stealth Plugin - */ const setupPuppeteer = async () => { const puppeteerExtra = puppeteer.use(StealthPlugin()); const browser = await puppeteerExtra.launch({ headless: true }); @@ -15,11 +12,6 @@ const setupPuppeteer = async () => { return { browser, page }; }; -/** - * Fetches channel data from Kick API - * @param channel - Channel name - * @returns KickChannelInfo or null - */ export const getChannelData = async ( channel: string, ): Promise => { @@ -55,11 +47,6 @@ export const getChannelData = async ( } }; -/** - * Fetches video data from Kick API - * @param video_id - Video ID - * @returns VideoInfo or null - */ export const getVideoData = async ( video_id: string, ): Promise => { @@ -95,13 +82,6 @@ export const getVideoData = async ( } }; -/** - * Authenticates a user and retrieves authentication tokens - * @param username - Username - * @param password - Password - * @param otp_secret - OTP Secret - * @returns Authentication tokens and status - */ export const authentication = async ({ username, password, @@ -219,8 +199,14 @@ export const authentication = async ({ xsrfToken = xsrfTokenCookie; } - if (!bearerToken || !xsrfToken || !cookieString) { - throw new Error("Failed to capture authentication tokens"); + if (!cookieString || cookieString === "") { + throw new Error("Failed to capture cookies"); + } + if (!bearerToken || bearerToken === "") { + throw new Error("Failed to capture bearer token"); + } + if (!xsrfToken || xsrfToken === "") { + throw new Error("Failed to capture xsrf token"); } isAuthenticated = true; diff --git a/src/core/requestHelper.ts b/src/core/requestHelper.ts index f56b8c3..f213e0b 100644 --- a/src/core/requestHelper.ts +++ b/src/core/requestHelper.ts @@ -20,17 +20,22 @@ export interface RequestConfig { export const createHeaders = ({ bearerToken, - xsrfToken, cookies, channelSlug, }: RequestConfig): AxiosHeaders => { const headers = new AxiosHeaders(); - headers.set("accept", "application/json, text/plain, */*"); + + headers.set("accept", "application/json"); + headers.set("accept-language", "en-US,en;q=0.9"); headers.set("authorization", `Bearer ${bearerToken}`); + headers.set("cache-control", "max-age=0"); + headers.set("cluster", "v2"); headers.set("content-type", "application/json"); - headers.set("x-xsrf-token", xsrfToken); + headers.set("priority", "u=1, i"); headers.set("cookie", cookies); headers.set("Referer", `https://kick.com/${channelSlug}`); + headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); + return headers; }; diff --git a/src/types/client.ts b/src/types/client.ts index 0fb26e6..c07252f 100644 --- a/src/types/client.ts +++ b/src/types/client.ts @@ -28,7 +28,7 @@ export interface Video { export interface KickClient { on: (event: string, listener: (...args: any[]) => void) => void; vod: (video_id: string) => Promise