kient
Version:
844 lines (802 loc) • 22.3 kB
JavaScript
// 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 getChannelsByID(kient, ids = []) {
const params = new URLSearchParams;
for (const id of ids) {
params.append("broadcaster_user_id", id.toString());
}
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;
}
// 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];
}
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/webhook.handler.ts
import crypto from "node:crypto";
// 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;
}
}
static verifySignature(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 = WebhookHandler.verifySignature({
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/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.split(" ");
}
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/scopes.ts
var KientScopes = {
UserRead: "user:read",
ChannelRead: "channel:read",
ChannelWrite: "channel:write",
ChatWrite: "chat:write",
StreamkeyRead: "streamkey:read",
EventsSubscribe: "events:subscribe"
};
export {
flatten as kientToJSON,
KientUserTokenAuthentication,
KientScopes,
Kient
};