disabled items can no longer be used with !use, admingive is now disableable, removed command specific intents, simplified command permission system

This commit is contained in:
2025-06-29 15:26:03 +02:00
parent 773a694714
commit 898e0b7b70
23 changed files with 72 additions and 85 deletions

View File

@@ -33,7 +33,7 @@ COMMAND|FUNCTION|USER|ALIASES|DISABLEABLE
`inventory [target]`|Get inventory contents of target or self|anyone|`inventory` `inv`|:white_check_mark:
`give {target} {item} {amount}`|Give targeted user amount of items|anyone|`give`|:white_check_mark:
`use {item} ...`|Use item. More info at [The items section](#items)|anyone|`use`|:white_check_mark:
`admingive {target} {item} {amount}`|Give targeted user amount of new items|admins|`admingive`|:x:
`admingive {target} {item} {amount}`|Give targeted user amount of new items|admins|`admingive`|:white_check_mark:
### Administrative commands

View File

@@ -6,7 +6,7 @@ async function initAuth(userId: string, clientId: string, clientSecret: string,
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);
const state = Bun.randomUUIDv7().replace(/-/g, "").slice(0, 32).toUpperCase();
// 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.`

View File

@@ -2,10 +2,8 @@ import { Command, sendMessage } from ".";
import { addAdmin } from "../lib/admins";
import parseCommandArgs from "../lib/parseCommandArgs";
import { User } from "../user";
import { unbannableUsers } from "..";
export default new Command('addadmin', ['addadmin'], [], async msg => {
if (!unbannableUsers.includes(msg.chatterId)) return;
export default new Command('addadmin', ['addadmin'], 'unbannable', async msg => {
const args = parseCommandArgs(msg.messageText);
if (!args[0]) { await sendMessage('Please specify a target', msg.messageId); return; };
const target = await User.initUsername(args[0].toLowerCase());

View File

@@ -1,12 +1,10 @@
import { Command, sendMessage } from ".";
import { getUserRecord } from "../db/dbUser";
import items, { changeItemCount } from "../items";
import { isAdmin } from "../lib/admins";
import parseCommandArgs from "../lib/parseCommandArgs";
import { User } from "../user";
export default new Command('admingive', ['admingive'], [], async msg => {
if (!await isAdmin(msg.chatterId)) return;
export default new Command('admingive', ['admingive'], 'admin', async msg => {
const args = parseCommandArgs(msg.messageText);
if (!args[0]) { await sendMessage('Please specify a user', msg.messageId); return; };
const target = await User.initUsername(args[0].toLowerCase());
@@ -28,4 +26,4 @@ export default new Command('admingive', ['admingive'], [], async msg => {
await sendMessage(`Failed to give ${target.displayName} ${amount} ${item.prettyName + (amount === 1 ? '' : item.plural)}`, msg.messageId);
};
await target.clearLock();
}, false);
});

View File

@@ -1,10 +1,8 @@
import { redis } from "bun";
import commands, { Command, sendMessage } from ".";
import { isAdmin } from "../lib/admins";
import parseCommandArgs from "../lib/parseCommandArgs";
export default new Command('disablecommand', ['disablecommand'], [], async msg => {
if (!await isAdmin(msg.chatterId)) return;
export default new Command('disablecommand', ['disablecommand'], 'admin', async msg => {
const args = parseCommandArgs(msg.messageText);
if (!args[0]) { await sendMessage('Please specify a command to disable', msg.messageId); return; };
const selection = commands.get(args[0].toLowerCase());

View File

@@ -1,10 +1,8 @@
import { redis } from "bun";
import commands, { Command, sendMessage } from ".";
import { isAdmin } from "../lib/admins";
import parseCommandArgs from "../lib/parseCommandArgs";
export default new Command('enablecommand', ['enablecommand'], [], async msg => {
if (!await isAdmin(msg.chatterId)) return;
export default new Command('enablecommand', ['enablecommand'], 'admin', async msg => {
const args = parseCommandArgs(msg.messageText);
if (!args[0]) { await sendMessage('Please specify a command to enable', msg.messageId); return; };
const selection = commands.get(args[0].toLowerCase());

View File

@@ -2,7 +2,7 @@ import { Command, sendMessage } from ".";
import { getAdmins } from "../lib/admins";
import { User } from "../user";
export default new Command('getadmins', ['getadmins'], [], async msg => {
export default new Command('getadmins', ['getadmins'], 'chatter', async msg => {
const admins = await getAdmins()
const adminnames: string[] = [];
for (const id of admins) {

View File

@@ -2,14 +2,14 @@ import { redis } from "bun";
import { basecommands, Command, sendMessage } from ".";
import parseCommandArgs from "../lib/parseCommandArgs";
export default new Command('getcommands', ['getcommands', 'getc'], [], async msg => {
export default new Command('getcommands', ['getcommands', 'getc'], 'chatter', async msg => {
const args = parseCommandArgs(msg.messageText);
if (!args[0]) { await sendMessage(`A list of commands can be found here: https://github.com/qwerinope/qweribot#commands`, msg.messageId); return; };
if (!args[0]) { await sendMessage(`A full list of commands can be found here: https://github.com/qwerinope/qweribot#commands`, msg.messageId); return; };
const disabledcommands = await redis.smembers('disabledcommands');
if (args[0].toLowerCase() === 'enabled') {
const commandnames: string[] = [];
for (const [name, command] of Array.from(basecommands.entries())) {
if (!command.disableable) continue;
if (command.usertype !== 'chatter') continue; // Admin only commands should be somewhat hidden
if (disabledcommands.includes(name)) continue;
commandnames.push(name);
};

View File

@@ -5,7 +5,7 @@ import items, { changeItemCount } from "../items";
import parseCommandArgs from "../lib/parseCommandArgs";
import { User } from "../user";
export default new Command('give', ['give'], [], async (msg, user) => {
export default new Command('give', ['give'], 'chatter', async (msg, user) => {
const args = parseCommandArgs(msg.messageText);
if (!args[0]) { await sendMessage('Please specify a user', msg.messageId); return; };
const target = await User.initUsername(args[0].toLowerCase());
@@ -34,6 +34,7 @@ export default new Command('give', ['give'], [], async (msg, user) => {
const newamount = tempdata.inventory[item.name]!;
await sendMessage(`${user.displayName} gave ${amount} ${item.prettyName + (amount === 1 ? '' : item.plural)} to ${target.displayName}. They now have ${newamount} ${item.prettyName + (newamount === 1 ? '' : item.plural)}`, msg.messageId);
} else {
// TODO: Rewrite this section
await sendMessage(`Failed to give ${target.displayName} ${amount} ${item.prettyName + (amount === 1 ? '' : item.plural)}`, msg.messageId);
console.error(`WARNING: Item donation failed: target success: ${data[0] !== false}, donator success: ${data[0] !== false}`);
};

View File

@@ -1,17 +1,19 @@
import { EventSubChannelChatMessageEvent } from "@twurple/eventsub-base";
import { User } from "../user";
export type userType = 'chatter' | 'admin' | 'unbannable';
/** The Command class represents a command */
export class Command {
public readonly name: string;
public readonly aliases: string[];
public readonly requiredIntents: string[];
public readonly usertype: userType;
public readonly disableable: boolean;
public readonly execute: (message: EventSubChannelChatMessageEvent, sender: User) => Promise<void>;
constructor(name: string, aliases: string[], requiredIntents: string[], execution: (message: EventSubChannelChatMessageEvent, sender: User) => Promise<void>, disableable?: boolean) {
constructor(name: string, aliases: string[], usertype: userType, execution: (message: EventSubChannelChatMessageEvent, sender: User) => Promise<void>, disableable?: boolean) {
this.name = name.toLowerCase();
this.aliases = aliases;
this.requiredIntents = requiredIntents;
this.usertype = usertype;
this.execute = execution;
this.disableable = disableable ?? true;
};
@@ -19,7 +21,6 @@ export class Command {
import { readdir } from 'node:fs/promises';
const commands = new Map<string, Command>;
const commandintents: string[] = [];
const basecommands = new Map<string, Command>;
const files = await readdir(import.meta.dir);
@@ -27,7 +28,6 @@ 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);
commandintents.push(...command.requiredIntents);
basecommands.set(command.name, command);
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
@@ -40,7 +40,7 @@ for (const [name, item] of Array.from(items)) {
};
export default commands;
export { commandintents, basecommands };
export { basecommands };
import { singleUserMode, chatterApi, chatterId, streamerId } from "..";

View File

@@ -4,7 +4,7 @@ import parseCommandArgs from "../lib/parseCommandArgs";
import { User } from "../user";
import items from "../items";
export default new Command('inventory', ['inv', 'inventory'], [], async (msg, user) => {
export default new Command('inventory', ['inv', 'inventory'], 'chatter', async (msg, user) => {
const args = parseCommandArgs(msg.messageText);
let target: User = user;
if (args[0]) {

View File

@@ -2,7 +2,7 @@ import { Command, sendMessage } from ".";
import items from "../items";
import parseCommandArgs from "../lib/parseCommandArgs";
export default new Command('iteminfo', ['iteminfo', 'itemhelp', 'info'], [], async msg => {
export default new Command('iteminfo', ['iteminfo', 'itemhelp', 'info'], 'chatter', async msg => {
const messagequery = parseCommandArgs(msg.messageText).join(' ');
if (!messagequery) { await sendMessage('Please specify an item you would like to get info about', msg.messageId); return; };
const selection = items.get(messagequery.toLowerCase());

View File

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

View File

@@ -4,8 +4,7 @@ import { removeAdmin } from "../lib/admins";
import parseCommandArgs from "../lib/parseCommandArgs";
import { User } from "../user";
export default new Command('removeadmin', ['removeadmin'], [], async msg => {
if (!unbannableUsers.includes(msg.chatterId)) return;
export default new Command('removeadmin', ['removeadmin'], 'unbannable', async msg => {
const args = parseCommandArgs(msg.messageText);
if (!args[0]) { await sendMessage('Please specify a target', msg.messageId); return; };
const target = await User.initUsername(args[0].toLowerCase());

View File

@@ -1,15 +1,15 @@
import { Command, sendMessage } from ".";
import { timeout } from "../lib/timeout";
export default new Command('seiso', ['seiso'], ['moderator:manage:banned_users'], async (msg, user) => {
export default new Command('seiso', ['seiso'], 'chatter', async (msg, user) => {
const rand = Math.floor(Math.random() * 101);
if (rand > 75) await sendMessage(`${rand}% seiso YAAAA`, msg.messageId);
else if (rand > 51) await sendMessage(`${rand}% seiso POGGERS`, msg.messageId);
else if (rand === 50) await sendMessage(`${rand}% seiso ok`, msg.messageId);
else if (rand > 30) await sendMessage(`${rand}% seiso SWEAT`, msg.messageId);
else if (rand > 10) await sendMessage(`${rand}% seiso catErm`, msg.messageId);
else {
await sendMessage(`${rand}% seiso RIPBOZO`);
timeout(user, 'TOO YABAI!', 60);
};
else await Promise.all([
sendMessage(`${rand}% seiso RIPBOZO`),
timeout(user, 'TOO YABAI!', 60)
]);
});

View File

@@ -1,10 +1,12 @@
import { redis } from "bun";
import { Command, sendMessage } from ".";
import items from "../items";
export default new Command('use', ['use'], [], async (msg, user) => {
export default new Command('use', ['use'], 'chatter', async (msg, user) => {
const messagequery = msg.messageText.trim().split(' ').slice(1);
if (!messagequery[0]) { await sendMessage('Please specify an item you would like to use', msg.messageId); return; };
const selection = items.get(messagequery[0].toLowerCase());
if (!selection) { await sendMessage(`'${messagequery[0]}' is not an item`, msg.messageId); return; };
if (await redis.sismember('disabledcommands', selection.name)) { await sendMessage(`The ${selection.prettyName} item is disabled`, msg.messageId); return; };
await selection.execute(msg, user);
});

View File

@@ -1,12 +1,8 @@
import { redis } from "bun";
import { Command, sendMessage } from ".";
export default new Command('vulnchatters',
['vulnchatters', 'vulnc'],
[],
async msg => {
const data = await redis.keys('vulnchatters:*');
const one = data.length === 1;
await sendMessage(`There ${one ? 'is' : 'are'} ${data.length} vulnerable chatter${one ? '' : 's'}`, msg.messageId);
}
);
export default new Command('vulnchatters', ['vulnchatters', 'vulnc'], 'chatter', async msg => {
const data = await redis.keys('vulnchatters:*');
const one = data.length === 1;
await sendMessage(`There ${one ? 'is' : 'are'} ${data.length} vulnerable chatter${one ? '' : 's'}`, msg.messageId);
});

View File

@@ -2,18 +2,14 @@ import { Command, sendMessage } from ".";
import { timeout } from "../lib/timeout";
// Remake of the !yabai command in ttv/kiara_tv
export default new Command('yabai',
['yabai', 'goon'],
['moderator:manage:banned_users'],
async (msg, user) => {
const rand = Math.floor(Math.random() * 101);
if (rand < 25) sendMessage(`${rand}% yabai! GIGACHAD`, msg.messageId);
else if (rand < 50) sendMessage(`${rand}% yabai POGGERS`, msg.messageId);
else if (rand === 50) sendMessage(`${rand}% yabai ok`, msg.messageId);
else if (rand < 90) sendMessage(`${rand}% yabai AINTNOWAY`, msg.messageId);
else {
sendMessage(`${msg.chatterDisplayName} is ${rand}% yabai CAUGHT`);
timeout(user, "TOO YABAI!", 60);
};
}
);
export default new Command('yabai', ['yabai', 'goon'], 'chatter', async (msg, user) => {
const rand = Math.floor(Math.random() * 101);
if (rand < 25) sendMessage(`${rand}% yabai! GIGACHAD`, msg.messageId);
else if (rand < 50) sendMessage(`${rand}% yabai POGGERS`, msg.messageId);
else if (rand === 50) sendMessage(`${rand}% yabai ok`, msg.messageId);
else if (rand < 90) sendMessage(`${rand}% yabai AINTNOWAY`, msg.messageId);
else await Promise.all([
sendMessage(`${msg.chatterDisplayName} is ${rand}% yabai CAUGHT`),
timeout(user, "TOO YABAI!", 60)
]);
});

View File

@@ -2,6 +2,7 @@ import { chatterId, streamerId, eventSub, commandPrefix, singleUserMode, unbanna
import { User } from "../user";
import commands from "../commands";
import { redis } from "bun";
import { isAdmin } from "../lib/admins";
console.info(`Loaded the following commands: ${commands.keys().toArray().join(', ')}`);
@@ -30,6 +31,16 @@ eventSub.onChannelChatMessage(streamerId, streamerId, async msg => {
const selected = commands.get(commandSelection.toLowerCase());
if (!selected) return;
if (disabledcommands.includes(selected.name)) return;
switch (selected.usertype) {
case "admin":
if (!await isAdmin(user!.id)) return;
break;
case "unbannable":
if (!unbannableUsers.includes(msg.chatterId)) return;
break;
};
try { await selected.execute(msg, user!); }
catch (err) { console.error(err); };
};

View File

@@ -1,12 +1,10 @@
import { createAuthProvider } from "./auth";
import { ApiClient } from "@twurple/api";
import { EventSubHttpListener, ReverseProxyAdapter } from "@twurple/eventsub-http";
import { commandintents } from "./commands";
import { itemintents } from "./items";
import { addAdmin } from "./lib/admins";
const CHATTERBASEINTENTS = ["user:read:chat", "user:write:chat", "user:bot"];
const STREAMERBASEINTENTS = ["user:read:chat", "moderation:read", "channel:manage:moderators"];
const CHATTERINTENTS = ["user:read:chat", "user:write:chat", "user:bot"];
const STREAMERINTENTS = ["user:read:chat", "moderation:read", "channel:manage:moderators", "moderator:manage:banned_users"];
export const singleUserMode = process.env.CHATTER_IS_STREAMER === 'true';
export const chatterId = process.env.CHATTER_ID ?? "";
@@ -19,11 +17,8 @@ if (hostName === "") { console.error('Please set a EVENTSUB_HOSTNAME in the .env
const port = Number(process.env.EVENTSUB_PORT) ?? 0;
if (port === 0) { console.error('Please set a EVENTSUB_PORT in the .env'); process.exit(1); };
const streamerIntents = STREAMERBASEINTENTS.concat(commandintents, itemintents);
const chatterIntents = singleUserMode ? CHATTERBASEINTENTS.concat(streamerIntents) : CHATTERBASEINTENTS;
export const chatterAuthProvider = await createAuthProvider(chatterId, chatterIntents);
export const streamerAuthProvider = singleUserMode ? undefined : await createAuthProvider(streamerId, streamerIntents, true);
export const chatterAuthProvider = await createAuthProvider(chatterId, singleUserMode ? CHATTERINTENTS.concat(STREAMERINTENTS) : 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 });

View File

@@ -9,7 +9,7 @@ const ITEMNAME = 'blaster';
export default new Item(ITEMNAME, 'Blaster', 's',
'Times a specific person out for 60 seconds',
['blaster', 'blast'], ['moderator:manage:banned_users'],
['blaster', 'blast'],
async (msg, user) => {
const userObj = await getUserRecord(user);
if (userObj.inventory[ITEMNAME]! < 1) { await sendMessage(`You don't have any blasters!`, msg.messageId); return; };

View File

@@ -10,7 +10,6 @@ const ITEMNAME = 'grenade';
export default new Item(ITEMNAME, 'Grenade', 's',
'Give a random chatter a 60s timeout',
['grenade'],
['moderator:manage:banned_users'],
async (msg, user) => {
const userObj = await getUserRecord(user);
if (userObj.inventory[ITEMNAME]! < 1) { await sendMessage(`You don't have any grenades!`, msg.messageId); return; };

View File

@@ -1,5 +1,6 @@
import { EventSubChannelChatMessageEvent } from "@twurple/eventsub-base";
import { User } from "../user";
import { type userType } from "../commands";
export class Item {
public readonly name: string;
@@ -7,24 +8,25 @@ export class Item {
public readonly plural: string;
public readonly description: string;
public readonly aliases: string[];
public readonly requiredIntents: string[];
public readonly usertype: userType;
public readonly execute: (message: EventSubChannelChatMessageEvent, sender: User) => Promise<void>;
public readonly disableable: boolean;
/** Creates an item object
* @param name - internal name of item
* @param prettyName - name of item for presenting to chat
* @param plural - plural appendage; example: lootbox(es)
* @param description - description of what item does
* @param aliases - alternative ways to activate item
* @param requiredIntents - required twitch API scopes to use item
* @param execution - code that gets executed when item gets used */
constructor(name: string, prettyName: string, plural: string, description: string, aliases: string[], requiredIntents: string[], execution: (message: EventSubChannelChatMessageEvent, sender: User) => Promise<void>) {
constructor(name: string, prettyName: string, plural: string, description: string, aliases: string[], execution: (message: EventSubChannelChatMessageEvent, sender: User) => Promise<void>) {
this.name = name;
this.prettyName = prettyName;
this.plural = plural;
this.description = description;
this.aliases = aliases;
this.requiredIntents = requiredIntents;
this.usertype = 'chatter'; // Items are usable by everyone
this.execute = execution;
this.disableable = true;
};
};
@@ -32,7 +34,6 @@ import { readdir } from 'node:fs/promises';
import type { userRecord } from "../db/connection";
import { updateUserRecord } from "../db/dbUser";
const items = new Map<string, Item>;
const itemintents: string[] = [];
const emptyInventory: inventory = {};
const itemarray: string[] = [];
@@ -43,14 +44,13 @@ for (const file of files) {
const item: Item = await import(import.meta.dir + '/' + file.slice(0, -3)).then(a => a.default);
emptyInventory[item.name] = 0;
itemarray.push(item.name);
itemintents.push(...item.requiredIntents);
for (const alias of item.aliases) {
items.set(alias, item); // Since it's not a primitive type the map is filled with references to the item, not the actual object
};
};
export default items;
export { itemintents, emptyInventory, itemarray };
export { emptyInventory, itemarray };
export type inventory = {
[key: string]: number;
};