mirror of
https://github.com/qwerinope/qweribot.git
synced 2025-12-19 05:51:37 +01:00
first commit, basic command handling and auth managing
This commit is contained in:
84
bot/auth.ts
Normal file
84
bot/auth.ts
Normal 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
42
bot/commands/index.ts
Normal 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
10
bot/commands/ping.ts
Normal 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
17
bot/commands/yabai.ts
Normal 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
31
bot/db/connection.ts
Normal 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
78
bot/db/dbAuth.ts
Normal 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
27
bot/events/index.ts
Normal 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
27
bot/events/message.ts
Normal 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
34
bot/index.ts
Normal 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
45
bot/user.ts
Normal 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');
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user