UNPKG

@ritamoe/kient

Version:

<h1 align="center"> Kient </h1>

895 lines (852 loc) 23.7 kB
// src/kient.ts import defu from "defu"; import { EventEmitter } from "tseep"; // src/api.client.ts import { ofetch } from "ofetch"; class APIClient { kient; apiFetch; ofetchOptions; constructor(kient, options) { this.kient = kient; this.ofetchOptions = options?.ofetch || {}; this.apiFetch = ofetch.create(this.ofetchOptions); } get fetch() { return this.apiFetch; } setHeaders(headers) { this.apiFetch = ofetch.create({ ...this.ofetchOptions, headers: { ...this.ofetchOptions.headers, ...headers } }); } } // src/api/api-base.ts class APIBase { kient; constructor(kient) { this.kient = kient; } } // src/util/flatten.ts function flatten(obj, ...props) { if (!obj || typeof obj !== "object") { return obj; } if (Array.isArray(obj)) { return obj.map((item) => flatten(item)); } const objProps = Object.keys(obj).filter((key) => !key.startsWith("_")).map((key) => ({ [key]: true })); const mergedProps = Object.assign({}, ...objProps, ...props); const out = {}; for (const [prop, newProp] of Object.entries(mergedProps)) { if (!newProp) continue; const outputKey = newProp === true ? prop : newProp; const element = obj[prop]; const elemIsObj = element && typeof element === "object"; const valueOf = elemIsObj && typeof element.valueOf === "function" ? element.valueOf() : null; const hasToJSON = elemIsObj && typeof element.toJSON === "function"; if (element instanceof Date) { out[outputKey] = element.toISOString(); } else if (Array.isArray(element)) { out[outputKey] = element.map((elm) => hasToJSON ? elm.toJSON() : flatten(elm)); } else if (hasToJSON) { out[outputKey] = element.toJSON(); } else if (elemIsObj && valueOf && typeof valueOf !== "object") { out[outputKey] = valueOf; } else if (elemIsObj) { out[outputKey] = flatten(element); } else { out[outputKey] = element; } } return out; } // src/structures/base.ts class Base { _kient; constructor(kient) { this._kient = kient; } } // src/structures/category.ts class Category extends Base { id; name; thumbnail; constructor(kient, data) { super(kient); this.id = data.id; this.name = data.name; this.thumbnail = data.thumbnail; } toJSON() { return flatten(this); } } // src/api/category/get-categories.ts async function getCategoriesByQuery(kient, query) { const response = await kient._apiClient.fetch("/categories", { query: { q: query } }); const categoryInstances = []; for (const categoryData of response.data) { const category = new Category(kient, categoryData); categoryInstances.push(category); } return categoryInstances; } // src/api/category/index.ts class CategoryAPI extends APIBase { query(query) { return getCategoriesByQuery(this.kient, query); } } // src/structures/base-user.ts class BaseUser extends Base { id; username; profilePicture; constructor(kient, data) { super(kient); this.id = data.id; this.username = data.username; this.profilePicture = data.profilePicture; } toJSON() { return flatten(this); } } // src/structures/user.ts class User extends BaseUser { email; constructor(kient, data) { super(kient, data); this.email = data.email === "" ? undefined : data.email; } toJSON() { return flatten(this); } } // src/api/user/get-user.ts async function getUsersByID(kient, ids = []) { const params = new URLSearchParams; for (const id of ids) { params.append("id", id.toString()); } const response = await kient._apiClient.fetch(`/users?${params}`); const userInstances = []; for (const userData of response.data) { const category = new User(kient, { id: userData.user_id, username: userData.name, email: userData.email, profilePicture: userData.profile_picture }); userInstances.push(category); } return userInstances; } // src/api/user/index.ts class UserAPI extends APIBase { getByIds(ids) { return getUsersByID(this.kient, ids); } async getById(id) { return (await getUsersByID(this.kient, [id]))[0]; } async getAuthorisedUser() { return (await getUsersByID(this.kient))[0]; } } // src/structures/channel.ts class Channel extends Base { id; slug; bannerPicture; channelDescription; stream; ingest; constructor(kient, data) { super(kient); this.id = data.id; this.slug = data.slug; this.bannerPicture = data.bannerPicture; this.channelDescription = data.channelDescription; this.stream = { category: new Category(kient, data.stream.category), isLive: data.stream.isLive, isMature: data.stream.isMature, language: data.stream.language, startTime: new Date(data.stream.startTime), streamTitle: data.stream.streamTitle, viewerCount: data.stream.viewerCount }; if (data.ingest?.key && data.ingest.url) { this.ingest = { key: data.ingest.key, url: data.ingest.url }; } } toJSON() { return flatten(this); } } // src/api/channel/get-channels.ts async function fetchChannels(kient, params) { const response = await kient._apiClient.fetch(`/channels?${params}`); const channelInstances = []; for (const channelData of response.data) { const channel = new Channel(kient, { id: channelData.broadcaster_user_id, slug: channelData.slug, bannerPicture: channelData.banner_picture, channelDescription: channelData.channel_description, stream: { category: channelData.category, isLive: channelData.stream.is_live, isMature: channelData.stream.is_mature, language: channelData.stream.language, startTime: channelData.stream.start_time, streamTitle: channelData.stream_title, viewerCount: channelData.stream.viewer_count }, ingest: { key: channelData.stream.key || "", url: channelData.stream.url || "" } }); channelInstances.push(channel); } return channelInstances; } async function getChannelsByID(kient, ids = []) { const params = new URLSearchParams; for (const id of ids) { params.append("broadcaster_user_id", id.toString()); } return fetchChannels(kient, params); } async function getChannelsBySlugs(kient, slugs = []) { const params = new URLSearchParams; for (const slug of slugs) { params.append("slug", slug); } return fetchChannels(kient, params); } // src/api/channel/index.ts class ChannelAPI extends APIBase { getByIds(ids) { return getChannelsByID(this.kient, ids); } async getById(id) { return (await getChannelsByID(this.kient, [id]))[0]; } getBySlugs(slugs) { return getChannelsBySlugs(this.kient, slugs); } async getBySlug(slug) { return (await getChannelsBySlugs(this.kient, [slug]))[0]; } async getAuthorisedUser() { return (await getChannelsByID(this.kient))[0]; } } // src/structures/chat.ts class Chat extends Base { id; isSent; constructor(kient, data) { super(kient); this.id = data.id; this.isSent = data.isSent; } toJSON() { return flatten(this); } } // src/api/chat/send-chat.ts async function sendChat(kient, params) { const requestParams = { content: params.message, type: params.type }; if (params.type === "user") { requestParams.broadcaster_user_id = params.userId; } const response = await kient._apiClient.fetch("/chat", { method: "POST", body: JSON.stringify(requestParams) }); const chat = new Chat(kient, { id: response.data.message_id, isSent: response.data.is_sent }); return chat; } // src/api/chat/index.ts class ChatAPI extends APIBase { send(params) { return sendChat(this.kient, params); } } // src/api/misc/get-public-key.ts async function getPublicKey(kient) { const response = await kient._apiClient.fetch("/public-key"); return response.data.public_key; } // src/api/misc/index.ts class MiscAPI extends APIBase { getPublicKey() { return getPublicKey(this.kient); } } // src/webhook.server.ts import { Hono } from "hono"; // src/util/verify-webhook-signature.ts import crypto from "node:crypto"; async function verifyWebhookSignature(params) { try { const data = `${params.messageId}.${params.timestamp}.${params.body}`; const verifer = crypto.createVerify("RSA-SHA256"); verifer.update(data); const signature = Buffer.from(params.signature, "base64"); const isValid = verifer.verify(params.publicKey, signature); if (!isValid) { console.warn("Webhook signature verification failed"); } return isValid; } catch (err) { console.error("Unable to verify signature of webhook event", err); return false; } } // src/webhook.server.ts class WebhookServer { kient; instance; constructor(kient) { this.kient = kient; this.instance = new Hono; this.initialiseRoutes(); } initialiseRoutes() { this.instance.post("/webhook", async (c) => { if (this.kient._kickPublicKey) { const messageId = c.req.header("Kick-Event-Message-Id"); const subscriptionId = c.req.header("Kick-Event-Subscription-Id"); const signature = c.req.header("Kick-Event-Signature"); const timestamp = c.req.header("Kick-Event-Message-Timestamp"); const body = await c.req.text(); if (!messageId || !timestamp || !body || !signature) { console.error("Missing required parameters for signature verification"); return c.body(null, 400); } const signatureValid = verifyWebhookSignature({ publicKey: this.kient._kickPublicKey, messageId, signature, timestamp, body }); if (!signatureValid) { return c.body(null, 400); } const eventType = c.req.header("Kick-Event-Type"); const eventVersion = c.req.header("Kick-Event-Version"); if (!eventType || !eventVersion || !subscriptionId) { console.error("Missing required event type or version"); return c.body(null, 400); } this.kient.handleWebhookEvent({ messageId, subscriptionId, timestamp, type: eventType, version: eventVersion, body }); } else { console.warn("Not handling webhook event as public key is not available"); } return c.body(null, 200); }); } get fetch() { return this.instance.fetch; } } // src/structures/chat-message.ts import destr from "destr"; // src/structures/base-event.ts class EventBase extends Base { kickEvent; constructor(kient, data) { super(kient); this.kickEvent = { messageId: data.messageId, timestamp: new Date(data.timestamp), subscriptionId: data.subscriptionId }; } unsubscribe() { this._kient.api.event.unsubscribe([this.kickEvent.subscriptionId]); } } // src/structures/chat-user.ts class ChatUser extends BaseUser { isVerified; slug; constructor(kient, data) { super(kient, data); this.isVerified = data.isVerified; this.slug = data.slug; } static constructChatUser(data) { return { id: data.user_id, username: data.username, profilePicture: data.profile_picture, isVerified: data.is_verified, slug: data.channel_slug }; } toJSON() { return flatten(this); } } // src/structures/chat-message.ts class ChatMessage extends EventBase { messageId; broadcaster; sender; content; emotes; constructor(kient, data) { super(kient, data); const eventBody = destr(data.body); this.messageId = eventBody.message_id; this.broadcaster = new ChatUser(kient, ChatUser.constructChatUser(eventBody.broadcaster)); this.sender = new ChatUser(kient, ChatUser.constructChatUser(eventBody.sender)); this.content = eventBody.content; this.emotes = eventBody.emotes; } toJSON() { return flatten(this); } } // src/structures/channel-follow.ts import destr2 from "destr"; class ChannelFollow extends EventBase { broadcaster; follower; constructor(kient, data) { super(kient, data); const eventBody = destr2(data.body); this.broadcaster = new ChatUser(kient, ChatUser.constructChatUser(eventBody.broadcaster)); this.follower = new ChatUser(kient, ChatUser.constructChatUser(eventBody.follower)); } toJSON() { return flatten(this); } } // src/structures/channel-subscription.ts import destr3 from "destr"; class ChannelSubscription extends EventBase { broadcaster; subscriber; duration; createdAt; constructor(kient, data) { super(kient, data); const eventBody = destr3(data.body); this.broadcaster = new ChatUser(kient, ChatUser.constructChatUser(eventBody.broadcaster)); this.subscriber = new ChatUser(kient, ChatUser.constructChatUser(eventBody.subscriber)); this.duration = eventBody.duration; this.createdAt = new Date(eventBody.created_at); } toJSON() { return flatten(this); } } // src/structures/channel-subscription-gift.ts import destr4 from "destr"; class ChannelSubscriptionGift extends EventBase { broadcaster; gifter; giftees; createdAt; constructor(kient, data) { super(kient, data); const eventBody = destr4(data.body); this.broadcaster = new ChatUser(kient, ChatUser.constructChatUser(eventBody.broadcaster)); if (!eventBody.gifter.is_anonymous) { this.gifter = new ChatUser(kient, ChatUser.constructChatUser(eventBody.gifter)); } const gifteeInstances = []; for (const giftee of eventBody.giftees) { const gifteeInstance = new ChatUser(kient, ChatUser.constructChatUser(giftee)); gifteeInstances.push(gifteeInstance); } this.giftees = gifteeInstances; this.createdAt = new Date(eventBody.created_at); } toJSON() { return flatten(this); } } // src/webhook.handler.ts class WebhookHandler { kient; constructor(kient) { this.kient = kient; } handleEvent(event) { switch (event.type) { case "chat.message.sent": { const chatMessage = new ChatMessage(this.kient, event); this.kient.emit("KIENT_CHAT_MESSAGE_SENT", chatMessage); break; } case "channel.followed": { const channelFollow = new ChannelFollow(this.kient, event); this.kient.emit("KIENT_CHANNEL_FOLLOW", channelFollow); break; } case "channel.subscription.new": { const channelSubscription = new ChannelSubscription(this.kient, event); this.kient.emit("KIENT_CHANNEL_SUBSCRIPTION", channelSubscription); break; } case "channel.subscription.renewal": { const channelSubscription = new ChannelSubscription(this.kient, event); this.kient.emit("KIENT_CHANNEL_RESUBSCRIPTION", channelSubscription); break; } case "channel.subscription.gifts": { const channelSubscriptionGift = new ChannelSubscriptionGift(this.kient, event); this.kient.emit("KIENT_CHANNEL_GIFT_SUBSCRIPTIONS", channelSubscriptionGift); break; } default: break; } } } // src/structures/event-subscription.ts class EventSubscription extends Base { id; event; version; constructor(kient, data) { super(kient); this.id = data.id; this.event = data.event; this.version = data.version; } unsubscribe() { this._kient.api.event.unsubscribe([this.id]); } toJSON() { return flatten(this); } } // src/structures/detailed-event-subscription.ts class DetailedEventSubscription extends EventSubscription { appId; userId; method; createdAt; updatedAt; constructor(kient, data) { super(kient, data); this.appId = data.appId; this.userId = data.userId; this.method = data.method; this.createdAt = new Date(data.createdAt); this.updatedAt = new Date(data.updatedAt); } unsubscribe() { this._kient.api.event.unsubscribe([this.id]); } toJSON() { return flatten(this); } } // src/api/events/get-events.ts async function getEventSubscriptions(kient) { const response = await kient._apiClient.fetch("/events/subscriptions"); const subscriptionInstances = []; for (const subscription of response.data) { const subscriptionEvent = new DetailedEventSubscription(kient, { id: subscription.id, event: subscription.event, version: subscription.version, appId: subscription.app_id, userId: subscription.broadcaster_user_id, method: subscription.method, createdAt: subscription.created_at, updatedAt: subscription.updated_at }); subscriptionInstances.push(subscriptionEvent); } return subscriptionInstances; } // src/api/events/subscribe.ts async function subscribeToEvent(kient, param) { const response = await kient._apiClient.fetch("/events/subscriptions", { method: "POST", body: param }); const basicSubscriptionInstances = []; for (const subscription of response.data) { if (subscription.error) { throw new Error(subscription.error); } const basicSubscription = new EventSubscription(kient, { id: subscription.subscription_id, event: subscription.name, version: subscription.version }); basicSubscriptionInstances.push(basicSubscription); } return basicSubscriptionInstances; } // src/api/events/unsubscribe.ts async function unsubscribeFromEvents(kient, ids) { const params = new URLSearchParams; for (const id of ids) { params.append("id", id.toString()); } await kient._apiClient.fetch(`/events/subscriptions?${params}`, { method: "DELETE" }); } // src/api/events/index.ts class EventAPI extends APIBase { getSubscriptions() { return getEventSubscriptions(this.kient); } subscribe(params) { return subscribeToEvent(this.kient, params); } unsubscribe(ids) { return unsubscribeFromEvents(this.kient, ids); } } // src/kient.ts var defaultKientOptions = { apiClient: { ofetch: { baseURL: "https://api.kick.com/public/v1" } }, webhookServer: { enable: true } }; class Kient extends EventEmitter { kientOptions; _webhookServer; _webhookHandler; _apiClient; _kickPublicKey; constructor(options) { super(); this.kientOptions = defu(options, defaultKientOptions); if (this.kientOptions.webhookServer.enable) { this.createWebhookServer(); } this._apiClient = new APIClient(this, this.kientOptions.apiClient); this._webhookHandler = new WebhookHandler(this); } createWebhookServer() { this._webhookServer = new WebhookServer(this); } async setAuthToken(token) { this._apiClient.setHeaders({ Authorization: `Bearer ${token}` }); this._kickPublicKey = await this.api.misc.getPublicKey(); } get webhookServerFetch() { return this._webhookServer?.fetch; } handleWebhookEvent(event) { this._webhookHandler.handleEvent(event); } api = { misc: new MiscAPI(this), category: new CategoryAPI(this), user: new UserAPI(this), channel: new ChannelAPI(this), chat: new ChatAPI(this), event: new EventAPI(this) }; } // src/authentication/user-token.ts import { nanoid } from "nanoid"; import { randomBytes, createHash } from "node:crypto"; import { ofetch as ofetch2 } from "ofetch"; // src/structures/token.ts class Token { accessToken; tokenType; refreshToken; expiresIn; scope; constructor(data) { this.accessToken = data.accessToken; this.tokenType = data.tokenType; this.refreshToken = data.refreshToken; this.expiresIn = data.expiresIn; this.scope = data.scope; } get scopes() { return this.scope ? this.scope.split(" ") : []; } get isAppToken() { return !this.scope || !this.refreshToken; } toJSON() { return flatten(this); } } // src/authentication/user-token.ts var KICK_AUTH_ENDPOINT = "https://id.kick.com/oauth/authorize"; var KICK_TOKEN_ENDPOINT = "https://id.kick.com/oauth/token"; class KientUserTokenAuthentication { params; constructor(params) { this.params = params; } constructAuthoriseUrl(params) { const state = params.state ?? nanoid(); let verifier = params.codeVerifier; if (!verifier) { verifier = base64URLEncode(randomBytes(32)); } const challenge = base64URLEncode(sha256(verifier)); const authParams = new URLSearchParams({ client_id: this.params.clientId, redirect_uri: this.params.redirectUri, response_type: "code", scope: params.scopes.join(" "), code_challenge: challenge, code_challenge_method: "S256", state }); const url = `${KICK_AUTH_ENDPOINT}?${authParams.toString()}`; return { url, state, verifier }; } async generateToken(params) { const tokenParams = new URLSearchParams({ code: params.code, client_id: this.params.clientId, client_secret: this.params.clientSecret, redirect_uri: this.params.redirectUri, grant_type: "authorization_code", code_verifier: params.codeVerifier }); const req = await ofetch2(KICK_TOKEN_ENDPOINT, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: tokenParams.toString() }); return new Token({ accessToken: req.access_token, tokenType: req.token_type, refreshToken: req.refresh_token, expiresIn: req.expires_in, scope: req.scope }); } async refeshToken(params) { const refreshParams = new URLSearchParams({ refresh_token: params.refreshToken, client_id: this.params.clientId, client_secret: this.params.clientSecret, grant_type: "refresh_token" }); const req = await ofetch2(KICK_TOKEN_ENDPOINT, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: refreshParams.toString() }); return new Token({ accessToken: req.access_token, tokenType: req.token_type, refreshToken: req.refresh_token, expiresIn: req.expires_in, scope: req.scope }); } } function base64URLEncode(buffer) { return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); } function sha256(buffer) { return createHash("sha256").update(buffer).digest(); } // src/authentication/app-token.ts import { ofetch as ofetch3 } from "ofetch"; var KICK_TOKEN_ENDPOINT2 = "https://id.kick.com/oauth/token"; class KientAppTokenAuthentication { params; constructor(params) { this.params = params; } async generateToken() { const tokenParams = new URLSearchParams({ client_id: this.params.clientId, client_secret: this.params.clientSecret, grant_type: "client_credentials" }); const req = await ofetch3(KICK_TOKEN_ENDPOINT2, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: tokenParams.toString() }); console.log(req); return new Token({ accessToken: req.access_token, tokenType: req.token_type, expiresIn: req.expires_in }); } } // src/authentication/scopes.ts var KientScopes = { UserRead: "user:read", ChannelRead: "channel:read", ChannelWrite: "channel:write", ChatWrite: "chat:write", StreamkeyRead: "streamkey:read", EventsSubscribe: "events:subscribe" }; export { verifyWebhookSignature, flatten as kientToJSON, KientUserTokenAuthentication, KientScopes, KientAppTokenAuthentication, Kient };