added invulnerable chatters, completely reworked the way vulnerable chatters and admins is stored

This commit is contained in:
2025-07-17 22:05:56 +02:00
parent 0ebc3d7cf6
commit dcd2eda439
16 changed files with 142 additions and 23 deletions

View File

@@ -8,6 +8,16 @@ Admins are defined by the streamer and can use special administrative commands o
Admins don't need to have moderator status in the channel.
The chatterbot and streamer always have admin status and cannot be stripped of admin powers.
Only the streamer and chatterbot have the power to add and remove admins.
Admins have the power to destroy the item economy. Be very careful with admin powers.
### Invulns
Invulns, or invulnerable chatters cannot be shot with items and cannot get hit by explosives.
They can however use items.
The intended use for invulns is for when you need to talk to a chatter, or for using other bots.
Invulns don't need moderator or vip status in the channel.
The chatterbot and streamer always are invuln and cannot be stripped of this status.
Only the streamer and chatterbot have the power to add and remove invulns.
### Commands
@@ -86,9 +96,12 @@ COMMAND|FUNCTION|USER|ALIASES|DISABLEABLE
`enablecommand {command/item}`|Re-enable a specific command/item|admins|`enablecommand`|:x:
`disablecheer {cheer}`|Disable a specific cheer event|admins|`disablecheer`|:x:
`enablecheer {cheer}`|Re-enable a specific cheer event|admins|`enablecheer`|:x:
`getinvulns`|Get a list of every invulnerable chatter in the channel|anyone|`getinvulns`|:x:
`getadmins`|Get a list of every admin in the channel|anyone|`getadmins`|:x:
`itemlock {target}`|Toggle the itemlock on the specified target|admins|`itemlock`|:x:
`testcheer {amount} [args]`|Create a fake cheering event|streamer/chatterbot|`testcheer`|:x:
`addinvuln {target}`|Adds an invuln user|streamer/chatterbot|`addinvuln`|:x:
`removeinvuln {target}`|Removes an invuln user| streamer/chatterbot|`removeinvuln`|:x:
`addadmin {target}`|Adds an admin|streamer/chatterbot|`addadmin`|:x:
`removeadmin {target}`|Removes an admin|streamer/chatterbot|`removeadmin`|:x:

27
flake.lock generated Normal file
View File

@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1751984180,
"narHash": "sha256-LwWRsENAZJKUdD3SpLluwDmdXY9F45ZEgCb0X+xgOL0=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "9807714d6944a957c2e036f84b0ff8caf9930bc0",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

18
flake.nix Normal file
View File

@@ -0,0 +1,18 @@
{
inputs = { nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; };
outputs = { self, nixpkgs }:
let
system = "x86_64-linux";
pkgs = import nixpkgs { inherit system; };
in {
devShells."${system}" = {
default = pkgs.mkShell {
packages = with pkgs; [ bun nodejs deno ];
shellHook = ''
echo Loaded the qweribot dev shell
'';
};
};
};
}

View File

@@ -9,6 +9,6 @@ export default new Command('addadmin', ['addadmin'], 'streamer', async msg => {
const target = await User.initUsername(args[0].toLowerCase());
if (!target) { await sendMessage(`Chatter ${args[0]} doesn't exist`, msg.messageId); return; };
const data = await addAdmin(target.id);
if (data === 1) await sendMessage(`${target.displayName} is now an admin`, msg.messageId);
if (data === "OK") await sendMessage(`${target.displayName} is now an admin`, msg.messageId);
else await sendMessage(`${target.displayName} is already an admin`, msg.messageId);
}, false);

14
src/commands/addinvuln.ts Normal file
View File

@@ -0,0 +1,14 @@
import { Command, sendMessage } from ".";
import { addInvuln } from "../lib/invuln";
import parseCommandArgs from "../lib/parseCommandArgs";
import { User } from "../user";
export default new Command('addinvuln', ['addinvuln'], 'streamer', 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());
if (!target) { await sendMessage(`Chatter ${args[0]} doesn't exist`, msg.messageId); return; };
const data = await addInvuln(target.id);
if (data === "OK") await sendMessage(`${target.displayName} is now an invuln`, msg.messageId);
else await sendMessage(`${target.displayName} is already an invuln`, msg.messageId);
}, false);

View File

@@ -0,0 +1,13 @@
import { Command, sendMessage } from ".";
import { getInvulns } from "../lib/invuln";
import { User } from "../user";
export default new Command('getinvulns', ['getinvulns'], 'chatter', async msg => {
const invulns = await getInvulns()
const invulnnames: string[] = [];
for (const id of invulns) {
const invuln = await User.initUserId(id);
invulnnames.push(invuln?.displayName!);
};
await sendMessage(`Current invulnerable chatters: ${invulnnames.join(', ')}`, msg.messageId);
}, false);

View File

@@ -0,0 +1,16 @@
import { Command, sendMessage } from ".";
import { streamerUsers } from "..";
import { removeInvuln } from "../lib/invuln";
import parseCommandArgs from "../lib/parseCommandArgs";
import { User } from "../user";
export default new Command('removeinvuln', ['removeinvuln'], 'streamer', 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());
if (!target) { await sendMessage(`Chatter ${args[0]} doesn't exist`, msg.messageId); return; };
if (streamerUsers.includes(target.id)) { await sendMessage(`Can't remove invulnerability from ${target.displayName} as they are managed by the bot program`, msg.messageId); return; };
const data = await removeInvuln(target.id);
if (data === 1) await sendMessage(`${target.displayName} is no longer invulnerable`, msg.messageId);
else await sendMessage(`${target.displayName} isn't invulnerable`, msg.messageId);
}, false);

View File

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

View File

@@ -7,6 +7,7 @@ import { isAdmin } from "../lib/admins";
import cheers from "../cheers";
import logger from "../lib/logger";
import { addMessageToChatWidget } from "../chatwidget/message";
import { isInvuln } from "../lib/invuln";
logger.info(`Loaded the following commands: ${commands.keys().toArray().join(', ')}`);
@@ -14,8 +15,6 @@ eventSub.onChannelChatMessage(streamerId, streamerId, parseChatMessage);
async function parseChatMessage(msg: EventSubChannelChatMessageEvent) {
addMessageToChatWidget(msg);
if (!singleUserMode && msg.chatterId === chatterId) return;
// return if double user mode is on and the chatter says something, we don't need them
const user = await User.initUsername(msg.chatterName);
@@ -27,7 +26,7 @@ async function parseChatMessage(msg: EventSubChannelChatMessageEvent) {
// and both are usable to target the same user (id is the same)
// The only problem would be if a user changed their name and someone else took their name right after
if (!streamerUsers.includes(msg.chatterId)) user?.makeVulnerable(); // Make the user vulnerable to explosions if not streamerbot or chatterbot
if (!await isInvuln(user?.id!)) user?.setVulnerable(); // Make the user vulnerable to explosions if not marked as invuln
if (!msg.isCheer && !msg.isRedemption) await handleChatMessage(msg, user!)
else if (msg.isCheer && !msg.isRedemption) await handleCheer(msg, msg.bits, user!);

View File

@@ -3,6 +3,7 @@ import { ApiClient } from "@twurple/api";
import { EventSubWsListener } from "@twurple/eventsub-ws";
import { addAdmin } from "./lib/admins";
import logger from "./lib/logger";
import { addInvuln } from "./lib/invuln";
const CHATTERINTENTS = ["user:read:chat", "user:write:chat", "user:bot"];
const STREAMERINTENTS = ["user:read:chat", "moderation:read", "channel:manage:moderators", "moderator:manage:banned_users", "bits:read"];
@@ -28,7 +29,7 @@ export const eventSub = new EventSubWsListener({ apiClient: streamerApi });
export const commandPrefix = process.env.COMMAND_PREFIX ?? "!";
export const streamerUsers = [chatterId, streamerId];
streamerUsers.forEach(async id => await addAdmin(id));
streamerUsers.forEach(async id => await Promise.all([addAdmin(id), addInvuln(id)]));
await import("./events");

View File

@@ -15,10 +15,10 @@ export default new Item(ITEMNAME, 'Grenade', 's',
async (msg, user) => {
const userObj = await getUserRecord(user);
if (userObj.inventory[ITEMNAME]! < 1) { await sendMessage(`You don't have any grenades!`, msg.messageId); return; };
const targets = await redis.keys('vulnchatters:*');
const targets = await redis.keys(`user:*:vulnerable`);
if (targets.length === 0) { await sendMessage('No vulnerable chatters to blow up', msg.messageId); return; };
const selection = targets[Math.floor(Math.random() * targets.length)]!;
const target = await User.initUserId(selection.split(':')[1]!);
const target = await User.initUserId(selection.slice(5, -11));
await getUserRecord(target!); // make sure the user record exist in the database

View File

@@ -15,7 +15,7 @@ export default new Item(ITEMNAME, 'TNT', 's',
async (msg, user) => {
const userObj = await getUserRecord(user);
if (userObj.inventory[ITEMNAME]! < 1) { await sendMessage(`You don't have any TNTs!`, msg.messageId); return; };
const vulntargets = await redis.keys('vulnchatters:*');
const vulntargets = await redis.keys('user:*:vulnerable').then(a => a.map(b => b.slice(5, -11)));
if (vulntargets.length === 0) { await sendMessage('No vulnerable chatters to blow up', msg.messageId); return; };
const targets = getTNTTargets(vulntargets);
@@ -23,7 +23,7 @@ export default new Item(ITEMNAME, 'TNT', 's',
await user.setLock();
await Promise.all(targets.map(async targetid => {
const target = await User.initUserId(targetid.split(':')[1]!);
const target = await User.initUserId(targetid);
await getUserRecord(target!); // make sure the user record exist in the database
await Promise.all([
timeout(target!, `You got hit by ${user.displayName}'s TNT!`, 60),

View File

@@ -1,14 +1,15 @@
import { redis } from "bun";
export async function getAdmins() {
return await redis.smembers('admins');
const data = await redis.keys('user:*:admin');
return data.map(a => a.slice(5, -6));
};
export async function isAdmin(userid: string) {
return await redis.sismember('admins', userid);
return await redis.exists(`user:${userid}:admin`);
};
export async function addAdmin(userid: string) {
return await redis.sadd('admins', userid);
return await redis.set(`user:${userid}:admin`, '1');
};
export async function removeAdmin(userid: string) {
return await redis.srem('admins', userid);
return await redis.del(`user:${userid}:admin`);
};

16
src/lib/invuln.ts Normal file
View File

@@ -0,0 +1,16 @@
import { redis } from "bun";
export async function getInvulns() {
const data = await redis.keys('user:*:invulnerable');
return data.map(a => a.slice(5, -13));
};
export async function isInvuln(userid: string) {
return await redis.exists(`user:${userid}:invulnerable`);
};
export async function addInvuln(userid: string) {
await redis.del(`user:${userid}:vulnerable`);
return await redis.set(`user:${userid}:invulnerable`, '1');
};
export async function removeInvuln(userid: string) {
return await redis.del(`user:${userid}:invulnerable`);
};

View File

@@ -1,9 +1,10 @@
import { streamerApi, streamerId, streamerUsers } from "..";
import { streamerApi, streamerId } from "..";
import logger from "./logger";
import { User } from "../user";
import { isInvuln } from "./invuln";
type SuccessfulTimeout = { status: true };
type UnSuccessfulTimeout = { status: false; reason: 'banned' | 'unknown' | 'illegal' };
type SuccessfulTimeout = { status: true; };
type UnSuccessfulTimeout = { status: false; reason: 'banned' | 'unknown' | 'illegal'; };
type TimeoutResult = SuccessfulTimeout | UnSuccessfulTimeout;
/** Give a user a timeout/ban
@@ -11,7 +12,7 @@ type TimeoutResult = SuccessfulTimeout | UnSuccessfulTimeout;
* @param reason - reason for timeout/ban
* @param duration - duration of timeout. don't specifiy for ban */
export const timeout = async (user: User, reason: string, duration?: number): Promise<TimeoutResult> => {
if (streamerUsers.includes(user.id)) return { status: false, reason: 'illegal' };
if (await isInvuln(user.id)) return { status: false, reason: 'illegal' }; // Don't timeout invulnerable chatters
// Check if user already has a timeout
const banStatus = await streamerApi.moderation.getBannedUsers(streamerId, { userId: user.id }).then(a => a.data);

View File

@@ -89,12 +89,12 @@ export class User {
await redis.set(`user:${this.id}:itemlock`, '0');
};
public async makeVulnerable(): Promise<void> {
await redis.set(`vulnchatters:${this.id}`, this.displayName);
await redis.expire(`vulnchatters:${this.id}`, Math.floor(EXPIRETIME / 2)); // Vulnerable chatter gets removed from the pool after 30 minutes
public async setVulnerable(): Promise<void> {
await redis.set(`user:${this.id}:vulnerable`, '1');
await redis.expire(`user:${this.id}:vulnerable`, Math.floor(EXPIRETIME / 2)); // Vulnerable chatter gets removed from the pool after 30 minutes
};
public async makeInvulnerable(): Promise<void> {
await redis.del(`vulnchatters:${this.id}`);
public async clearVulnerable(): Promise<void> {
await redis.del(`user:${this.id}:vulnerable`);
};
};