From cde679e5833fea12b952dd2a43fa8fe453cbb796 Mon Sep 17 00:00:00 2001 From: qwerinope Date: Tue, 29 Jul 2025 01:36:28 +0200 Subject: [PATCH] add stats command to readme, implement optional stacking timeouts, fully rework timeout management --- README.md | 3 +++ src/commands/gettimeout.ts | 8 ++++---- src/commands/stacking.ts | 22 ++++++++++++++++++++++ src/events/banned.ts | 3 +++ src/events/unban.ts | 6 ++++++ src/index.ts | 8 ++++++++ src/lib/timeout.ts | 30 +++++++++++++++++++++++------- 7 files changed, 69 insertions(+), 11 deletions(-) create mode 100644 src/commands/stacking.ts create mode 100644 src/events/unban.ts diff --git a/README.md b/README.md index 0245306..b47b732 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,8 @@ COMMAND|FUNCTION|USER|ALIASES|DISABLEABLE `backshot`|'Backshot' a random previous chatter|anyone|`backshot`|:white_check_mark: `roulette`|Play russian roulette for a 5 minute timeout|anyone|`roulette`|:white_check_mark: `timeout {target}`|Times targeted user out for 60 seconds (costs 100 qweribucks)|anyone|`timeout`|:white_check_mark: +`stats [target]`|Get timeout and some item stats for yourself or specified user this month|anyone|`stats` `monthlystats`|:white_check_mark: +`alltime [target]`|Get timeout and some item stats for yourself or specified user of all time|anyone|`alltime` `alltimestats`|:white_check_mark: ### Qweribucks commands @@ -93,6 +95,7 @@ COMMAND|FUNCTION|USER|ALIASES|DISABLEABLE `getcommands [enabled/disabled]`|Get a list of all, enabled or disabled commands|anyone|`getcommands` `getc`|:x: `getcheers [enabled/disabled]`|Get a list of all, enabled or disabled cheers|anyone|`getcheers` `getcheer`|:x: `gettimeout {target}`|Get the remaining timeout duration of targeted user|anyone|`gettimeout` `gett`|:white_check_mark: +`stacking [on/off]`|Check/set if timeouts are stacking. Only admins can set the stacking state|anyone/admins|`stacking`|:x: `vulnchatters`|Get amount of chatters vulnerable to explosives|anyone|`vulnchatters` `vulnc`|:white_check_mark: `disablecommand {command/item}`|Disable a specific command/item|admins|`disablecommand`|:x: `enablecommand {command/item}`|Re-enable a specific command/item|admins|`enablecommand`|:x: diff --git a/src/commands/gettimeout.ts b/src/commands/gettimeout.ts index 65d95c6..3778c5a 100644 --- a/src/commands/gettimeout.ts +++ b/src/commands/gettimeout.ts @@ -1,16 +1,16 @@ import { Command, sendMessage } from "commands"; -import { streamerApi, streamerId } from "main"; import { buildTimeString } from "lib/dateManager"; import parseCommandArgs from "lib/parseCommandArgs"; import User from "user"; +import { timeoutDuration } from "lib/timeout"; export default new Command('gettimeout', ['gett', 'gettimeout'], 'chatter', 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 streamerApi.moderation.getBannedUsers(streamerId, { userId: target.id }).then(a => a.data); - if (!data[0]) { await sendMessage(`Chatter ${target.displayName} isn't timed out`, msg.messageId); return; }; - if (data[0].expiryDate) { await sendMessage(`${target.displayName} is still timed out for ${buildTimeString(data[0].expiryDate.getTime(), Date.now())}`, msg.messageId); return; }; + const data = await timeoutDuration(target); + if (data === false) { await sendMessage(`Chatter ${target.displayName} isn't timed out`, msg.messageId); return; }; + if (data) { await sendMessage(`${target.displayName} is still timed out for ${buildTimeString(data * 1000, Date.now())}`, msg.messageId); return; }; await sendMessage(`${target.displayName} is permanently banned`, msg.messageId); }); diff --git a/src/commands/stacking.ts b/src/commands/stacking.ts new file mode 100644 index 0000000..2561ea7 --- /dev/null +++ b/src/commands/stacking.ts @@ -0,0 +1,22 @@ +import { redis } from "bun"; +import { Command, sendMessage } from "commands"; +import { isAdmin } from "lib/admins"; +import parseCommandArgs from "lib/parseCommandArgs"; + +export default new Command('stacking', ['stacking'], 'chatter', async msg => { + const args = parseCommandArgs(msg.messageText); + if (!args[0] || !await isAdmin(msg.chatterId)) { await sendMessage(`Timeout stacking is currently ${await redis.exists('timeoutStacking') ? "on" : "off"}`, msg.messageId); return; }; + // Only admins can reach this part of code + switch (args[0]) { + case 'enable': + case 'on': + await redis.set('timeoutStacking', '1'); + await sendMessage('Timeout stacking is now on') + break; + case 'disable': + case 'off': + await redis.del('timeoutStacking'); + await sendMessage('Timeout stacking is now off') + break; + }; +}, false); diff --git a/src/events/banned.ts b/src/events/banned.ts index d336742..83fd569 100644 --- a/src/events/banned.ts +++ b/src/events/banned.ts @@ -1,6 +1,9 @@ import { eventSub, streamerId } from "main"; import { deleteBannedUserMessagesFromChatWidget } from "web/chatWidget/message"; +import { redis } from "bun"; eventSub.onChannelBan(streamerId, async msg => { deleteBannedUserMessagesFromChatWidget(msg); + await redis.set(`user:${msg.userId}:timeout`, '1'); + if (msg.endDate) await redis.expire(`user:${msg.userId}:timeout`, Math.floor((msg.endDate.getTime() - Date.now()) / 1000)); }); diff --git a/src/events/unban.ts b/src/events/unban.ts new file mode 100644 index 0000000..d7219dc --- /dev/null +++ b/src/events/unban.ts @@ -0,0 +1,6 @@ +import { redis } from "bun"; +import { eventSub, streamerId } from "main"; + +eventSub.onChannelUnban(streamerId, async msg => { + await redis.del(`user:${msg.userId}:timeout`); +}); diff --git a/src/index.ts b/src/index.ts index 51326ae..c208d03 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { EventSubWsListener } from "@twurple/eventsub-ws"; import { addAdmin } from "lib/admins"; import logger from "lib/logger"; import { addInvuln } from "lib/invuln"; +import { redis } from "bun"; 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", "channel:moderate"]; @@ -31,6 +32,13 @@ export const commandPrefix = process.env.COMMAND_PREFIX ?? "!"; export const streamerUsers = [chatterId, streamerId]; streamerUsers.forEach(async id => await Promise.all([addAdmin(id), addInvuln(id)])); +const banned = await streamerApi.moderation.getBannedUsers(streamerId).then(a => a.data); +banned.forEach(async ban => { + await redis.set(`user:${ban.userId}:timeout`, '1'); + if (ban.expiryDate) redis.expire(`user:${ban.userId}:timeout`, Math.floor((ban.expiryDate.getTime() - Date.now()) / 1000)); + logger.info(`Set the timeout/ban of ${ban.userDisplayName} in the Redis/Valkey database.`); +}); + await import("./events"); await import("./web"); diff --git a/src/lib/timeout.ts b/src/lib/timeout.ts index 6fac34d..3e2a467 100644 --- a/src/lib/timeout.ts +++ b/src/lib/timeout.ts @@ -2,6 +2,7 @@ import { streamerApi, streamerId } from "main"; import logger from "lib/logger"; import User from "user"; import { isInvuln } from "lib/invuln"; +import { redis } from "bun"; type SuccessfulTimeout = { status: true; }; type UnSuccessfulTimeout = { status: false; reason: 'banned' | 'unknown' | 'illegal'; }; @@ -14,9 +15,13 @@ type TimeoutResult = SuccessfulTimeout | UnSuccessfulTimeout; export const timeout = async (user: User, reason: string, duration?: number): Promise => { 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); - if (banStatus[0]) return { status: false, reason: 'banned' }; + // Check if user already has a timeout and handle stacking + const banStatus = await timeoutDuration(user); + if (banStatus) { + if (await redis.exists('timeoutStacking')) { + if (duration) duration += Math.floor((banStatus * 1000 - Date.now()) / 1000); // the target is timed out and stacking is on + } else return { status: false, reason: 'banned' }; // the target is timed out, but stacking is off + } else if (banStatus === null) return { status: false, reason: 'banned' }; // target is perma banned if (await streamerApi.moderation.checkUserMod(streamerId, user.id!)) { if (!duration) duration = 60; // make sure that mods don't get perma-banned @@ -28,18 +33,21 @@ export const timeout = async (user: User, reason: string, duration?: number): Pr await streamerApi.moderation.banUser(streamerId, { user: user.id, reason, duration }); } catch (err) { logger.err(err as string); - return { status: false, reason: 'unknown' } + return { status: false, reason: 'unknown' }; }; + await redis.set(`user:${user.id}:timeout`, '1'); + if (duration) await redis.expire(`user:${user.id}:timeout`, duration); + return { status: true }; }; /** Give the target mod status back after timeout */ function remodMod(target: User, duration: number) { setTimeout(async () => { - const bandata = await streamerApi.moderation.getBannedUsers(streamerId, { userId: target.id }).then(a => a.data); - if (bandata[0]) { // If the target is still timed out, try again when new timeout expires - const timeoutleft = Date.parse(bandata[0].expiryDate?.toString()!) - Date.now(); // date when timeout expires - current date + const bandata = await timeoutDuration(target); + if (bandata) { // If the target is still timed out, try again when new timeout expires + const timeoutleft = bandata * 1000 - Date.now(); // date when timeout expires - current date remodMod(target, timeoutleft); // Call the current function with new time (recursion) } else { try { @@ -48,3 +56,11 @@ function remodMod(target: User, duration: number) { }; }, duration + 3000); // callback gets called after duration of timeout + 3 seconds }; + +/** This returns number if there is a duration of time for the timeout, false if not banned and null if perma banned */ +export async function timeoutDuration(user: User): Promise { + const data = await redis.expiretime(`user:${user.id}:timeout`); + if (data === -1) return null; // Perma banned + else if (data === -2) return false; // Not banned + return data; +};