first commit, basic command handling and auth managing

This commit is contained in:
2025-05-30 22:56:46 +02:00
commit cc796765ed
17 changed files with 610 additions and 0 deletions

84
bot/auth.ts Normal file
View File

@@ -0,0 +1,84 @@
import { RefreshingAuthProvider, exchangeCode, type AccessToken } from "@twurple/auth";
import { createAuthRecord, deleteAuthRecord, getAuthRecord, updateAuthRecord } from "./db/dbAuth";
async function initAuth(userId: string, clientId: string, clientSecret: string, requestedIntents: string[], streamer: boolean): Promise<AccessToken> {
const port = process.env.REDIRECT_PORT ?? 3456
const redirectURL = process.env.REDIRECT_URL ?? `http://localhost:${port}`;
// Set the default url and port to http://localhost:3456
const state = Bun.randomUUIDv7().replace(/-/g, "").slice(0, 32);
// Generate random state variable to prevent cross-site-scripting attacks
const instruction = `Visit this URL as ${streamer ? 'the streamer' : 'the chatter'} to authenticate the bot.`
console.info(instruction);
console.info(`https://id.twitch.tv/oauth2/authorize?client_id=${clientId}&redirect_uri=${redirectURL}&response_type=code&scope=${requestedIntents.join('+')}&state=${state}`);
const createCodePromise = () => {
let resolver: (code: string) => void;
const promise = new Promise<string>((resolve) => { resolver = resolve; });
return { promise, resolver: resolver! };
}
const { promise: codepromise, resolver } = createCodePromise();
const server = Bun.serve({
port,
fetch(request) {
const { searchParams } = new URL(request.url);
if (searchParams.has('code') && searchParams.has('state') && searchParams.get('state') === state) {
// Check if the required fields exist on the params and validate the state
resolver(searchParams.get('code')!);
return new Response("Successfully authenticated!");
} else {
return new Response(`Authentication attempt unsuccessful, please make sure the redirect url in the twitch developer console is set to ${redirectURL} and that the bot is listening to that url & port.`, { status: 400 });
}
}
});
await deleteAuthRecord(userId);
const code = await codepromise;
await server.stop(false);
console.info(`Authentication code received.`);
const tokenData = await exchangeCode(clientId, clientSecret, code, redirectURL);
console.info(`Successfully authenticated code.`);
await createAuthRecord(tokenData, userId);
console.info(`Successfully saved auth data in the database.`)
return tokenData;
};
export async function createAuthProvider(user: string, intents: string[], streamer = false): Promise<RefreshingAuthProvider> {
const clientId = process.env.CLIENT_ID;
if (!clientId) { console.error("Please provide a CLIENT_ID in .env."); process.exit(1); };
const clientSecret = process.env.CLIENT_SECRET;
if (!clientSecret) { console.error("Please provide a CLIENT_SECRET in .env."); process.exit(1); };
const authRecord = await getAuthRecord(user, intents);
const token = authRecord ? authRecord.accesstoken : await initAuth(user, clientId, clientSecret, intents, streamer);
const authData = new RefreshingAuthProvider({
clientId,
clientSecret
});
await authData.addUserForToken(token, intents);
authData.onRefresh(async (user, token) => {
console.info(`Successfully refreshed auth for user ${user}`);
await updateAuthRecord(user, token.scope, token);
});
authData.onRefreshFailure((user, err) => {
console.error(`ERROR: Failed to refresh auth for user ${user}: ${err.name} ${err.message}`);
});
try {
await authData.refreshAccessTokenForUser(user);
} catch (err) {
console.error(`Failed to refresh user ${user}. Please restart the bot and re-authenticate it. Make sure the user that auths the bot and the user that's defined in .env are the same.`);
await deleteAuthRecord(user);
};
return authData;
};

42
bot/commands/index.ts Normal file
View File

@@ -0,0 +1,42 @@
import { EventSubChannelChatMessageEvent } from "@twurple/eventsub-base";
import { type HelixSendChatMessageParams } from "@twurple/api";
import { User } from "../user";
/** The Command class represents a command */
export class Command {
public readonly name: string;
public readonly aliases: string[];
public readonly requiredIntents: string[];
public readonly execute: (message: EventSubChannelChatMessageEvent, sender: User) => Promise<void>;
constructor(name: string, aliases: string[], requiredIntents: string[], execution: (message: EventSubChannelChatMessageEvent, sender: User) => Promise<void>) {
this.name = name;
this.aliases = aliases;
this.requiredIntents = requiredIntents;
this.execute = execution;
};
};
import { readdir } from 'node:fs/promises';
const commands = new Map<string, Command>;
const intents: string[] = [];
const files = await readdir(import.meta.dir);
for (const file of files) {
if (!file.endsWith('.ts')) continue;
if (file === import.meta.file) continue;
const command: Command = await import(import.meta.dir + '/' + file.slice(0, -3)).then(a => a.default);
intents.push(...command.requiredIntents);
for (const alias of command.aliases) {
commands.set(alias, command); // Since it's not a primitive type the map is filled with references to the command, not the actual object
};
};
export default commands;
export { intents };
import { singleUserMode, chatterApi, chatterId, streamerId } from "..";
/** Helper function to send a message to the stream */
export const sendMessage = async (message: string, options?: HelixSendChatMessageParams) => {
singleUserMode ? await chatterApi.chat.sendChatMessage(streamerId, message, options) : chatterApi.asUser(chatterId, async newapi => newapi.chat.sendChatMessage(streamerId, message, options));
};

10
bot/commands/ping.ts Normal file
View File

@@ -0,0 +1,10 @@
import { Command, sendMessage } from ".";
// This command is purely for testing
export default new Command('ping',
['ping'],
[],
async msg => {
await sendMessage('pong!', { replyParentMessageId: msg.messageId });
}
);

17
bot/commands/yabai.ts Normal file
View File

@@ -0,0 +1,17 @@
import { Command, sendMessage } from ".";
import { type HelixSendChatMessageParams } from "@twurple/api";
// Remake of the !yabai command in ttv/kiara_tv
export default new Command('yabai',
['yabai', 'goon'],
[],
async msg => {
const replyobj: HelixSendChatMessageParams = { replyParentMessageId: msg.messageId }
const rand = Math.floor(Math.random() * 100) + 1;
if (rand < 25) await sendMessage(`${rand}% yabai! chumpi4Bewwy`, replyobj);
else if (rand < 50) await sendMessage(`${rand}% yabai chumpi4Hustle`, replyobj);
else if (rand === 50) await sendMessage(`${rand}% yabai kiawaBlank`, replyobj);
else if (rand < 80) await sendMessage(`${rand}% yabai chumpi4Shock`, replyobj);
else await sendMessage(`${rand}% yabai chumpi4Jail`, replyobj);
}
);

31
bot/db/connection.ts Normal file
View File

@@ -0,0 +1,31 @@
import Surreal from "surrealdb";
const surrealurl = process.env.SURREAL_URL ?? "";
if (surrealurl === "") { console.error("Please provide a SURREAL_URL in .env."); process.exit(1); };
const namespace = process.env.SURREAL_NAMESPACE ?? "";
if (namespace === "") { console.error("Please provide a SURREAL_NAMESPACE in .env."); process.exit(1); };
const database = process.env.SURREAL_DATABASE ?? "";
if (database === "") { console.error("Please provide a SURREAL_DATABASE in .env."); process.exit(1); };
const username = process.env.SURREAL_USERNAME ?? "";
if (username === "") { console.error("Please provide a SURREAL_USERNAME in .env."); process.exit(1); };
const password = process.env.SURREAL_PASSWORD ?? "";
if (password === "") { console.error("Please provide a SURREAL_PASSWORD in .env."); process.exit(1); };
export default async function DB(): Promise<Surreal> {
const db = new Surreal();
try {
await db.connect(surrealurl, {
auth: {
username,
password
}
});
await db.use({ namespace, database });
return db;
}
catch (err) {
console.error("Failed to connect to SurrealDB:", err instanceof Error ? err.message : String(err));
await db.close();
throw err;
};
};

78
bot/db/dbAuth.ts Normal file
View File

@@ -0,0 +1,78 @@
import type { AccessToken } from "@twurple/auth";
import DB from "./connection";
import type { RecordId } from "surrealdb";
type authRecord = {
accesstoken: AccessToken,
user: string,
};
export async function createAuthRecord(token: AccessToken, userId: string): Promise<void> {
const db = await DB();
if (!db) return;
const data: authRecord = {
accesstoken: token,
user: userId
};
try {
await db.create("auth", data);
} catch (err) {
console.error(err);
} finally {
await db.close();
};
};
type getAuthRecordQuery = authRecord & { id: RecordId };
type getAuthRecordResult = { accesstoken: AccessToken, id: RecordId };
export async function getAuthRecord(userId: string, requiredIntents: string[]): Promise<getAuthRecordResult | undefined> {
const db = await DB();
if (!db) return undefined;
try {
const data = await db.query<getAuthRecordQuery[][]>("SELECT * from auth WHERE user=$userId AND accesstoken.scope CONTAINSALL $intents;", { userId, intents: requiredIntents });
if (!data[0] || !data[0][0]) return undefined;
return { accesstoken: data[0][0].accesstoken, id: data[0][0].id };
} catch (err) {
console.error(err);
return undefined;
} finally {
await db.close();
};
};
export async function updateAuthRecord(userId: string, intents: string[], newtoken: AccessToken): Promise<boolean> {
const db = await DB();
if (!db) return false;
try {
const data = await getAuthRecord(userId, intents);
const newrecord: authRecord = {
accesstoken: newtoken,
user: userId
};
await db.update(data?.id!, newrecord);
return true;
} catch (err) {
console.error(err);
return false;
} finally {
await db.close();
};
};
export async function deleteAuthRecord(userId: string): Promise<void> {
const db = await DB();
if (!db) return;
try {
const data = await db.query<getAuthRecordQuery[][]>("SELECT * FROM auth WHERE user=$userId;", { userId });
if (!data[0] || !data[0][0]) return undefined;
console.log(data)
for (const obj of data[0]) {
db.delete(obj.id);
};
} catch (err) {
console.error(err);
};
};

27
bot/events/index.ts Normal file
View File

@@ -0,0 +1,27 @@
import { eventSub } from "..";
eventSub.onSubscriptionCreateSuccess(event => {
console.info(`Successfully created EventSub subscription: ${event.id}`);
});
eventSub.onSubscriptionCreateFailure(event => {
eventSub.stop()
console.error(`Failed to create EventSub subscription: ${event.id}`);
eventSub.start()
});
eventSub.onSubscriptionDeleteSuccess(event => {
console.info(`Successfully deleted EventSub subscription: ${event.id}`);
});
eventSub.onSubscriptionDeleteFailure(event => {
console.error(`Failed to delete EventSub subscription: ${event.id}`);
});
import { readdir } from 'node:fs/promises';
const files = await readdir(import.meta.dir);
for (const file of files) {
if (!file.endsWith('.ts')) continue;
if (file === import.meta.file) continue;
await import(import.meta.dir + '/' + file.slice(0, -3));
};

27
bot/events/message.ts Normal file
View File

@@ -0,0 +1,27 @@
import { redis } from "bun";
import { chatterId, streamerId, eventSub, commandPrefix } from "..";
import { User } from "../user";
import commands, { sendMessage } from "../commands";
console.info(`Loaded the ${commands.keys().toArray().join(', ')} commands`);
eventSub.onChannelChatMessage(streamerId, streamerId, async msg => {
// Get user from cache or place user in cache
const user = await User.init(msg.chatterName);
// Manage vulnerable chatters
if (![chatterId, streamerId].includes(msg.chatterId)) {// Don't add the chatter or streamer to the vulnerable chatters
if (!await redis.sismember("vulnchatters", msg.chatterId)) {
await redis.sadd('vulnchatters', msg.chatterId);
console.debug(`${msg.chatterDisplayName} is now vulnerable to explosives.`);
};
};
// Parse commands:
if (msg.messageText.startsWith(commandPrefix)) {
const commandSelection = msg.messageText.slice(commandPrefix.length).split(' ')[0]!;
const selected = commands.get(commandSelection.toLowerCase());
if (!selected) { await sendMessage(`${commandSelection} command does not exist`, { replyParentMessageId: msg.messageId }); return; };
await selected.execute(msg, user!);
};
});

34
bot/index.ts Normal file
View File

@@ -0,0 +1,34 @@
import { createAuthProvider } from "./auth";
import { ApiClient } from "@twurple/api";
import { EventSubWsListener } from "@twurple/eventsub-ws";
import { intents } from "./commands";
const CHATTERBASEINTENTS = ["user:read:chat", "user:write:chat", "user:bot"];
const STREAMERBASEINTENTS = ["user:read:chat"];
export const singleUserMode = process.env.CHATTER_IS_STREAMER === 'true';
export const chatterId = process.env.CHATTER_ID ?? "";
if (chatterId === "") { console.error('Please set a CHATTER_ID in the .env'); process.exit(1); };
export const streamerId = process.env.STREAMER_ID ?? "";
if (streamerId === "") { console.log('Please set a STREAMER_ID in the .env'); process.exit(1); };
const chatterIntents = singleUserMode ? CHATTERBASEINTENTS.concat(STREAMERBASEINTENTS) : CHATTERBASEINTENTS;
const streamerIntents = STREAMERBASEINTENTS.concat(intents);
export const chatterAuthProvider = await createAuthProvider(chatterId, chatterIntents);
export const streamerAuthProvider = singleUserMode ? undefined : await createAuthProvider(streamerId, streamerIntents, true);
/** chatterApi should be used for sending messages, retrieving user data, etc */
export const chatterApi = new ApiClient({ authProvider: chatterAuthProvider });
/** streamerApi should be used for: adding/removing mods, managing timeouts, etc. */
export const streamerApi = streamerAuthProvider ? new ApiClient({ authProvider: streamerAuthProvider }) : chatterApi; // if there is no streamer user, use the chatter user
/** As the streamerApi has either the streamer or the chatter if the chatter IS the streamer this has streamer permissions */
export const eventSub = new EventSubWsListener({ apiClient: streamerApi });
export const commandPrefix = process.env.COMMAND_PREFIX ?? "!";
await import("./events");
eventSub.start();

45
bot/user.ts Normal file
View File

@@ -0,0 +1,45 @@
import { redis } from "bun";
import { chatterApi } from ".";
export class User {
constructor(username: string) {
this.username = username;
};
public username: string;
public id: string | undefined | null; // The null here and below serves the function to make typescript shut the fuck up
public displayName: string | undefined | null;
static async init(username: string): Promise<User | null> {
const userObj = new User(username);
const cachedata = await redis.exists(`user:${username}`);
if (!cachedata) {
const twitchdata = await chatterApi.users.getUserByName(username!);
if (!twitchdata) return null; // User does not exist in redis and twitch api can't get them: return null
await redis.set(`user:${username}:id`, twitchdata.id);
await redis.set(`user:${username}:displayName`, twitchdata.displayName);
await redis.set(`user:${username}:itemlock`, '0');
};
await userObj._setOptions();
return userObj;
};
private async _setOptions(): Promise<void> {
this.id = await redis.get(`user:${this.username}:id`);
this.displayName = await redis.get(`user:${this.username}:displayName`);
};
public async itemLock(): Promise<boolean> {
const lock = await redis.get(`user:${this.username}:itemlock`);
if (lock === '0') return false;
return true;
};
public async setLock(): Promise<void> {
await redis.set(`user:${this.username}:itemlock`, '1');
};
public async clearLock(): Promise<void> {
await redis.set(`user:${this.username}:itemlock`, '0');
};
};