mirror of
https://github.com/qwerinope/qweribot.git
synced 2025-12-19 08:41:39 +01:00
rename bot directory to src, add chatwidget
This commit is contained in:
87
src/auth.ts
Normal file
87
src/auth.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { RefreshingAuthProvider, exchangeCode, type AccessToken } from "@twurple/auth";
|
||||
import { createAuthRecord, deleteAuthRecord, getAuthRecord, updateAuthRecord } from "./db/dbAuth";
|
||||
|
||||
import logger from "./lib/logger";
|
||||
import kleur from "kleur";
|
||||
|
||||
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).toUpperCase();
|
||||
// Generate random state variable to prevent cross-site-scripting attacks
|
||||
|
||||
const instruction = `Visit this URL as ${kleur.red().underline().italic(streamer ? 'the streamer' : 'the chatter')} to authenticate the bot.`
|
||||
logger.info(instruction);
|
||||
logger.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;
|
||||
server.stop(false);
|
||||
logger.info(`Authentication code received.`);
|
||||
const tokenData = await exchangeCode(clientId, clientSecret, code, redirectURL);
|
||||
logger.info(`Successfully authenticated code.`);
|
||||
await createAuthRecord(tokenData, userId);
|
||||
logger.ok(`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) { logger.enverr("CLIENT_ID"); process.exit(1); };
|
||||
|
||||
const clientSecret = process.env.CLIENT_SECRET;
|
||||
if (!clientSecret) { logger.enverr("CLIENT_SECRET"); 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) => {
|
||||
logger.ok(`Successfully refreshed auth for user ${user}`);
|
||||
await updateAuthRecord(user, token);
|
||||
});
|
||||
|
||||
authData.onRefreshFailure((user, err) => {
|
||||
logger.err(`Failed to refresh auth for user ${user}: ${err.name} ${err.message}`);
|
||||
});
|
||||
|
||||
try {
|
||||
await authData.refreshAccessTokenForUser(user);
|
||||
} catch (err) {
|
||||
logger.err(`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;
|
||||
};
|
||||
72
src/chatwidget/index.ts
Normal file
72
src/chatwidget/index.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { chatterApi, streamerId } from "..";
|
||||
import logger from "../lib/logger";
|
||||
import chatWidget from "./www/index.html";
|
||||
|
||||
type badgeObject = {
|
||||
[key: string]: {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
|
||||
const port = Number(process.env.CHATWIDGET_PORT);
|
||||
if (isNaN(port)) { logger.enverr("CHATWIDGET_PORT"); process.exit(1); };
|
||||
|
||||
export default Bun.serve({
|
||||
port,
|
||||
fetch(request, server) {
|
||||
if (server.upgrade(request)) return;
|
||||
return new Response('oops', { status: 500 });
|
||||
},
|
||||
routes: {
|
||||
"/": chatWidget,
|
||||
"/getBadges": async () => {
|
||||
const globalBadges = chatterApi.chat.getGlobalBadges();
|
||||
const channelBadges = chatterApi.chat.getChannelBadges(streamerId);
|
||||
const rawBadges = await Promise.all([globalBadges, channelBadges]);
|
||||
|
||||
const newObj: badgeObject = {};
|
||||
parseRawBadges(newObj, rawBadges[0]);
|
||||
parseRawBadges(newObj, rawBadges[1]);
|
||||
|
||||
return Response.json(newObj);
|
||||
},
|
||||
},
|
||||
websocket: {
|
||||
open(ws) {
|
||||
logger.ok('Opened websocket connection to chatwidget');
|
||||
ws.sendText(JSON.stringify({
|
||||
function: 'serverNotification',
|
||||
message: 'Sucessfully opened websocket connection'
|
||||
}));
|
||||
},
|
||||
message(ws, omessage) {
|
||||
const message = JSON.parse(omessage.toString());
|
||||
if (!message.type) return;
|
||||
switch (message.type) {
|
||||
case 'subscribe':
|
||||
if (!message.target) return;
|
||||
ws.subscribe(message.target);
|
||||
ws.send(JSON.stringify({
|
||||
function: 'serverNotification',
|
||||
message: `Successfully subscribed to all ${message.target} events`
|
||||
}));
|
||||
break;
|
||||
};
|
||||
},
|
||||
close(ws) {
|
||||
ws.close();
|
||||
}
|
||||
},
|
||||
development: true
|
||||
});
|
||||
|
||||
import { HelixChatBadgeSet } from "@twurple/api";
|
||||
|
||||
function parseRawBadges(returnobj: badgeObject, data: HelixChatBadgeSet[]) {
|
||||
for (const badge of data) {
|
||||
if (!returnobj[badge.id]) returnobj[badge.id] = {};
|
||||
for (const version of badge.versions) {
|
||||
returnobj[badge.id]![version.id] = version.getImageUrl(4);
|
||||
};
|
||||
};
|
||||
};
|
||||
21
src/chatwidget/message.ts
Normal file
21
src/chatwidget/message.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { EventSubChannelChatMessageEvent, EventSubChannelChatMessageDeleteEvent } from "@twurple/eventsub-base";
|
||||
import chatwserver from ".";
|
||||
|
||||
export async function addMessageToChatWidget(msg: EventSubChannelChatMessageEvent) {
|
||||
chatwserver.publish('twitch', JSON.stringify({
|
||||
function: 'createMessage',
|
||||
messageParts: msg.messageParts,
|
||||
displayName: msg.chatterDisplayName,
|
||||
chatterId: msg.chatterId,
|
||||
chatterColor: msg.color,
|
||||
messageId: msg.messageId,
|
||||
badgeData: msg.badges
|
||||
}));
|
||||
};
|
||||
|
||||
export async function deleteMessageFromChatWidget(msg: EventSubChannelChatMessageDeleteEvent) {
|
||||
chatwserver.publish('twitch', JSON.stringify({
|
||||
function: 'deleteMessage',
|
||||
messageId: msg.messageId
|
||||
}));
|
||||
};
|
||||
55
src/chatwidget/websockettypes.ts
Normal file
55
src/chatwidget/websockettypes.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export type createMessageEvent = { function: 'createMessage', messageParts: EventSubChatMessagePart[], messageId: string, displayName: string, chatterId: string, chatterColor: null | string, badgeData: string[] };
|
||||
export type deleteMessageEvent = { function: 'deleteMessage', messageId: string };
|
||||
export type serverNotificationEvent = { function: 'serverNotification', message: string };
|
||||
|
||||
export type eventData = createMessageEvent | deleteMessageEvent | serverNotificationEvent;
|
||||
|
||||
// The types below are taken straight from @twurple/eventsub-base
|
||||
// I would import this from the package, but that's impossible
|
||||
export interface EventSubChatMessageTextPart {
|
||||
type: 'text';
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface EventSubChatMessageCheermote {
|
||||
prefix: string;
|
||||
bits: number;
|
||||
tier: number;
|
||||
}
|
||||
|
||||
export interface EventSubChatMessageCheermotePart {
|
||||
type: 'cheermote';
|
||||
text: string;
|
||||
cheermote: EventSubChatMessageCheermote;
|
||||
}
|
||||
|
||||
export interface EventSubChatMessageEmote {
|
||||
id: string;
|
||||
emote_set_id: string;
|
||||
owner_id: string;
|
||||
format: string[];
|
||||
}
|
||||
|
||||
export interface EventSubChatMessageEmotePart {
|
||||
type: 'emote';
|
||||
text: string;
|
||||
emote: EventSubChatMessageEmote;
|
||||
}
|
||||
|
||||
export interface EventSubChatMessageMention {
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
user_login: string;
|
||||
}
|
||||
|
||||
export interface EventSubChatMessageMentionPart {
|
||||
type: 'mention';
|
||||
text: string;
|
||||
mention: EventSubChatMessageMention;
|
||||
}
|
||||
|
||||
export type EventSubChatMessagePart =
|
||||
| EventSubChatMessageTextPart
|
||||
| EventSubChatMessageCheermotePart
|
||||
| EventSubChatMessageEmotePart
|
||||
| EventSubChatMessageMentionPart;
|
||||
12
src/chatwidget/www/index.html
Normal file
12
src/chatwidget/www/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>qwerinope's chat widget</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
61
src/chatwidget/www/src/createMessage.ts
Normal file
61
src/chatwidget/www/src/createMessage.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import './style.css';
|
||||
|
||||
const badges = await fetch(`http://${location.host}/getBadges`).then(data => data.json());
|
||||
|
||||
import { type createMessageEvent } from '../../websockettypes';
|
||||
|
||||
export function parseMessage(data: createMessageEvent): HTMLDivElement {
|
||||
const parentDiv = document.createElement('div');
|
||||
parentDiv.className = 'message';
|
||||
|
||||
// Badge Parsing
|
||||
const badgeContainer = document.createElement('div');
|
||||
badgeContainer.className = 'badgeContainer';
|
||||
for (const badge of Object.entries(data.badgeData)) {
|
||||
const badgeElement = document.createElement('img');
|
||||
badgeElement.className = 'badgeElement';
|
||||
const currentbadge = badges[badge[0]][badge[1]];
|
||||
badgeElement.src = currentbadge;
|
||||
badgeContainer.appendChild(badgeElement);
|
||||
};
|
||||
parentDiv.appendChild(badgeContainer);
|
||||
|
||||
const chatterName = document.createElement('span');
|
||||
chatterName.style = `color: ${data.chatterColor ?? "#00ff00"}`;
|
||||
chatterName.innerText = data.displayName;
|
||||
chatterName.className = "chatterName";
|
||||
parentDiv.appendChild(chatterName);
|
||||
|
||||
const seperator = document.createElement('span');
|
||||
seperator.innerText = ": ";
|
||||
seperator.className = "chatMessageSeparator";
|
||||
parentDiv.appendChild(seperator);
|
||||
|
||||
const textElement = document.createElement('div');
|
||||
for (const messagePart of data.messageParts) {
|
||||
let messageElement;
|
||||
switch (messagePart.type) {
|
||||
case 'text':
|
||||
messageElement = document.createElement('span');
|
||||
messageElement.className = "textMessage";
|
||||
messageElement.innerText = messagePart.text;
|
||||
break;
|
||||
case 'cheermote':
|
||||
messageElement = document.createElement('img');
|
||||
break;
|
||||
case 'emote':
|
||||
messageElement = document.createElement('img');
|
||||
messageElement.className = "emoteMessage";
|
||||
messageElement.src = `https://static-cdn.jtvnw.net/emoticons/v2/${messagePart.emote.id}/default/dark/3.0`;
|
||||
break;
|
||||
case 'mention':
|
||||
messageElement = document.createElement('span');
|
||||
messageElement.innerText = `Replying to ${messagePart.text}`;
|
||||
messageElement.className = "replyMessage";
|
||||
break;
|
||||
};
|
||||
textElement.appendChild(messageElement);
|
||||
};
|
||||
parentDiv.appendChild(textElement);
|
||||
return parentDiv;
|
||||
};
|
||||
33
src/chatwidget/www/src/main.ts
Normal file
33
src/chatwidget/www/src/main.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { type eventData } from "../../websockettypes";
|
||||
|
||||
import { parseMessage } from './createMessage';
|
||||
|
||||
const socket = new WebSocket(`ws://${location.host}`);
|
||||
|
||||
socket.onopen = () => {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'subscribe',
|
||||
target: 'twitch'
|
||||
}));
|
||||
};
|
||||
|
||||
socket.onmessage = event => {
|
||||
const data: eventData = JSON.parse(event.data);
|
||||
switch (data.function) {
|
||||
case 'createMessage':
|
||||
const newMessageElement = parseMessage(data);
|
||||
newMessageElement.id = data.messageId;
|
||||
document.querySelector("#message-container")?.appendChild(newMessageElement);
|
||||
break;
|
||||
case 'deleteMessage':
|
||||
document.querySelector(`#${CSS.escape(data.messageId)}`)?.remove();
|
||||
break;
|
||||
case 'serverNotification':
|
||||
console.log(data.message);
|
||||
break;
|
||||
};
|
||||
};
|
||||
|
||||
document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
|
||||
<div id="message-container"></div>
|
||||
`;
|
||||
46
src/chatwidget/www/src/style.css
Normal file
46
src/chatwidget/www/src/style.css
Normal file
@@ -0,0 +1,46 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.message {
|
||||
>* {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.replymessage {
|
||||
color: grey;
|
||||
font-size: 2px;
|
||||
}
|
||||
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
img {
|
||||
vertical-align: middle;
|
||||
width: 18px;
|
||||
padding: 1px;
|
||||
}
|
||||
105
src/chatwidget/www/tsconfig.json
Normal file
105
src/chatwidget/www/tsconfig.json
Normal file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||
/* Projects */
|
||||
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
/* Language and Environment */
|
||||
"target": "es2020", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
// "libReplacement": true, /* Enable lib replacement. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||
/* Modules */
|
||||
"module": "esnext", /* Specify what module code is generated. */
|
||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
|
||||
// "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */
|
||||
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
|
||||
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
|
||||
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
|
||||
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
|
||||
// "resolveJsonModule": true, /* Enable importing .json files. */
|
||||
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
|
||||
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||
/* JavaScript Support */
|
||||
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||
/* Emit */
|
||||
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||
// "outDir": "./", /* Specify an output folder for all emitted files. */
|
||||
// "removeComments": true, /* Disable emitting comments. */
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
/* Interop Constraints */
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
|
||||
// "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
|
||||
// "erasableSyntaxOnly": true, /* Do not allow runtime constructs that are not part of ECMAScript. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||
/* Type Checking */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
|
||||
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
}
|
||||
}
|
||||
29
src/cheers/index.ts
Normal file
29
src/cheers/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { User } from '../user';
|
||||
import { EventSubChannelChatMessageEvent } from "@twurple/eventsub-base";
|
||||
|
||||
export class Cheer {
|
||||
public readonly name: string;
|
||||
public readonly amount: number;
|
||||
public readonly execute: (msg: EventSubChannelChatMessageEvent, sender: User) => Promise<void>;
|
||||
constructor(name: string, amount: number, execution: (msg: EventSubChannelChatMessageEvent, sender: User) => Promise<void>) {
|
||||
this.name = name.toLowerCase();
|
||||
this.amount = amount;
|
||||
this.execute = execution;
|
||||
};
|
||||
};
|
||||
|
||||
import { readdir } from 'node:fs/promises';
|
||||
const cheers = new Map<number, Cheer>;
|
||||
const namedcheers = new Map<string, Cheer>;
|
||||
|
||||
const files = await readdir(import.meta.dir);
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.ts')) continue;
|
||||
if (file === import.meta.file) continue;
|
||||
const cheer: Cheer = await import(import.meta.dir + '/' + file.slice(0, -3)).then(a => a.default);
|
||||
cheers.set(cheer.amount, cheer);
|
||||
namedcheers.set(cheer.name, cheer);
|
||||
};
|
||||
|
||||
export default cheers;
|
||||
export { namedcheers };
|
||||
54
src/cheers/timeout.ts
Normal file
54
src/cheers/timeout.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Cheer } from ".";
|
||||
import { EventSubChannelChatMessageEvent } from "@twurple/eventsub-base"
|
||||
import { changeItemCount } from "../items";
|
||||
import { sendMessage } from "../commands";
|
||||
import { getUserRecord } from "../db/dbUser";
|
||||
import { User } from "../user";
|
||||
import { timeout } from "../lib/timeout";
|
||||
import { createTimeoutRecord } from "../db/dbTimeouts";
|
||||
import logger from "../lib/logger";
|
||||
import { parseCheerArgs } from "../lib/parseCommandArgs";
|
||||
|
||||
export default new Cheer('timeout', 100, async (msg, user) => {
|
||||
const args = parseCheerArgs(msg.messageText);
|
||||
if (!args[0]) { await handleNoBlasterTarget(msg, user, false); return; };
|
||||
const target = await User.initUsername(args[0].toLowerCase());
|
||||
if (!target) { await handleNoBlasterTarget(msg, user, false); return; };
|
||||
await getUserRecord(target);
|
||||
|
||||
const result = await timeout(target, `You got blasted by ${user.displayName}!`, 60);
|
||||
if (result.status) await Promise.all([
|
||||
sendMessage(`GOTTEM ${target.displayName} got BLASTED by ${user.displayName} GOTTEM`),
|
||||
createTimeoutRecord(user, target, 'blaster'),
|
||||
]);
|
||||
else {
|
||||
await handleNoBlasterTarget(msg, user);
|
||||
switch (result.reason) {
|
||||
case "banned":
|
||||
await sendMessage(`${target.displayName} is already timed out/banned`, msg.messageId);
|
||||
break;
|
||||
case "illegal":
|
||||
await Promise.all([
|
||||
sendMessage(`${user.displayName} Nou Nou Nou`),
|
||||
timeout(user, 'nah', 60)
|
||||
]);
|
||||
break;
|
||||
case "unknown":
|
||||
await sendMessage('Something went wrong...', msg.messageId);
|
||||
break;
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
async function handleNoBlasterTarget(msg: EventSubChannelChatMessageEvent, user: User, silent = true) {
|
||||
if (await user.itemLock()) {
|
||||
await sendMessage(`Cannot give ${user.displayName} a blaster`, msg.messageId);
|
||||
logger.err(`Failed to give ${user.displayName} a blaster for their cheer`);
|
||||
return;
|
||||
};
|
||||
await user.setLock();
|
||||
const userRecord = await getUserRecord(user);
|
||||
if (!silent) await sendMessage('No (valid) target specified. You got a blaster!', msg.messageId);
|
||||
await changeItemCount(user, userRecord, 'blaster', 1);
|
||||
await user.clearLock();
|
||||
};
|
||||
14
src/commands/addadmin.ts
Normal file
14
src/commands/addadmin.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Command, sendMessage } from ".";
|
||||
import { addAdmin } from "../lib/admins";
|
||||
import parseCommandArgs from "../lib/parseCommandArgs";
|
||||
import { User } from "../user";
|
||||
|
||||
export default new Command('addadmin', ['addadmin'], '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 addAdmin(target.id);
|
||||
if (data === 1) await sendMessage(`${target.displayName} is now an admin`, msg.messageId);
|
||||
else await sendMessage(`${target.displayName} is already an admin`, msg.messageId);
|
||||
}, false);
|
||||
25
src/commands/admindonate.ts
Normal file
25
src/commands/admindonate.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Command, sendMessage } from ".";
|
||||
import { getUserRecord } from "../db/dbUser";
|
||||
import { changeBalance } from "../lib/changeBalance";
|
||||
import parseCommandArgs from "../lib/parseCommandArgs";
|
||||
import { User } from "../user";
|
||||
|
||||
export default new Command('admindonate', ['admindonate'], '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());
|
||||
if (!target) { await sendMessage(`Chatter ${args[0]} doesn't exist`, msg.messageId); return; };
|
||||
const userRecord = await getUserRecord(target);
|
||||
if (!args[1]) { await sendMessage('Please specify the amount qweribucks you want to give', msg.messageId); return; };
|
||||
const amount = Number(args[1]);
|
||||
if (isNaN(amount)) { await sendMessage(`${args[1]} is not a valid amount`); return; };
|
||||
if (await target.itemLock()) { await sendMessage('Cannot give qweribucks: item lock is set', msg.messageId); return; };
|
||||
await target.setLock();
|
||||
const data = await changeBalance(target, userRecord, amount);
|
||||
if (!data) {
|
||||
await sendMessage(`Failed to give ${target.displayName} ${amount} qweribuck${amount === 1 ? '' : 's'}`, msg.messageId);
|
||||
} else {
|
||||
await sendMessage(`${target.displayName} now has ${data.balance} qweribuck${data.balance === 1 ? '' : 's'}`, msg.messageId);
|
||||
};
|
||||
await target.clearLock();
|
||||
});
|
||||
29
src/commands/admingive.ts
Normal file
29
src/commands/admingive.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Command, sendMessage } from ".";
|
||||
import { getUserRecord } from "../db/dbUser";
|
||||
import items, { changeItemCount } from "../items";
|
||||
import parseCommandArgs from "../lib/parseCommandArgs";
|
||||
import { User } from "../user";
|
||||
|
||||
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());
|
||||
if (!target) { await sendMessage(`Chatter ${args[0]} doesn't exist`, msg.messageId); return; };
|
||||
const userRecord = await getUserRecord(target);
|
||||
if (!args[1]) { await sendMessage('Please specify an item to give', msg.messageId); return; };
|
||||
const item = items.get(args[1].toLowerCase());
|
||||
if (!item) { await sendMessage(`Item ${args[1]} doesn't exist`, msg.messageId); return; };
|
||||
if (!args[2]) { await sendMessage('Please specify the amount of the item you want to give', msg.messageId); return; };
|
||||
const amount = Number(args[2]);
|
||||
if (isNaN(amount)) { await sendMessage(`${args[2]} is not a valid amount`); return; };
|
||||
if (await target.itemLock()) { await sendMessage('Cannot give item: item lock is set', msg.messageId); return; };
|
||||
await target.setLock();
|
||||
const data = await changeItemCount(target, userRecord, item.name, amount);
|
||||
if (data) {
|
||||
const newamount = data.inventory[item.name]!;
|
||||
await sendMessage(`${target.displayName} now has ${newamount} ${item.prettyName + (newamount === 1 ? '' : item.plural)}`, msg.messageId);
|
||||
} else {
|
||||
await sendMessage(`Failed to give ${target.displayName} ${amount} ${item.prettyName + (amount === 1 ? '' : item.plural)}`, msg.messageId);
|
||||
};
|
||||
await target.clearLock();
|
||||
});
|
||||
14
src/commands/disablecheer.ts
Normal file
14
src/commands/disablecheer.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { redis } from "bun";
|
||||
import { Command, sendMessage } from ".";
|
||||
import parseCommandArgs from "../lib/parseCommandArgs";
|
||||
import { namedcheers } from "../cheers";
|
||||
|
||||
export default new Command('disablecheer', ['disablecheer'], 'admin', async msg => {
|
||||
const args = parseCommandArgs(msg.messageText);
|
||||
if (!args[0]) { await sendMessage('Please specify a cheer to disable', msg.messageId); return; };
|
||||
const selection = namedcheers.get(args[0].toLowerCase());
|
||||
if (!selection) { await sendMessage(`There is no ${args[0]} cheer`, msg.messageId); return; };
|
||||
const result = await redis.sadd('disabledcheers', selection.name);
|
||||
if (result === 0) { await sendMessage(`The ${selection.name} cheer is already disabled`, msg.messageId); return; };
|
||||
await sendMessage(`Successfully disabled the ${selection.name} cheer`, msg.messageId);
|
||||
}, false);
|
||||
14
src/commands/disablecommand.ts
Normal file
14
src/commands/disablecommand.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { redis } from "bun";
|
||||
import commands, { Command, sendMessage } from ".";
|
||||
import parseCommandArgs from "../lib/parseCommandArgs";
|
||||
|
||||
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());
|
||||
if (!selection) { await sendMessage(`There is no ${args[0]} command`, msg.messageId); return; };
|
||||
if (!selection.disableable) { await sendMessage(`Cannot disable ${selection.name} as the command is not disableable`, msg.messageId); return; };
|
||||
const result = await redis.sadd('disabledcommands', selection.name);
|
||||
if (result === 0) { await sendMessage(`The ${selection.name} command is already disabled`, msg.messageId); return; };
|
||||
await sendMessage(`Successfully disabled the ${selection.name} command`, msg.messageId);
|
||||
}, false);
|
||||
48
src/commands/donateqbucks.ts
Normal file
48
src/commands/donateqbucks.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Command, sendMessage } from ".";
|
||||
import type { userRecord } from "../db/connection";
|
||||
import { getUserRecord } from "../db/dbUser";
|
||||
import parseCommandArgs from "../lib/parseCommandArgs";
|
||||
import { changeBalance } from "../lib/changeBalance";
|
||||
import { User } from "../user";
|
||||
import logger from "../lib/logger";
|
||||
|
||||
export default new Command('donate', ['donate'], '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());
|
||||
if (!target) { await sendMessage(`Chatter ${args[0]} doesn't exist`, msg.messageId); return; };
|
||||
if (target.username === user.username) { await sendMessage("You can't give yourself qweribucks", msg.messageId); return; };
|
||||
const targetRecord = await getUserRecord(target);
|
||||
if (!args[1]) { await sendMessage('Please specify the amount of the item you want to give', msg.messageId); return; };
|
||||
const amount = Number(args[1]);
|
||||
if (isNaN(amount) || amount < 0) { await sendMessage(`${args[1]} is not a valid amount`); return; };
|
||||
|
||||
const userRecord = await getUserRecord(user);
|
||||
if (userRecord.balance < amount) { await sendMessage(`You can't give qweribucks you don't have!`, msg.messageId); return; };
|
||||
|
||||
if (await user.itemLock() || await target.itemLock()) { await sendMessage('Cannot give qweribucks', msg.messageId); return; };
|
||||
|
||||
await Promise.all([
|
||||
user.setLock(),
|
||||
target.setLock()
|
||||
]);
|
||||
|
||||
const data = await Promise.all([
|
||||
await changeBalance(target, targetRecord, amount),
|
||||
await changeBalance(user, userRecord, -amount)
|
||||
]);
|
||||
|
||||
if (!data.includes(false)) {
|
||||
const { balance: newamount } = data[0] as userRecord;
|
||||
await sendMessage(`${user.displayName} gave ${amount} qweribuck${amount === 1 ? '' : 's'} to ${target.displayName}. They now have ${newamount} qweribuck${newamount === 1 ? '' : 's'}`, msg.messageId);
|
||||
} else {
|
||||
// TODO: Rewrite this section
|
||||
await sendMessage(`Failed to give ${target.displayName} ${amount} qbuck${(amount === 1 ? '' : 's')}`, msg.messageId);
|
||||
logger.err(`WARNING: Qweribucks donation failed: target success: ${data[0] !== false}, donator success: ${data[1] !== false}`);
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
user.clearLock(),
|
||||
target.clearLock()
|
||||
]);
|
||||
});
|
||||
14
src/commands/enablecheer.ts
Normal file
14
src/commands/enablecheer.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { redis } from "bun";
|
||||
import { Command, sendMessage } from ".";
|
||||
import parseCommandArgs from "../lib/parseCommandArgs";
|
||||
import { namedcheers } from "../cheers";
|
||||
|
||||
export default new Command('enablecheer', ['enablecheer'], 'admin', async msg => {
|
||||
const args = parseCommandArgs(msg.messageText);
|
||||
if (!args[0]) { await sendMessage('Please specify a cheer to enable', msg.messageId); return; };
|
||||
const selection = namedcheers.get(args[0].toLowerCase());
|
||||
if (!selection) { await sendMessage(`There is no ${args[0]} cheer`, msg.messageId); return; };
|
||||
const result = await redis.srem('disabledcheers', selection.name);
|
||||
if (result === 0) { await sendMessage(`The ${selection.name} cheer isn't disabled`, msg.messageId); return; };
|
||||
await sendMessage(`Successfully enabled the ${selection.name} cheer`, msg.messageId);
|
||||
}, false);
|
||||
13
src/commands/enablecommand.ts
Normal file
13
src/commands/enablecommand.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { redis } from "bun";
|
||||
import commands, { Command, sendMessage } from ".";
|
||||
import parseCommandArgs from "../lib/parseCommandArgs";
|
||||
|
||||
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());
|
||||
if (!selection) { await sendMessage(`There is no ${args[0]} command`, msg.messageId); return; };
|
||||
const result = await redis.srem('disabledcommands', selection.name);
|
||||
if (result === 0) { await sendMessage(`The ${selection.name} command isn't disabled`, msg.messageId); return; };
|
||||
await sendMessage(`Successfully enabled the ${selection.name} command`, msg.messageId);
|
||||
}, false);
|
||||
13
src/commands/getadmins.ts
Normal file
13
src/commands/getadmins.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Command, sendMessage } from ".";
|
||||
import { getAdmins } from "../lib/admins";
|
||||
import { User } from "../user";
|
||||
|
||||
export default new Command('getadmins', ['getadmins'], 'chatter', async msg => {
|
||||
const admins = await getAdmins()
|
||||
const adminnames: string[] = [];
|
||||
for (const id of admins) {
|
||||
const admin = await User.initUserId(id);
|
||||
adminnames.push(admin?.displayName!);
|
||||
};
|
||||
await sendMessage(`Current admins: ${adminnames.join(', ')}`, msg.messageId);
|
||||
}, false);
|
||||
12
src/commands/getbalance.ts
Normal file
12
src/commands/getbalance.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Command, sendMessage } from ".";
|
||||
import { getUserRecord } from "../db/dbUser";
|
||||
import parseCommandArgs from "../lib/parseCommandArgs";
|
||||
import { User } from "../user";
|
||||
|
||||
export default new Command('getbalance', ['getbalance', 'balance', 'qbucks', 'qweribucks', 'wallet', 'getwallet'], 'chatter', async (msg, user) => {
|
||||
const args = parseCommandArgs(msg.messageText);
|
||||
const target = args[0] ? await User.initUsername(args[0].toLowerCase()) : user;
|
||||
if (!target) { await sendMessage(`Chatter ${args[0]} doesn't exist!`, msg.messageId); return; };
|
||||
const data = await getUserRecord(target);
|
||||
await sendMessage(`${target.displayName} has ${data.balance} qbuck${data.balance === 1 ? '' : 's'}`, msg.messageId);
|
||||
});
|
||||
33
src/commands/getcheers.ts
Normal file
33
src/commands/getcheers.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { redis } from "bun";
|
||||
import { Command, sendMessage } from ".";
|
||||
import parseCommandArgs from "../lib/parseCommandArgs";
|
||||
import { namedcheers } from "../cheers";
|
||||
|
||||
export default new Command('getcheers', ['getcheers', 'getcheer'], 'chatter', async msg => {
|
||||
const args = parseCommandArgs(msg.messageText);
|
||||
if (!args[0]) { await sendMessage(`A full list of cheers can be found here: https://github.com/qwerinope/qweribot#cheers`, msg.messageId); return; };
|
||||
const disabledcheers = await redis.smembers('disabledcheers');
|
||||
const cheerstrings: string[] = [];
|
||||
|
||||
if (args[0].toLowerCase() === "enabled") {
|
||||
for (const [name, cheer] of Array.from(namedcheers.entries())) {
|
||||
if (disabledcheers.includes(name)) continue;
|
||||
cheerstrings.push(`${cheer.amount}: ${name}`);
|
||||
};
|
||||
|
||||
const last = cheerstrings.pop();
|
||||
if (!last) { await sendMessage("No enabled cheers", msg.messageId); return; };
|
||||
await sendMessage(cheerstrings.length === 0 ? last : cheerstrings.join(', ') + " and " + last, msg.messageId);
|
||||
|
||||
} else if (args[0].toLowerCase() === "disabled") {
|
||||
for (const [name, cheer] of Array.from(namedcheers.entries())) {
|
||||
if (!disabledcheers.includes(name)) continue;
|
||||
cheerstrings.push(`${cheer.amount}: ${name}`);
|
||||
};
|
||||
|
||||
const last = cheerstrings.pop();
|
||||
if (!last) { await sendMessage("No disabled cheers", msg.messageId); return; };
|
||||
await sendMessage(cheerstrings.length === 0 ? last : cheerstrings.join(', ') + " and " + last, msg.messageId);
|
||||
|
||||
} else await sendMessage('Please specify if you want the enabled or disabled cheers', msg.messageId);
|
||||
}, false);
|
||||
23
src/commands/getcommands.ts
Normal file
23
src/commands/getcommands.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { redis } from "bun";
|
||||
import { basecommands, Command, sendMessage } from ".";
|
||||
import parseCommandArgs from "../lib/parseCommandArgs";
|
||||
|
||||
export default new Command('getcommands', ['getcommands', 'getc'], 'chatter', async msg => {
|
||||
const args = parseCommandArgs(msg.messageText);
|
||||
if (!args[0]) { await sendMessage(`A full list of commands can be found here: https://github.com/qwerinope/qweribot#commands-1`, 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.usertype !== 'chatter') continue; // Admin only commands should be somewhat hidden
|
||||
if (disabledcommands.includes(name)) continue;
|
||||
commandnames.push(name);
|
||||
};
|
||||
if (commandnames.length === 0) await sendMessage('No commands besides non-disableable commands are enabled', msg.messageId);
|
||||
else await sendMessage(`Currently enabled commands: ${commandnames.join(', ')}`, msg.messageId);
|
||||
} else if (args[0].toLowerCase() === 'disabled') {
|
||||
if (disabledcommands.length === 0) await sendMessage('No commands are disabled', msg.messageId);
|
||||
else await sendMessage(`Currently disabled commands: ${disabledcommands.join(', ')}`);
|
||||
}
|
||||
else await sendMessage('Please specify if you want the enabled or disabled commands', msg.messageId);
|
||||
}, false);
|
||||
26
src/commands/getinventory.ts
Normal file
26
src/commands/getinventory.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Command, sendMessage } from ".";
|
||||
import { getUserRecord } from "../db/dbUser";
|
||||
import parseCommandArgs from "../lib/parseCommandArgs";
|
||||
import { User } from "../user";
|
||||
import items from "../items";
|
||||
|
||||
export default new Command('inventory', ['inv', 'inventory'], 'chatter', async (msg, user) => {
|
||||
const args = parseCommandArgs(msg.messageText);
|
||||
let target: User = user;
|
||||
if (args[0]) {
|
||||
const obj = await User.initUsername(args[0].toLowerCase());
|
||||
if (!obj) { await sendMessage(`User ${args[0]} doesn't exist`, msg.messageId); return; };
|
||||
target = obj;
|
||||
};
|
||||
|
||||
const data = await getUserRecord(target);
|
||||
const messagedata: string[] = [];
|
||||
for (const [key, amount] of Object.entries(data.inventory)) {
|
||||
if (amount === 0) continue;
|
||||
const itemselection = items.get(key);
|
||||
messagedata.push(`${itemselection?.prettyName}${amount === 1 ? '' : itemselection?.plural}: ${amount}`);
|
||||
};
|
||||
|
||||
if (messagedata.length === 0) { await sendMessage(`${target.displayName} has no items`, msg.messageId); return; };
|
||||
await sendMessage(`Inventory of ${target.displayName}: ${messagedata.join(', ')}`, msg.messageId);
|
||||
});
|
||||
16
src/commands/gettimeout.ts
Normal file
16
src/commands/gettimeout.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Command, sendMessage } from ".";
|
||||
import { streamerApi, streamerId } from "..";
|
||||
import { buildTimeString } from "../lib/dateManager";
|
||||
import parseCommandArgs from "../lib/parseCommandArgs";
|
||||
import { User } from "../user";
|
||||
|
||||
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; };
|
||||
await sendMessage(`${target.displayName} is permanently banned`, msg.messageId);
|
||||
});
|
||||
49
src/commands/giveitem.ts
Normal file
49
src/commands/giveitem.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Command, sendMessage } from ".";
|
||||
import type { userRecord } from "../db/connection";
|
||||
import { getUserRecord } from "../db/dbUser";
|
||||
import items, { changeItemCount } from "../items";
|
||||
import parseCommandArgs from "../lib/parseCommandArgs";
|
||||
import { User } from "../user";
|
||||
import logger from "../lib/logger";
|
||||
|
||||
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());
|
||||
if (!target) { await sendMessage(`Chatter ${args[0]} doesn't exist`, msg.messageId); return; };
|
||||
if (target.username === user.username) { await sendMessage("You can't give yourself items", msg.messageId); return; };
|
||||
const targetRecord = await getUserRecord(target);
|
||||
if (!args[1]) { await sendMessage('Please specify an item to give', msg.messageId); return; };
|
||||
const item = items.get(args[1].toLowerCase());
|
||||
if (!item) { await sendMessage(`Item ${args[1]} doesn't exist`, msg.messageId); return; };
|
||||
if (!args[2]) { await sendMessage('Please specify the amount of the item you want to give', msg.messageId); return; };
|
||||
const amount = Number(args[2]);
|
||||
if (isNaN(amount) || amount < 0) { await sendMessage(`${args[2]} is not a valid amount`); return; };
|
||||
|
||||
const userRecord = await getUserRecord(user);
|
||||
if (userRecord.inventory[item.name]! < amount) { await sendMessage(`You can't give items you don't have!`, msg.messageId); return; };
|
||||
|
||||
if (await user.itemLock() || await target.itemLock()) { await sendMessage('Cannot give item', msg.messageId); return; };
|
||||
|
||||
await Promise.all([
|
||||
user.setLock(),
|
||||
target.setLock()
|
||||
]);
|
||||
|
||||
const data = await Promise.all([
|
||||
await changeItemCount(target, targetRecord, item.name, amount),
|
||||
await changeItemCount(user, userRecord, item.name, -amount)
|
||||
]);
|
||||
|
||||
if (!data.includes(false)) {
|
||||
const tempdata = data[0] as userRecord;
|
||||
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);
|
||||
logger.warn(`WARNING: Item donation failed: target success: ${data[0] !== false}, donator success: ${data[1] !== false}`);
|
||||
};
|
||||
await user.clearLock();
|
||||
await target.clearLock();
|
||||
});
|
||||
50
src/commands/index.ts
Normal file
50
src/commands/index.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { EventSubChannelChatMessageEvent } from "@twurple/eventsub-base";
|
||||
import { User } from "../user";
|
||||
|
||||
export type userType = 'chatter' | 'admin' | 'streamer';
|
||||
|
||||
/** The Command class represents a command */
|
||||
export class Command {
|
||||
public readonly name: string;
|
||||
public readonly aliases: string[];
|
||||
public readonly usertype: userType;
|
||||
public readonly disableable: boolean;
|
||||
public readonly execute: (message: EventSubChannelChatMessageEvent, sender: User) => Promise<void>;
|
||||
constructor(name: string, aliases: string[], usertype: userType, execution: (message: EventSubChannelChatMessageEvent, sender: User) => Promise<void>, disableable?: boolean) {
|
||||
this.name = name.toLowerCase();
|
||||
this.aliases = aliases;
|
||||
this.usertype = usertype;
|
||||
this.execute = execution;
|
||||
this.disableable = disableable ?? true;
|
||||
};
|
||||
};
|
||||
|
||||
import { readdir } from 'node:fs/promises';
|
||||
const commands = new Map<string, Command>; // This map has all command/item aliases mapped to commands/items (many-to-one)
|
||||
const basecommands = new Map<string, Command>; // This map has all command names mapped to commands (one-to-one) (no items)
|
||||
|
||||
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);
|
||||
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
|
||||
};
|
||||
};
|
||||
|
||||
import items from "../items";
|
||||
for (const [name, item] of Array.from(items)) {
|
||||
commands.set(name, item); // As Item is basically just Command but with more parameters, this should work fine
|
||||
};
|
||||
|
||||
export default commands;
|
||||
export { basecommands };
|
||||
|
||||
import { singleUserMode, chatterApi, chatterId, streamerId } from "..";
|
||||
|
||||
/** Helper function to send a message to the stream */
|
||||
export const sendMessage = async (message: string, replyParentMessageId?: string) => {
|
||||
singleUserMode ? await chatterApi.chat.sendChatMessage(streamerId, message, { replyParentMessageId }) : chatterApi.asUser(chatterId, async newapi => newapi.chat.sendChatMessage(streamerId, message, { replyParentMessageId }));
|
||||
};
|
||||
11
src/commands/iteminfo.ts
Normal file
11
src/commands/iteminfo.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Command, sendMessage } from ".";
|
||||
import items from "../items";
|
||||
import parseCommandArgs from "../lib/parseCommandArgs";
|
||||
|
||||
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());
|
||||
if (!selection) { await sendMessage(`'${messagequery}' is not an item`, msg.messageId); return; };
|
||||
await sendMessage(`Name: ${selection.prettyName}, Description: ${selection.description}, Aliases: ${selection.aliases.join(', ')}`, msg.messageId);
|
||||
});
|
||||
13
src/commands/itemlock.ts
Normal file
13
src/commands/itemlock.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Command, sendMessage } from ".";
|
||||
import parseCommandArgs from "../lib/parseCommandArgs";
|
||||
import { User } from "../user";
|
||||
|
||||
export default new Command('itemlock', ['itemlock'], 'admin', async msg => {
|
||||
const args = parseCommandArgs(msg.messageText);
|
||||
if (!args[0]) { await sendMessage('Please specify a chatter to toggle the lock for', msg.messageId); return; };
|
||||
const target = await User.initUsername(args[0].toLowerCase());
|
||||
if (!target) { await sendMessage('Targeted user does not exist', msg.messageId); return; };
|
||||
const status = await target.itemLock();
|
||||
status ? await target.clearLock() : await target.setLock();
|
||||
await sendMessage(`Successfully ${status ? 'cleared' : 'set'} the item lock on ${target.displayName}`, msg.messageId);
|
||||
}, false);
|
||||
6
src/commands/ping.ts
Normal file
6
src/commands/ping.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Command, sendMessage } from ".";
|
||||
|
||||
// This command is purely for testing
|
||||
export default new Command('ping', ['ping'], 'chatter', async msg => {
|
||||
await sendMessage('pong!', msg.messageId);
|
||||
});
|
||||
16
src/commands/removeadmin.ts
Normal file
16
src/commands/removeadmin.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Command, sendMessage } from ".";
|
||||
import { streamerUsers } from "..";
|
||||
import { removeAdmin } from "../lib/admins";
|
||||
import parseCommandArgs from "../lib/parseCommandArgs";
|
||||
import { User } from "../user";
|
||||
|
||||
export default new Command('removeadmin', ['removeadmin'], '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 admin ${target.displayName} as they are managed by the bot program`, msg.messageId); return; };
|
||||
const data = await removeAdmin(target.id);
|
||||
if (data === 1) await sendMessage(`${target.displayName} is no longer an admin`, msg.messageId);
|
||||
else await sendMessage(`${target.displayName} isn't an admin`, msg.messageId);
|
||||
}, false);
|
||||
15
src/commands/seiso.ts
Normal file
15
src/commands/seiso.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Command, sendMessage } from ".";
|
||||
import { timeout } from "../lib/timeout";
|
||||
|
||||
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 Promise.all([
|
||||
sendMessage(`${rand}% seiso RIPBOZO`),
|
||||
timeout(user, 'TOO YABAI!', 60)
|
||||
]);
|
||||
});
|
||||
11
src/commands/testcheer.ts
Normal file
11
src/commands/testcheer.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Command, sendMessage } from ".";
|
||||
import { handleCheer } from "../events/message";
|
||||
import parseCommandArgs from "../lib/parseCommandArgs";
|
||||
|
||||
export default new Command('testcheer', ['testcheer'], 'streamer', async (msg, user) => {
|
||||
const args = parseCommandArgs(msg.messageText);
|
||||
if (!args[0]) { await sendMessage('Please specify the amount of fake bits you want to send', msg.messageId); return; };
|
||||
if (isNaN(Number(args[0]))) { await sendMessage(`${args[0]} is not a valid amout of bits`); return; };
|
||||
const bits = Number(args.shift()); // we shift it so the amount of bits isn't part of the handleCheer message, we already know that args[0] can be parsed as a number so this is fine.
|
||||
await handleCheer(msg, bits, user);
|
||||
}, false);
|
||||
12
src/commands/useitem.ts
Normal file
12
src/commands/useitem.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { redis } from "bun";
|
||||
import { Command, sendMessage } from ".";
|
||||
import items from "../items";
|
||||
|
||||
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);
|
||||
}, false);
|
||||
8
src/commands/vulnchatters.ts
Normal file
8
src/commands/vulnchatters.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
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 one = data.length === 1;
|
||||
await sendMessage(`There ${one ? 'is' : 'are'} ${data.length} vulnerable chatter${one ? '' : 's'}`, msg.messageId);
|
||||
});
|
||||
15
src/commands/yabai.ts
Normal file
15
src/commands/yabai.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
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'], '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)
|
||||
]);
|
||||
});
|
||||
44
src/db/connection.ts
Normal file
44
src/db/connection.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { AccessToken } from "@twurple/auth";
|
||||
import PocketBase, { RecordService } from "pocketbase";
|
||||
import type { inventory } from "../items";
|
||||
import logger from "../lib/logger";
|
||||
|
||||
const pocketbaseurl = process.env.POCKETBASE_URL ?? "localhost:8090";
|
||||
if (pocketbaseurl === "") { logger.enverr("POCKETBASE_URL"); process.exit(1); };
|
||||
|
||||
export type authRecord = {
|
||||
id: string;
|
||||
accesstoken: AccessToken;
|
||||
};
|
||||
|
||||
export type userRecord = {
|
||||
id: string;
|
||||
username: string; // Don't use this, Use User.username or User.displayName. This is just to make the pocketbase data easier to read.
|
||||
balance: number;
|
||||
inventory: inventory;
|
||||
lastlootbox: string;
|
||||
};
|
||||
|
||||
export type usedItemRecord = {
|
||||
id?: string;
|
||||
user: string;
|
||||
item: string;
|
||||
created: string;
|
||||
};
|
||||
|
||||
export type timeoutRecord = {
|
||||
id?: string;
|
||||
user: string;
|
||||
target: string;
|
||||
item: string;
|
||||
created: string;
|
||||
};
|
||||
|
||||
interface TypedPocketBase extends PocketBase {
|
||||
collection(idOrName: 'auth'): RecordService<authRecord>;
|
||||
collection(idOrName: 'users'): RecordService<userRecord>;
|
||||
collection(idOrName: 'usedItems'): RecordService<usedItemRecord>;
|
||||
collection(idOrName: 'timeouts'): RecordService<timeoutRecord>;
|
||||
};
|
||||
|
||||
export default new PocketBase(pocketbaseurl).autoCancellation(false) as TypedPocketBase;
|
||||
38
src/db/dbAuth.ts
Normal file
38
src/db/dbAuth.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { AccessToken } from "@twurple/auth";
|
||||
import pocketbase, { type authRecord } from "./connection";
|
||||
const pb = pocketbase.collection('auth');
|
||||
|
||||
export async function createAuthRecord(token: AccessToken, userId: string) {
|
||||
try {
|
||||
const data: authRecord = {
|
||||
accesstoken: token,
|
||||
id: userId
|
||||
};
|
||||
await pb.create(data);
|
||||
} catch (err) { };
|
||||
};
|
||||
|
||||
export async function getAuthRecord(userId: string, requiredIntents: string[]) {
|
||||
try {
|
||||
const data = await pb.getOne(userId);
|
||||
if (!requiredIntents.every(intent => data.accesstoken.scope.includes(intent))) return undefined;
|
||||
return { accesstoken: data.accesstoken };
|
||||
} catch (err) {
|
||||
return undefined;
|
||||
};
|
||||
};
|
||||
|
||||
export async function updateAuthRecord(userId: string, newtoken: AccessToken) {
|
||||
try {
|
||||
const newrecord = {
|
||||
accesstoken: newtoken,
|
||||
};
|
||||
await pb.update(userId, newrecord);
|
||||
} catch (err) { };
|
||||
};
|
||||
|
||||
export async function deleteAuthRecord(userId: string): Promise<void> {
|
||||
try {
|
||||
await pb.delete(userId);
|
||||
} catch (err) { };
|
||||
};
|
||||
13
src/db/dbTimeouts.ts
Normal file
13
src/db/dbTimeouts.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import pocketbase from "./connection";
|
||||
import { User } from "../user";
|
||||
import logger from "../lib/logger";
|
||||
const pb = pocketbase.collection('timeouts');
|
||||
|
||||
export async function createTimeoutRecord(user: User, target: User, item: string): Promise<void> {
|
||||
try {
|
||||
await pb.create({ user: user.id, target: target.id, item });
|
||||
} catch (err) {
|
||||
logger.err(`Failed to create timeout record in database: user: ${user.id}, target: ${target.id}, item: ${item}`);
|
||||
logger.err(err as string);
|
||||
};
|
||||
};
|
||||
13
src/db/dbUsedItems.ts
Normal file
13
src/db/dbUsedItems.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import pocketbase from "./connection";
|
||||
import { User } from "../user";
|
||||
import logger from "../lib/logger";
|
||||
const pb = pocketbase.collection('usedItems');
|
||||
|
||||
export async function createUsedItemRecord(user: User, item: string): Promise<void> {
|
||||
try {
|
||||
await pb.create({ user: user.id, item });
|
||||
} catch (err) {
|
||||
logger.err(`Failed to create usedItem record in database: user: ${user.id}, item: ${item}`);
|
||||
logger.err(err as string);
|
||||
};
|
||||
};
|
||||
46
src/db/dbUser.ts
Normal file
46
src/db/dbUser.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import pocketbase, { type userRecord } from "./connection";
|
||||
import { emptyInventory, itemarray } from "../items";
|
||||
import type { User } from "../user";
|
||||
import logger from "../lib/logger";
|
||||
|
||||
const pb = pocketbase.collection('users');
|
||||
|
||||
/** Use this function to both ensure existance and to retreive data */
|
||||
export async function getUserRecord(user: User): Promise<userRecord> {
|
||||
try {
|
||||
const data = await pb.getOne(user.id);
|
||||
|
||||
if (Object.keys(data.inventory).sort().toString() !== itemarray.sort().toString()) { // If the items in the user inventory are missing an item.
|
||||
itemarray.forEach(key => {
|
||||
if (!(key in data.inventory)) data.inventory[key] = 0;
|
||||
});
|
||||
};
|
||||
|
||||
return data;
|
||||
} catch (err) {
|
||||
// This gets triggered if the user doesn't exist in the database
|
||||
return await createUserRecord(user);
|
||||
};
|
||||
};
|
||||
|
||||
async function createUserRecord(user: User): Promise<userRecord> {
|
||||
const data = await pb.create({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
balance: 0,
|
||||
inventory: emptyInventory,
|
||||
lastlootbox: new Date(0).toISOString()
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export async function updateUserRecord(user: User, newData: userRecord): Promise<boolean> {
|
||||
try {
|
||||
await pb.update(user.id, newData);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.err(err as string);
|
||||
return false;
|
||||
};
|
||||
};
|
||||
6
src/events/deleteMessage.ts
Normal file
6
src/events/deleteMessage.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { eventSub, streamerId } from "..";
|
||||
import { deleteMessageFromChatWidget } from "../chatwidget/message";
|
||||
|
||||
eventSub.onChannelChatMessageDelete(streamerId, streamerId, async msg => {
|
||||
deleteMessageFromChatWidget(msg);
|
||||
});
|
||||
67
src/events/index.ts
Normal file
67
src/events/index.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import kleur from "kleur";
|
||||
import { eventSub, streamerApi, streamerId } from "..";
|
||||
import logger from "../lib/logger";
|
||||
|
||||
eventSub.onRevoke(event => {
|
||||
logger.ok(`Successfully revoked EventSub subscription: ${kleur.underline(event.id)}`);
|
||||
});
|
||||
|
||||
eventSub.onSubscriptionCreateSuccess(event => {
|
||||
logger.ok(`Successfully created EventSub subscription: ${kleur.underline(event.id)}`);
|
||||
deleteDuplicateSubscriptions.refresh();
|
||||
});
|
||||
|
||||
eventSub.onSubscriptionCreateFailure(event => {
|
||||
logger.err(`Failed to create EventSub subscription: ${kleur.underline(event.id)}`);
|
||||
});
|
||||
|
||||
eventSub.onSubscriptionDeleteSuccess(event => {
|
||||
logger.ok(`Successfully deleted EventSub subscription: ${kleur.underline(event.id)}`);
|
||||
});
|
||||
|
||||
eventSub.onSubscriptionDeleteFailure(event => {
|
||||
logger.err(`Failed to delete EventSub subscription: ${kleur.underline(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));
|
||||
};
|
||||
|
||||
eventSub.start();
|
||||
|
||||
import { getAuthRecord } from "../db/dbAuth";
|
||||
import { StaticAuthProvider } from "@twurple/auth";
|
||||
import { ApiClient, HelixEventSubSubscription } from "@twurple/api";
|
||||
|
||||
const deleteDuplicateSubscriptions = setTimeout(async () => {
|
||||
logger.info('Deleting all double subscriptions');
|
||||
const tokendata = await streamerApi.getTokenInfo();
|
||||
const authdata = await getAuthRecord(streamerId, []);
|
||||
const tempauth = new StaticAuthProvider(tokendata.clientId, authdata?.accesstoken.accessToken!);
|
||||
const tempapi: ApiClient = new ApiClient({ authProvider: tempauth });
|
||||
logger.info('Created the temporary API client');
|
||||
const subs = await tempapi.eventSub.getSubscriptionsForStatus('enabled');
|
||||
const seen = new Map();
|
||||
const duplicates: HelixEventSubSubscription[] = [];
|
||||
|
||||
for (const sub of subs.data) {
|
||||
if (seen.has(sub.type)) {
|
||||
if (!duplicates.some(o => o.type === sub.type)) {
|
||||
duplicates.push(seen.get(sub.type));
|
||||
};
|
||||
} else {
|
||||
seen.set(sub.type, sub);
|
||||
};
|
||||
};
|
||||
|
||||
for (const sub of duplicates) {
|
||||
await tempapi.eventSub.deleteSubscription(sub.id);
|
||||
logger.ok(`Deleted sub: id: ${sub.id}, type: ${sub.type}`);
|
||||
};
|
||||
if (duplicates.length === 0) logger.ok('No duplicate subscriptions found');
|
||||
else logger.ok('Deleted all duplicate EventSub subscriptions');
|
||||
}, 5000);
|
||||
72
src/events/message.ts
Normal file
72
src/events/message.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { EventSubChannelChatMessageEvent } from "@twurple/eventsub-base"
|
||||
import { chatterId, streamerId, eventSub, commandPrefix, singleUserMode, streamerUsers } from "..";
|
||||
import { User } from "../user";
|
||||
import commands, { sendMessage } from "../commands";
|
||||
import { redis } from "bun";
|
||||
import { isAdmin } from "../lib/admins";
|
||||
import cheers from "../cheers";
|
||||
import logger from "../lib/logger";
|
||||
import { addMessageToChatWidget } from "../chatwidget/message";
|
||||
|
||||
logger.info(`Loaded the following commands: ${commands.keys().toArray().join(', ')}`);
|
||||
|
||||
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);
|
||||
|
||||
// Get user from cache or place user in cache
|
||||
// Given the fact that this is the user that chats, this user object always exists and cannot be null
|
||||
//
|
||||
// One of the flaws with the user object is solved by creating the object with the name.
|
||||
// This way, if a user changes their name, the original name stays in the cache for at least 1 hour (extendable by using that name as target for item)
|
||||
// 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 (!msg.isCheer && !msg.isRedemption) await handleChatMessage(msg, user!)
|
||||
else if (msg.isCheer && !msg.isRedemption) await handleCheer(msg, msg.bits, user!);
|
||||
};
|
||||
|
||||
async function handleChatMessage(msg: EventSubChannelChatMessageEvent, user: User) {
|
||||
// Parse commands:
|
||||
if (msg.messageText.startsWith(commandPrefix)) {
|
||||
const commandSelection = msg.messageText.slice(commandPrefix.length).split(' ')[0]!;
|
||||
const selected = commands.get(commandSelection.toLowerCase());
|
||||
if (!selected) return;
|
||||
if (await redis.sismember('disabledcommands', selected.name)) return;
|
||||
|
||||
switch (selected.usertype) {
|
||||
case "admin":
|
||||
if (!await isAdmin(user.id)) return;
|
||||
break;
|
||||
case "streamer":
|
||||
if (!streamerUsers.includes(msg.chatterId)) return;
|
||||
break;
|
||||
};
|
||||
|
||||
try { await selected.execute(msg, user); }
|
||||
catch (err) {
|
||||
logger.err(err as string);
|
||||
await sendMessage('ERROR: Something went wrong', msg.messageId);
|
||||
await user.clearLock();
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export async function handleCheer(msg: EventSubChannelChatMessageEvent, bits: number, user: User) {
|
||||
const selection = cheers.get(bits);
|
||||
if (!selection) return;
|
||||
|
||||
if (await redis.sismember('disabledcheers', selection.name)) { await sendMessage(`The ${selection.name} cheer is disabled`); return; };
|
||||
try {
|
||||
selection.execute(msg, user);
|
||||
} catch (err) {
|
||||
logger.err(err as string);
|
||||
};
|
||||
};
|
||||
35
src/index.ts
Normal file
35
src/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { createAuthProvider } from "./auth";
|
||||
import { ApiClient } from "@twurple/api";
|
||||
import { EventSubWsListener } from "@twurple/eventsub-ws";
|
||||
import { addAdmin } from "./lib/admins";
|
||||
import logger from "./lib/logger";
|
||||
|
||||
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"];
|
||||
|
||||
export const singleUserMode = process.env.CHATTER_IS_STREAMER === 'true';
|
||||
export const chatterId = process.env.CHATTER_ID ?? "";
|
||||
if (chatterId === "") { logger.enverr('CHATTER_ID'); process.exit(1); };
|
||||
export const streamerId = process.env.STREAMER_ID ?? "";
|
||||
if (streamerId === "") { logger.enverr('STREAMER_ID'); process.exit(1); };
|
||||
|
||||
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 });
|
||||
|
||||
/** 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 ?? "!";
|
||||
|
||||
export const streamerUsers = [chatterId, streamerId];
|
||||
streamerUsers.forEach(async id => await addAdmin(id));
|
||||
|
||||
await import("./events");
|
||||
|
||||
await import("./chatwidget");
|
||||
51
src/items/blaster.ts
Normal file
51
src/items/blaster.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { changeItemCount, Item } from ".";
|
||||
import { sendMessage } from "../commands";
|
||||
import { createTimeoutRecord } from "../db/dbTimeouts";
|
||||
import { createUsedItemRecord } from "../db/dbUsedItems";
|
||||
import { getUserRecord } from "../db/dbUser";
|
||||
import parseCommandArgs from "../lib/parseCommandArgs";
|
||||
import { timeout } from "../lib/timeout";
|
||||
import { User } from "../user";
|
||||
|
||||
const ITEMNAME = 'blaster';
|
||||
|
||||
export default new Item(ITEMNAME, 'Blaster', 's',
|
||||
'Times a specific person out for 60 seconds',
|
||||
['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; };
|
||||
const messagequery = parseCommandArgs(msg.messageText);
|
||||
if (!messagequery[0]) { await sendMessage('Please specify a target'); return; };
|
||||
const target = await User.initUsername(messagequery[0].toLowerCase());
|
||||
if (!target) { await sendMessage(`${messagequery[0]} doesn't exist`); return; };
|
||||
await getUserRecord(target); // make sure the user record exist in the database
|
||||
|
||||
if (await user.itemLock()) { await sendMessage('Cannot use an item right now', msg.messageId); return; };
|
||||
await user.setLock();
|
||||
const result = await timeout(target, `You got blasted by ${user.displayName}!`, 60);
|
||||
if (result.status) await Promise.all([
|
||||
sendMessage(`GOTTEM ${target.displayName} got BLASTED by ${user.displayName} GOTTEM`),
|
||||
changeItemCount(user, userObj, ITEMNAME),
|
||||
createTimeoutRecord(user, target, ITEMNAME),
|
||||
createUsedItemRecord(user, ITEMNAME)
|
||||
]);
|
||||
else {
|
||||
switch (result.reason) {
|
||||
case "banned":
|
||||
await sendMessage(`${target.displayName} is already timed out/banned`, msg.messageId);
|
||||
break;
|
||||
case "illegal":
|
||||
await Promise.all([
|
||||
sendMessage(`${user.displayName} Nou Nou Nou`),
|
||||
timeout(user, 'nah', 60)
|
||||
]);
|
||||
break;
|
||||
case "unknown":
|
||||
await sendMessage('Something went wrong...', msg.messageId);
|
||||
break;
|
||||
};
|
||||
};
|
||||
await user.clearLock();
|
||||
}
|
||||
);
|
||||
37
src/items/grenade.ts
Normal file
37
src/items/grenade.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { redis } from "bun";
|
||||
import { sendMessage } from "../commands";
|
||||
import { timeout } from "../lib/timeout";
|
||||
import { changeItemCount, Item } from ".";
|
||||
import { User } from "../user";
|
||||
import { getUserRecord } from "../db/dbUser";
|
||||
import { createTimeoutRecord } from "../db/dbTimeouts";
|
||||
import { createUsedItemRecord } from "../db/dbUsedItems";
|
||||
|
||||
const ITEMNAME = 'grenade';
|
||||
|
||||
export default new Item(ITEMNAME, 'Grenade', 's',
|
||||
'Give a random chatter a 60s timeout',
|
||||
['grenade'],
|
||||
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:*');
|
||||
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]!);
|
||||
|
||||
await getUserRecord(target!); // make sure the user record exist in the database
|
||||
|
||||
if (await user.itemLock()) { await sendMessage('Cannot use an item right now', msg.messageId); return; };
|
||||
await user.setLock();
|
||||
await Promise.all([
|
||||
timeout(target!, `You got hit by ${user.displayName}'s grenade!`, 60),
|
||||
redis.del(selection),
|
||||
sendMessage(`wybuh ${target?.displayName} got hit by ${user.displayName}'s grenade wybuh`),
|
||||
changeItemCount(user, userObj, ITEMNAME),
|
||||
createTimeoutRecord(user, target!, ITEMNAME),
|
||||
createUsedItemRecord(user, ITEMNAME)
|
||||
]);
|
||||
await user.clearLock();
|
||||
}
|
||||
);
|
||||
63
src/items/index.ts
Normal file
63
src/items/index.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { EventSubChannelChatMessageEvent } from "@twurple/eventsub-base";
|
||||
import { User } from "../user";
|
||||
import { type userType } from "../commands";
|
||||
|
||||
export class Item {
|
||||
public readonly name: string;
|
||||
public readonly prettyName: string;
|
||||
public readonly plural: string;
|
||||
public readonly description: string;
|
||||
public readonly aliases: 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 execution - code that gets executed when item gets used */
|
||||
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.usertype = 'chatter'; // Items are usable by everyone
|
||||
this.execute = execution;
|
||||
this.disableable = true;
|
||||
};
|
||||
};
|
||||
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import type { userRecord } from "../db/connection";
|
||||
import { updateUserRecord } from "../db/dbUser";
|
||||
const items = new Map<string, Item>;
|
||||
const emptyInventory: inventory = {};
|
||||
const itemarray: 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 item: Item = await import(import.meta.dir + '/' + file.slice(0, -3)).then(a => a.default);
|
||||
emptyInventory[item.name] = 0;
|
||||
itemarray.push(item.name);
|
||||
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 { emptyInventory, itemarray };
|
||||
export type inventory = {
|
||||
[key: string]: number;
|
||||
};
|
||||
|
||||
export async function changeItemCount(user: User, userRecord: userRecord, itemname: string, amount = -1): Promise<false | userRecord> {
|
||||
userRecord.inventory[itemname] = userRecord.inventory[itemname]! += amount;
|
||||
if (userRecord.inventory[itemname] < 0) return false;
|
||||
await updateUserRecord(user, userRecord);
|
||||
return userRecord;
|
||||
};
|
||||
51
src/items/silverbullet.ts
Normal file
51
src/items/silverbullet.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { changeItemCount, Item } from ".";
|
||||
import { sendMessage } from "../commands";
|
||||
import { createTimeoutRecord } from "../db/dbTimeouts";
|
||||
import { createUsedItemRecord } from "../db/dbUsedItems";
|
||||
import { getUserRecord } from "../db/dbUser";
|
||||
import parseCommandArgs from "../lib/parseCommandArgs";
|
||||
import { timeout } from "../lib/timeout";
|
||||
import { User } from "../user";
|
||||
|
||||
const ITEMNAME = 'silverbullet';
|
||||
|
||||
export default new Item(ITEMNAME, 'Silver bullet', 's',
|
||||
'Times a specific person out for 24 hours',
|
||||
['execute', 'silverbullet'],
|
||||
async (msg, user) => {
|
||||
const userObj = await getUserRecord(user);
|
||||
if (userObj.inventory[ITEMNAME]! < 1) { await sendMessage(`You don't have any silver bullets!`, msg.messageId); return; };
|
||||
const messagequery = parseCommandArgs(msg.messageText);
|
||||
if (!messagequery[0]) { await sendMessage('Please specify a target'); return; };
|
||||
const target = await User.initUsername(messagequery[0].toLowerCase());
|
||||
if (!target) { await sendMessage(`${messagequery[0]} doesn't exist`); return; };
|
||||
await getUserRecord(target); // make sure the user record exist in the database
|
||||
|
||||
if (await user.itemLock()) { await sendMessage('Cannot use an item right now', msg.messageId); return; };
|
||||
await user.setLock();
|
||||
const result = await timeout(target, `You got blasted by ${user.displayName}!`, 60 * 60 * 24);
|
||||
if (result.status) await Promise.all([
|
||||
sendMessage(`${target.displayName} RIPBOZO RIPBOZO RIPBOZO RIPBOZO RIPBOZO RIPBOZO RIPBOZO`),
|
||||
changeItemCount(user, userObj, ITEMNAME),
|
||||
createTimeoutRecord(user, target, ITEMNAME),
|
||||
createUsedItemRecord(user, ITEMNAME)
|
||||
]);
|
||||
else {
|
||||
switch (result.reason) {
|
||||
case "banned":
|
||||
await sendMessage(`${target.displayName} is already timed out/banned`, msg.messageId);
|
||||
break;
|
||||
case "illegal":
|
||||
await Promise.all([
|
||||
sendMessage(`${user.displayName} Nou Nou Nou`),
|
||||
timeout(user, 'nah', 60)
|
||||
]);
|
||||
break;
|
||||
case "unknown":
|
||||
await sendMessage('Something went wrong...', msg.messageId);
|
||||
break;
|
||||
};
|
||||
};
|
||||
await user.clearLock();
|
||||
}
|
||||
);
|
||||
53
src/items/tnt.ts
Normal file
53
src/items/tnt.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { redis } from "bun";
|
||||
import { sendMessage } from "../commands";
|
||||
import { timeout } from "../lib/timeout";
|
||||
import { changeItemCount, Item } from ".";
|
||||
import { User } from "../user";
|
||||
import { getUserRecord } from "../db/dbUser";
|
||||
import { createTimeoutRecord } from "../db/dbTimeouts";
|
||||
import { createUsedItemRecord } from "../db/dbUsedItems";
|
||||
|
||||
const ITEMNAME = 'tnt';
|
||||
|
||||
export default new Item(ITEMNAME, 'TNT', 's',
|
||||
'Give 5-10 random chatters 60 second timeouts',
|
||||
['tnt'],
|
||||
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:*');
|
||||
if (vulntargets.length === 0) { await sendMessage('No vulnerable chatters to blow up', msg.messageId); return; };
|
||||
const targets = getTNTTargets(vulntargets);
|
||||
|
||||
if (await user.itemLock()) { await sendMessage('Cannot use an item right now', msg.messageId); return; };
|
||||
await user.setLock();
|
||||
|
||||
await Promise.all(targets.map(async targetid => {
|
||||
const target = await User.initUserId(targetid.split(':')[1]!);
|
||||
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),
|
||||
redis.del(targetid),
|
||||
sendMessage(`wybuh ${target?.displayName} got hit by ${user.displayName}'s TNT wybuh`),
|
||||
createTimeoutRecord(user, target!, ITEMNAME),
|
||||
]);
|
||||
}));
|
||||
|
||||
await Promise.all([
|
||||
createUsedItemRecord(user, ITEMNAME),
|
||||
changeItemCount(user, userObj, ITEMNAME)
|
||||
]);
|
||||
await user.clearLock();
|
||||
await sendMessage(`RIPBOZO ${user.displayName} exploded ${targets.length} chatter${targets.length === 1 ? '' : 's'} with their TNT RIPBOZO`);
|
||||
}
|
||||
);
|
||||
|
||||
function getTNTTargets<T>(arr: T[]): T[] {
|
||||
if (arr.length <= 5) {
|
||||
return arr;
|
||||
};
|
||||
|
||||
const count = Math.floor(Math.random() * 6) + 5; // Random number between 5 and 10
|
||||
const shuffled = [...arr].sort(() => 0.5 - Math.random()); // Shuffle array
|
||||
return shuffled.slice(0, Math.min(count, arr.length)); // Return up to `count` entries
|
||||
};
|
||||
14
src/lib/admins.ts
Normal file
14
src/lib/admins.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { redis } from "bun";
|
||||
|
||||
export async function getAdmins() {
|
||||
return await redis.smembers('admins');
|
||||
};
|
||||
export async function isAdmin(userid: string) {
|
||||
return await redis.sismember('admins', userid);
|
||||
};
|
||||
export async function addAdmin(userid: string) {
|
||||
return await redis.sadd('admins', userid);
|
||||
};
|
||||
export async function removeAdmin(userid: string) {
|
||||
return await redis.srem('admins', userid);
|
||||
};
|
||||
10
src/lib/changeBalance.ts
Normal file
10
src/lib/changeBalance.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { updateUserRecord } from "../db/dbUser";
|
||||
import { type userRecord } from "../db/connection";
|
||||
import { User } from "../user";
|
||||
|
||||
export async function changeBalance(user: User, userRecord: userRecord, amount: number): Promise<false | userRecord> {
|
||||
userRecord.balance = userRecord.balance += amount;
|
||||
if (userRecord.balance < 0) return false;
|
||||
await updateUserRecord(user, userRecord);
|
||||
return userRecord;
|
||||
};
|
||||
17
src/lib/dateManager.ts
Normal file
17
src/lib/dateManager.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export function buildTimeString(time1: number, time2: number) {
|
||||
const diff = Math.abs(time1 - time2);
|
||||
const timeobj = {
|
||||
day: Math.floor(diff / (1000 * 60 * 60 * 24)),
|
||||
hour: Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)),
|
||||
minute: Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)),
|
||||
second: Math.floor((diff % (1000 * 60)) / 1000)
|
||||
};
|
||||
const stringarray: string[] = [];
|
||||
for (const [unit, value] of Object.entries(timeobj)) {
|
||||
if (value === 0) continue;
|
||||
if (unit === 'second' && timeobj.day > 0) continue;
|
||||
stringarray.push(`${value} ${unit}${value === 1 ? '' : 's'}`);
|
||||
};
|
||||
const last = stringarray.pop();
|
||||
return stringarray.length === 0 ? last : stringarray.join(', ') + " and " + last;
|
||||
};
|
||||
11
src/lib/logger.ts
Normal file
11
src/lib/logger.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import kleur from "kleur";
|
||||
|
||||
const logger = {
|
||||
err: (arg: string) => console.error(kleur.red().bold().italic('[ERROR] ') + kleur.red().bold(arg)),
|
||||
warn: (arg: string) => console.warn(kleur.yellow().bold().italic('[WARN] ') + kleur.yellow().bold(arg)),
|
||||
info: (arg: string) => console.info(kleur.white().bold().italic('[INFO] ') + kleur.white(arg)),
|
||||
ok: (arg: string) => console.info(kleur.green().bold(arg)),
|
||||
enverr: (arg: string) => logger.err(`Please provide a ${arg} in the .env`)
|
||||
};
|
||||
|
||||
export default logger
|
||||
16
src/lib/parseCommandArgs.ts
Normal file
16
src/lib/parseCommandArgs.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { commandPrefix } from "..";
|
||||
|
||||
/** Helper function to extract arguments from commands */
|
||||
export default function parseCommandArgs(input: string) {
|
||||
const a = input.slice(commandPrefix.length);
|
||||
const sliceLength = a.startsWith('use') ? 2 : 1;
|
||||
const b = a.trim().split(' ').slice(sliceLength);
|
||||
return b;
|
||||
};
|
||||
|
||||
export function parseCheerArgs(input: string) {
|
||||
const a = input.slice(commandPrefix.length);
|
||||
const sliceLength = a.startsWith('testcheer') ? 2 : 0;
|
||||
const b = a.trim().split(' ').slice(sliceLength);
|
||||
return b;
|
||||
};
|
||||
49
src/lib/timeout.ts
Normal file
49
src/lib/timeout.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { streamerApi, streamerId, streamerUsers } from "..";
|
||||
import logger from "./logger";
|
||||
import { User } from "../user";
|
||||
|
||||
type SuccessfulTimeout = { status: true };
|
||||
type UnSuccessfulTimeout = { status: false; reason: 'banned' | 'unknown' | 'illegal' };
|
||||
type TimeoutResult = SuccessfulTimeout | UnSuccessfulTimeout;
|
||||
|
||||
/** Give a user a timeout/ban
|
||||
* @param user - user class of target to timeout/ban
|
||||
* @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' };
|
||||
|
||||
// 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' };
|
||||
|
||||
if (await streamerApi.moderation.checkUserMod(streamerId, user.id!)) {
|
||||
if (!duration) duration = 60; // make sure that mods don't get perma-banned
|
||||
remodMod(user, duration);
|
||||
await streamerApi.moderation.removeModerator(streamerId, user.id!);
|
||||
};
|
||||
|
||||
try {
|
||||
await streamerApi.moderation.banUser(streamerId, { user: user.id, reason, duration });
|
||||
} catch (err) {
|
||||
logger.err(err as string);
|
||||
return { status: false, reason: 'unknown' }
|
||||
};
|
||||
|
||||
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
|
||||
remodMod(target, timeoutleft); // Call the current function with new time (recursion)
|
||||
} else {
|
||||
try {
|
||||
await streamerApi.moderation.addModerator(streamerId, target.id);
|
||||
} catch (err) { }; // This triggers when the timeout got shortened. try/catch so no runtime error
|
||||
};
|
||||
}, duration + 3000); // callback gets called after duration of timeout + 3 seconds
|
||||
};
|
||||
100
src/user.ts
Normal file
100
src/user.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { redis } from "bun";
|
||||
import { chatterApi } from ".";
|
||||
import { HelixUser } from "@twurple/api"
|
||||
|
||||
const EXPIRETIME = 60 * 60 // 60 minutes
|
||||
|
||||
// The objective of this class is to:
|
||||
// store displayname, username and id to reduce api calls
|
||||
// keep track of temporary user specific flags (vulnerable to explosives, locked from using items)
|
||||
//
|
||||
// The userlookup key is used to find id's based on the username.
|
||||
//
|
||||
// The vulnchatters and userlookup look similar, but they're not the same
|
||||
// userlookup expiration gets set when user chats or is targeted by another user
|
||||
// vulnchatters only gets set when user chats
|
||||
|
||||
export class User {
|
||||
public username!: string;
|
||||
public id!: string;
|
||||
public displayName!: string;
|
||||
|
||||
static async initUsername(username: string): Promise<User | null> {
|
||||
const userObj = new User();
|
||||
userObj.username = username;
|
||||
const userid = await redis.get(`userlookup:${username}`);
|
||||
if (!userid) {
|
||||
const userdata = await chatterApi.users.getUserByName(username);
|
||||
if (!userdata) return null;
|
||||
userObj._setCache(userdata);
|
||||
userObj.id = userdata.id;
|
||||
userObj.displayName = userdata.displayName;
|
||||
} else {
|
||||
const displayname = await redis.get(`user:${userid}:displayName`);
|
||||
userObj._setExpire(userid, username);
|
||||
userObj.id = userid;
|
||||
userObj.displayName = displayname!;
|
||||
};
|
||||
return userObj;
|
||||
};
|
||||
|
||||
static async initUserId(userId: string): Promise<User | null> {
|
||||
const userObj = new User();
|
||||
userObj.id = userId;
|
||||
if (!await redis.exists(`user:${userId}:displayName`)) {
|
||||
const userdata = await chatterApi.users.getUserById(userId);
|
||||
if (!userdata) return null;
|
||||
userObj._setCache(userdata);
|
||||
userObj.username = userdata.name;
|
||||
userObj.displayName = userdata.displayName;
|
||||
} else {
|
||||
const [displayName, username] = await Promise.all([
|
||||
redis.get(`user:${userId}:displayName`),
|
||||
redis.get(`user:${userId}:username`)
|
||||
]);
|
||||
userObj._setExpire(userId, username!);
|
||||
userObj.username = username!;
|
||||
userObj.displayName = displayName!;
|
||||
};
|
||||
return userObj;
|
||||
};
|
||||
|
||||
private async _setCache(userdata: HelixUser) {
|
||||
await Promise.all([
|
||||
redis.set(`user:${userdata.id}:displayName`, userdata.displayName),
|
||||
redis.set(`user:${userdata.id}:username`, userdata.name),
|
||||
redis.set(`userlookup:${userdata.name}`, userdata.id)
|
||||
]);
|
||||
await this._setExpire(userdata.id, userdata.name);
|
||||
};
|
||||
|
||||
private async _setExpire(userId: string, userName: string) {
|
||||
await Promise.all([
|
||||
redis.expire(`user:${userId}:displayName`, EXPIRETIME),
|
||||
redis.expire(`user:${userId}:username`, EXPIRETIME),
|
||||
redis.expire(`userlookup:${userName}`, EXPIRETIME)
|
||||
]);
|
||||
};
|
||||
|
||||
public async itemLock(): Promise<boolean> {
|
||||
const lock = await redis.get(`user:${this.id}:itemlock`);
|
||||
return lock === '1';
|
||||
};
|
||||
|
||||
public async setLock(): Promise<void> {
|
||||
await redis.set(`user:${this.id}:itemlock`, '1');
|
||||
};
|
||||
|
||||
public async clearLock(): Promise<void> {
|
||||
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 makeInvulnerable(): Promise<void> {
|
||||
await redis.del(`vulnchatters:${this.id}`);
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user