commit cc796765eda7dfdd7ee8cd172d4b34d6414e4593 Author: qwerinope Date: Fri May 30 22:56:46 2025 +0200 first commit, basic command handling and auth managing diff --git a/.example.env b/.example.env new file mode 100644 index 0000000..88da0ee --- /dev/null +++ b/.example.env @@ -0,0 +1,24 @@ +# Settings that have been commented out are optional + +# Application config +CLIENT_ID= # Client_id gotten from the twitch dev console +CLIENT_SECRET= # Client_secret gotten from the twitch dev console +# REDIRECT_URL= # Redirect URL that has been set in the twitch dev console. Defaults to: http://localhost:3456 +# REDIRECT_PORT= # Redirect port if the REDIRECT_URL has not been set. Defaults to 3456. This is also the port the bot will listen on to authenticate +# COMMAND_PREFIX= # The prefix which will be used to activate commands. Defaults to '!'. When requiring a space between prefix and command, escape the space with a backslash + +# The Twitch IDs required below can be gotten from this website: https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/ + +# Streamer config +STREAMER_ID= # Twitch ID of the streaming user + +# Chatter config +CHATTER_ID= # Twitch ID of the chatting user +CHATTER_IS_STREAMER= # If the bot that activates on commands is on the same account as the streamer, set this to true. Make sure the STREAMER_ID and CHATTER_ID match in that case. + +# SurrealDB config +SURREAL_URL= # SurrealDB URL, can either be remotely hosted or selfhosted +SURREAL_NAMESPACE= # SurrealDB Namespace. You need to create this manually +SURREAL_DATABASE= # SurrealDB Database. You need to create this manually +SURREAL_USERNAME= # SurrealDB username for authenticating +SURREAL_PASSWORD= # SurrealDB password for authenticating. Remember to escape characters like $ with a backslash diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..2cc89c0 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# qweribot + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.2.13. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/bot/auth.ts b/bot/auth.ts new file mode 100644 index 0000000..2dcc37c --- /dev/null +++ b/bot/auth.ts @@ -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 { + 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((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 { + 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; +}; diff --git a/bot/commands/index.ts b/bot/commands/index.ts new file mode 100644 index 0000000..6d64a66 --- /dev/null +++ b/bot/commands/index.ts @@ -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; + constructor(name: string, aliases: string[], requiredIntents: string[], execution: (message: EventSubChannelChatMessageEvent, sender: User) => Promise) { + this.name = name; + this.aliases = aliases; + this.requiredIntents = requiredIntents; + this.execute = execution; + }; +}; + +import { readdir } from 'node:fs/promises'; +const commands = new Map; +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)); +}; diff --git a/bot/commands/ping.ts b/bot/commands/ping.ts new file mode 100644 index 0000000..d76d1c0 --- /dev/null +++ b/bot/commands/ping.ts @@ -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 }); + } +); diff --git a/bot/commands/yabai.ts b/bot/commands/yabai.ts new file mode 100644 index 0000000..892d80e --- /dev/null +++ b/bot/commands/yabai.ts @@ -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); + } +); diff --git a/bot/db/connection.ts b/bot/db/connection.ts new file mode 100644 index 0000000..48b8e7f --- /dev/null +++ b/bot/db/connection.ts @@ -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 { + 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; + }; +}; diff --git a/bot/db/dbAuth.ts b/bot/db/dbAuth.ts new file mode 100644 index 0000000..5f0110d --- /dev/null +++ b/bot/db/dbAuth.ts @@ -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 { + 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 { + const db = await DB(); + if (!db) return undefined; + try { + const data = await db.query("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 { + 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 { + const db = await DB(); + if (!db) return; + try { + const data = await db.query("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); + }; +}; diff --git a/bot/events/index.ts b/bot/events/index.ts new file mode 100644 index 0000000..0979635 --- /dev/null +++ b/bot/events/index.ts @@ -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)); +}; diff --git a/bot/events/message.ts b/bot/events/message.ts new file mode 100644 index 0000000..a0be2f5 --- /dev/null +++ b/bot/events/message.ts @@ -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!); + }; +}); diff --git a/bot/index.ts b/bot/index.ts new file mode 100644 index 0000000..073a18a --- /dev/null +++ b/bot/index.ts @@ -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(); diff --git a/bot/user.ts b/bot/user.ts new file mode 100644 index 0000000..72cb237 --- /dev/null +++ b/bot/user.ts @@ -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 { + 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 { + this.id = await redis.get(`user:${this.username}:id`); + this.displayName = await redis.get(`user:${this.username}:displayName`); + }; + + public async itemLock(): Promise { + const lock = await redis.get(`user:${this.username}:itemlock`); + if (lock === '0') return false; + return true; + }; + + public async setLock(): Promise { + await redis.set(`user:${this.username}:itemlock`, '1'); + }; + + public async clearLock(): Promise { + await redis.set(`user:${this.username}:itemlock`, '0'); + }; +}; diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..41fb007 --- /dev/null +++ b/bun.lock @@ -0,0 +1,89 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "qweribot", + "dependencies": { + "@twurple/auth": "^7.3.0", + "@twurple/eventsub-ws": "^7.3.0", + "pocketbase": "^0.26.0", + "surrealdb": "^1.3.2", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@d-fischer/cache-decorators": ["@d-fischer/cache-decorators@4.0.1", "", { "dependencies": { "@d-fischer/shared-utils": "^3.6.3", "tslib": "^2.6.2" } }, "sha512-HNYLBLWs/t28GFZZeqdIBqq8f37mqDIFO6xNPof94VjpKvuP6ROqCZGafx88dk5zZUlBfViV9jD8iNNlXfc4CA=="], + + "@d-fischer/connection": ["@d-fischer/connection@9.0.0", "", { "dependencies": { "@d-fischer/isomorphic-ws": "^7.0.0", "@d-fischer/logger": "^4.2.1", "@d-fischer/shared-utils": "^3.5.0", "@d-fischer/typed-event-emitter": "^3.3.0", "@types/ws": "^8.5.4", "tslib": "^2.4.1", "ws": "^8.11.0" } }, "sha512-Mljp/EbaE+eYWfsFXUOk+RfpbHgrWGL/60JkAvjYixw6KREfi5r17XdUiXe54ByAQox6jwgdN2vebdmW1BT+nQ=="], + + "@d-fischer/cross-fetch": ["@d-fischer/cross-fetch@5.0.5", "", { "dependencies": { "node-fetch": "^2.6.12" } }, "sha512-symjDUPInTrkfIsZc2n2mo9hiAJLcTJsZkNICjZajEWnWpJ3s3zn50/FY8xpNUAf5w3eFuQii2wxztTGpvG1Xg=="], + + "@d-fischer/detect-node": ["@d-fischer/detect-node@3.0.1", "", {}, "sha512-0Rf3XwTzuTh8+oPZW9SfxTIiL+26RRJ0BRPwj5oVjZFyFKmsj9RGfN2zuTRjOuA3FCK/jYm06HOhwNK+8Pfv8w=="], + + "@d-fischer/isomorphic-ws": ["@d-fischer/isomorphic-ws@7.0.2", "", { "peerDependencies": { "ws": "^8.2.0" } }, "sha512-xK+qIJUF0ne3dsjq5Y3BviQ4M+gx9dzkN+dPP7abBMje4YRfow+X9jBgeEoTe5e+Q6+8hI9R0b37Okkk8Vf0hQ=="], + + "@d-fischer/logger": ["@d-fischer/logger@4.2.3", "", { "dependencies": { "@d-fischer/detect-node": "^3.0.1", "@d-fischer/shared-utils": "^3.2.0", "tslib": "^2.0.3" } }, "sha512-mJUx9OgjrNVLQa4od/+bqnmD164VTCKnK5B4WOW8TX5y/3w2i58p+PMRE45gUuFjk2BVtOZUg55JQM3d619fdw=="], + + "@d-fischer/qs": ["@d-fischer/qs@7.0.2", "", {}, "sha512-yAu3xDooiL+ef84Jo8nLjDjWBRk7RXk163Y6aTvRB7FauYd3spQD/dWvgT7R4CrN54Juhrrc3dMY7mc+jZGurQ=="], + + "@d-fischer/rate-limiter": ["@d-fischer/rate-limiter@1.1.0", "", { "dependencies": { "@d-fischer/logger": "^4.2.3", "@d-fischer/shared-utils": "^3.6.3", "tslib": "^2.6.2" } }, "sha512-O5HgACwApyCZhp4JTEBEtbv/W3eAwEkrARFvgWnEsDmXgCMWjIHwohWoHre5BW6IYXFSHBGsuZB/EvNL3942kQ=="], + + "@d-fischer/shared-utils": ["@d-fischer/shared-utils@3.6.4", "", { "dependencies": { "tslib": "^2.4.1" } }, "sha512-BPkVLHfn2Lbyo/ENDBwtEB8JVQ+9OzkjJhUunLaxkw4k59YFlQxUUwlDBejVSFcpQT0t+D3CQlX+ySZnQj0wxw=="], + + "@d-fischer/typed-event-emitter": ["@d-fischer/typed-event-emitter@3.3.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-OvSEOa8icfdWDqcRtjSEZtgJTFOFNgTjje7zaL0+nAtu2/kZtRCSK5wUMrI/aXtCH8o0Qz2vA8UqkhWUTARFQQ=="], + + "@twurple/api": ["@twurple/api@7.3.0", "", { "dependencies": { "@d-fischer/cache-decorators": "^4.0.0", "@d-fischer/cross-fetch": "^5.0.1", "@d-fischer/detect-node": "^3.0.1", "@d-fischer/logger": "^4.2.1", "@d-fischer/rate-limiter": "^1.1.0", "@d-fischer/shared-utils": "^3.6.1", "@d-fischer/typed-event-emitter": "^3.3.1", "@twurple/api-call": "7.3.0", "@twurple/common": "7.3.0", "retry": "^0.13.1", "tslib": "^2.0.3" }, "peerDependencies": { "@twurple/auth": "7.3.0" } }, "sha512-QtaVgYi50E3AB/Nxivjou/u6w1cuQ6g4R8lzQawYDaQNtlP2Ue8vvYuSp2PfxSpe8vNiKhgV8hZAs+j4V29sxQ=="], + + "@twurple/api-call": ["@twurple/api-call@7.3.0", "", { "dependencies": { "@d-fischer/cross-fetch": "^5.0.1", "@d-fischer/qs": "^7.0.2", "@d-fischer/shared-utils": "^3.6.1", "@twurple/common": "7.3.0", "tslib": "^2.0.3" } }, "sha512-nx389kXjVphAeR3RfnzkRRf7Qa45wqHla067/mr3YxnUICCg4YOFv0Jb5UohQGHkj5h18mDZ3iUu/x2J49c1lA=="], + + "@twurple/auth": ["@twurple/auth@7.3.0", "", { "dependencies": { "@d-fischer/logger": "^4.2.1", "@d-fischer/shared-utils": "^3.6.1", "@d-fischer/typed-event-emitter": "^3.3.1", "@twurple/api-call": "7.3.0", "@twurple/common": "7.3.0", "tslib": "^2.0.3" } }, "sha512-K68nFbQswfaEVCWP2MEPcxhHRR/N8kIHBP6AnRXzgSpmvWxhjOitz9oyP04di5DI1rJE+2NRauv1qFDyYia/qg=="], + + "@twurple/common": ["@twurple/common@7.3.0", "", { "dependencies": { "@d-fischer/shared-utils": "^3.6.1", "klona": "^2.0.4", "tslib": "^2.0.3" } }, "sha512-BGNniY7PBIohxfpRQ1bsOxUaktZcXZOExq8ojCtnsNBVDlchNEX2fYsere03ZwTLd48XBtxsdaUaeQXbx1aXLw=="], + + "@twurple/eventsub-base": ["@twurple/eventsub-base@7.3.0", "", { "dependencies": { "@d-fischer/logger": "^4.2.1", "@d-fischer/shared-utils": "^3.6.1", "@d-fischer/typed-event-emitter": "^3.3.0", "@twurple/api": "7.3.0", "@twurple/auth": "7.3.0", "@twurple/common": "7.3.0", "tslib": "^2.0.3" } }, "sha512-Wc/3qpyFfyvjabk/tQJVjAke+ixp5QWUT7LsuU+kMcCf46jsRQMB3InoXsZMRgX5sD1frBZzxUEJ7ujhxb8Ngw=="], + + "@twurple/eventsub-ws": ["@twurple/eventsub-ws@7.3.0", "", { "dependencies": { "@d-fischer/connection": "^9.0.0", "@d-fischer/logger": "^4.2.1", "@d-fischer/shared-utils": "^3.6.1", "@d-fischer/typed-event-emitter": "^3.3.0", "@twurple/auth": "7.3.0", "@twurple/common": "7.3.0", "@twurple/eventsub-base": "7.3.0", "tslib": "^2.0.3" }, "peerDependencies": { "@twurple/api": "7.3.0" } }, "sha512-jtIMdW/atTrn5Eo3XGS8Lw0EIsK3GQsZGJDLYRwqw2bCV8ZnQoZ8YaXUJb5Wd+gebUfeBr4j7mvZlGc+Wkp17w=="], + + "@types/bun": ["@types/bun@1.2.13", "", { "dependencies": { "bun-types": "1.2.13" } }, "sha512-u6vXep/i9VBxoJl3GjZsl/BFIsvML8DfVDO0RYLEwtSZSp981kEO1V5NwRcO1CPJ7AmvpbnDCiMKo3JvbDEjAg=="], + + "@types/node": ["@types/node@22.15.18", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-v1DKRfUdyW+jJhZNEI1PYy29S2YRxMV5AOO/x/SjKmW0acCIOqmbj6Haf9eHAhsPmrhlHSxEhv/1WszcLWV4cg=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + + "bun-types": ["bun-types@1.2.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-rRjA1T6n7wto4gxhAO/ErZEtOXyEZEmnIHQfl0Dt1QQSB4QV0iP6BZ9/YB5fZaHFQ2dwHFrmPaRQ9GGMX01k9Q=="], + + "isows": ["isows@1.0.7", "", { "peerDependencies": { "ws": "*" } }, "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg=="], + + "klona": ["klona@2.0.6", "", {}, "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA=="], + + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + + "pocketbase": ["pocketbase@0.26.0", "", {}, "sha512-WBBeOgz4Jnrd7a1KEzSBUJqpTortKKCcp16j5KoF+4tNIyQHsmynj+qRSvS56/RVacVMbAqO8Qkfj3N84fpzEw=="], + + "retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], + + "surrealdb": ["surrealdb@1.3.2", "", { "dependencies": { "isows": "^1.0.6", "uuidv7": "^1.0.1" }, "peerDependencies": { "tslib": "^2.6.3", "typescript": "^5.0.0" } }, "sha512-mL7nij33iuon3IQP72F46fgX3p2LAxFCWCBDbZB7IohZ13RTEwJVNq7nZeP1eMSceQUpKzS6OHIWOuF9LYAkNw=="], + + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "uuidv7": ["uuidv7@1.0.2", "", { "bin": { "uuidv7": "cli.js" } }, "sha512-8JQkH4ooXnm1JCIhqTMbtmdnYEn6oKukBxHn1Ic9878jMkL7daTI7anTExfY18VRCX7tcdn5quzvCb6EWrR8PA=="], + + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + + "ws": ["ws@8.18.2", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ=="], + } +} diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..3495453 --- /dev/null +++ b/compose.yml @@ -0,0 +1,7 @@ +services: + valkey: + image: valkey/valkey:alpine + container_name: valkey + ports: + - 6379:6379 + restart: no diff --git a/package.json b/package.json new file mode 100644 index 0000000..9001fc9 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "qweribot", + "module": "bot/index.ts", + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "private": true, + "type": "module", + "dependencies": { + "@twurple/auth": "^7.3.0", + "@twurple/eventsub-ws": "^7.3.0", + "pocketbase": "^0.26.0", + "surrealdb": "^1.3.2" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9c62f74 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}