├── .husky ├── .gitignore └── pre-commit ├── .node-version ├── .vscode └── settings.json ├── .gitattributes ├── prisma ├── .env.example ├── migrations │ ├── 20240531225538_chatter │ │ └── migration.sql │ ├── migration_lock.toml │ ├── 20240309023642_ignored_user_types │ │ └── migration.sql │ ├── 20240711185950_drop_infra_type │ │ └── migration.sql │ └── 20240307221730_base_migration │ │ └── migration.sql └── schema.prisma ├── docs └── runpod_endpoint.png ├── lib ├── utilities │ ├── permissions.ts │ ├── reference.ts │ ├── metrics.ts │ ├── sentry.ts │ └── instrumentation.ts ├── classes │ ├── AutoComplete.ts │ ├── StopWatch.ts │ ├── EventHandler.ts │ ├── Language.ts │ ├── Type.ts │ ├── Logger.ts │ ├── LanguageHandler.ts │ ├── AutoCompleteHandler.ts │ ├── ModalHandler.ts │ ├── ButtonHandler.ts │ ├── SelectMenuHandler.ts │ ├── TextCommandHandler.ts │ ├── Modal.ts │ ├── Button.ts │ ├── SelectMenu.ts │ ├── ApplicationCommand.ts │ └── TextCommand.ts └── extensions │ └── ExtendedClient.ts ├── docker-compose.yml ├── .env.example ├── typings ├── env.d.ts ├── index.d.ts └── language.d.ts ├── Dockerfile ├── languages ├── utils │ └── interface.ts └── en-US.ts ├── src ├── bot │ ├── events │ │ ├── guildRoleDelete.ts │ │ ├── guildRoleCreate.ts │ │ ├── guildRoleUpdate.ts │ │ ├── guildDelete.ts │ │ ├── messageDelete.ts │ │ ├── ready.ts │ │ ├── guildCreate.ts │ │ ├── interactionCreate.ts │ │ └── messageCreate.ts │ ├── applicationCommands │ │ ├── transcribe │ │ │ ├── transcribeEphemeralContextMenu.ts │ │ │ └── transcribeContextMenu.ts │ │ ├── misc │ │ │ └── ping.ts │ │ └── config │ │ │ ├── ignore.ts │ │ │ └── config.ts │ └── textCommands │ │ └── admin │ │ └── eval.ts └── index.ts ├── tsconfig.json ├── biome.json ├── .gitignore ├── config └── bot.config.ts ├── package.json └── README.md /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 20.18.0 -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "biomejs.biome" 3 | } 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | # sh will be used to run hooks 2 | # - husky docs 3 | pnpx lint-staged 4 | -------------------------------------------------------------------------------- /prisma/.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL="postgres://postgres:mysecretpassword@172.17.0.1/yapper" 2 | -------------------------------------------------------------------------------- /docs/runpod_endpoint.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/partyhatgg/yapper/HEAD/docs/runpod_endpoint.png -------------------------------------------------------------------------------- /prisma/migrations/20240531225538_chatter/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterEnum 2 | ALTER TYPE "InfrastructureUsed" ADD VALUE 'CHATTER'; 3 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /lib/utilities/permissions.ts: -------------------------------------------------------------------------------- 1 | import { PermissionFlagsBits } from "@discordjs/core"; 2 | import { BitField } from "@sapphire/bitfield"; 3 | 4 | const PermissionsBitField = new BitField(PermissionFlagsBits); 5 | 6 | export default PermissionsBitField; 7 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | image: postgres:16 4 | environment: 5 | POSTGRES_PASSWORD: mysecretpassword 6 | POSTGRES_DB: yapper 7 | 8 | yapper: 9 | build: . 10 | ports: 11 | - 3001:3000 12 | env_file: 13 | - .env.dev # Change to .env.production for Production 14 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Tokens 2 | DISCORD_TOKEN="" 3 | APPLICATION_ID="" 4 | CLIENT_SECRET="" 5 | SENTRY_DSN="" 6 | DATABASE_URL="" 7 | RUNPOD_API_KEY="" 8 | RUNPOD_HQ_ENDPOINT_ID="" 9 | RUNPOD_LQ_ENDPOINT_ID="" 10 | STRIPE_KEY="" 11 | STRIPE_WEBHOOK_SECRET="" 12 | 13 | # Other 14 | DEVELOPMENT_GUILD_ID="" 15 | WEB_PORT= 16 | BASE_URL="" 17 | SECRET="" -------------------------------------------------------------------------------- /lib/utilities/reference.ts: -------------------------------------------------------------------------------- 1 | const applicationCommandOptionTypeReference = { 2 | "3": "strings", 3 | "4": "integers", 4 | "5": "booleans", 5 | "6": "users", 6 | "7": "channels", 7 | "8": "roles", 8 | "9": "mentionables", 9 | "10": "numbers", 10 | "11": "attachments", 11 | }; 12 | 13 | export default applicationCommandOptionTypeReference; 14 | -------------------------------------------------------------------------------- /prisma/migrations/20240309023642_ignored_user_types/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `type` to the `ignored_users` table without a default value. This is not possible if the table is not empty. 5 | 6 | */ 7 | -- CreateEnum 8 | CREATE TYPE "IgnoreType" AS ENUM ('CONTEXT_MENU', 'AUTO_TRANSCRIPTION', 'ALL'); 9 | 10 | -- AlterTable 11 | ALTER TABLE "ignored_users" ADD COLUMN "type" "IgnoreType" NOT NULL; 12 | -------------------------------------------------------------------------------- /prisma/migrations/20240711185950_drop_infra_type/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `infrastructureUsed` on the `jobs` table. All the data in the column will be lost. 5 | - Added the required column `model` to the `jobs` table without a default value. This is not possible if the table is not empty. 6 | 7 | */ 8 | -- AlterTable 9 | ALTER TABLE "jobs" DROP COLUMN "infrastructureUsed", 10 | ADD COLUMN "model" TEXT NOT NULL; 11 | 12 | -- DropEnum 13 | DROP TYPE "InfrastructureUsed"; 14 | -------------------------------------------------------------------------------- /typings/env.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace NodeJS { 3 | interface ProcessEnv { 4 | APPLICATION_ID: string; 5 | BASE_URL: string; 6 | CLIENT_SECRET: string; 7 | CONSOLE_HOOK: string; 8 | DATABASE_URL: string; 9 | DEVELOPMENT_GUILD_ID: string; 10 | DISCORD_TOKEN: string; 11 | GUILD_HOOK: string; 12 | NODE_ENV: "development" | "production"; 13 | RUNPOD_API_KEY: string; 14 | RUNPOD_HQ_ENDPOINT_ID: string; 15 | RUNPOD_LQ_ENDPOINT_ID: string; 16 | SECRET: string; 17 | SENTRY_DSN: string; 18 | STRIPE_KEY: string; 19 | STRIPE_WEBHOOK_SECRET: string; 20 | WEB_PORT: string; 21 | } 22 | } 23 | } 24 | 25 | export type {}; 26 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20 AS base 2 | 3 | ENV PNPM_HOME="/pnpm" 4 | ENV PATH="$PNPM_HOME:$PATH" 5 | 6 | RUN npm i -g corepack@latest 7 | RUN corepack enable 8 | COPY . /app 9 | WORKDIR /app 10 | 11 | FROM base AS prod-deps 12 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --no-frozen-lockfile 13 | RUN pnpm translate 14 | RUN pnpm prisma migrate deploy 15 | 16 | FROM base AS build 17 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --no-frozen-lockfile 18 | RUN pnpm translate 19 | RUN pnpm prisma migrate deploy 20 | RUN pnpm tsc 21 | 22 | FROM base 23 | COPY --from=prod-deps /app/node_modules /app/node_modules 24 | COPY --from=build /app/dist /app/dist 25 | EXPOSE 3000 26 | CMD [ "pnpm", "start" ] 27 | -------------------------------------------------------------------------------- /languages/utils/interface.ts: -------------------------------------------------------------------------------- 1 | import { writeFile } from "node:fs"; 2 | import enUS from "../en-US.js"; 3 | 4 | const interfaceMap = new Map(); 5 | 6 | for (const [key, value] of Object.entries(enUS)) { 7 | interfaceMap.set( 8 | key, 9 | typeof value === "string" 10 | ? [...value.matchAll(/{{(.*?)}}/g)] 11 | .map((match) => match[1]) 12 | .filter(Boolean) 13 | .map((match) => match!) 14 | : [], 15 | ); 16 | } 17 | 18 | writeFile( 19 | "./typings/language.d.ts", 20 | `export interface LanguageValues {\n${[...interfaceMap.entries()] 21 | .map(([key, value]) => `${key}: {${[...new Set(value)].map((val) => `${val}: any`).join(", ")}}`) 22 | .join(",\n")}\n}`, 23 | () => {}, 24 | ); 25 | 26 | const interfaceArray = [...interfaceMap]; 27 | 28 | console.log( 29 | `Generated an interface for ${interfaceArray.length} keys, ${ 30 | interfaceArray.filter(([_, value]) => value.length > 0).length 31 | } of which have typed objects.`, 32 | ); 33 | -------------------------------------------------------------------------------- /src/bot/events/guildRoleDelete.ts: -------------------------------------------------------------------------------- 1 | import type { GatewayGuildRoleDeleteDispatchData, ToEventProps } from "@discordjs/core"; 2 | import { GatewayDispatchEvents } from "@discordjs/core"; 3 | import EventHandler from "../../../lib/classes/EventHandler.js"; 4 | import type ExtendedClient from "../../../lib/extensions/ExtendedClient.js"; 5 | 6 | export default class GuildRoleDelete extends EventHandler { 7 | public constructor(client: ExtendedClient) { 8 | super(client, GatewayDispatchEvents.GuildRoleDelete, false); 9 | } 10 | 11 | /** 12 | * Sent when a guild role is deleted. 13 | * 14 | * https://discord.com/developers/docs/topics/gateway-events#guild-role-delete 15 | */ 16 | public override async run({ data }: ToEventProps) { 17 | const previousGuildRoles = this.client.guildRolesCache.get(data.guild_id) ?? new Map(); 18 | previousGuildRoles.delete(data.guild_id); 19 | 20 | this.client.guildRolesCache.set(data.guild_id, previousGuildRoles); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/bot/events/guildRoleCreate.ts: -------------------------------------------------------------------------------- 1 | import type { GatewayGuildRoleCreateDispatchData, ToEventProps } from "@discordjs/core"; 2 | import { GatewayDispatchEvents } from "@discordjs/core"; 3 | import EventHandler from "../../../lib/classes/EventHandler.js"; 4 | import type ExtendedClient from "../../../lib/extensions/ExtendedClient.js"; 5 | 6 | export default class GuildRoleCreate extends EventHandler { 7 | public constructor(client: ExtendedClient) { 8 | super(client, GatewayDispatchEvents.GuildRoleCreate, false); 9 | } 10 | 11 | /** 12 | * Sent when a guild role is created. 13 | * 14 | * https://discord.com/developers/docs/topics/gateway-events#guild-role-create 15 | */ 16 | public override async run({ data }: ToEventProps) { 17 | const previousGuildRoles = this.client.guildRolesCache.get(data.guild_id) ?? new Map(); 18 | previousGuildRoles.set(data.role.id, data.role); 19 | 20 | this.client.guildRolesCache.set(data.guild_id, previousGuildRoles); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/bot/events/guildRoleUpdate.ts: -------------------------------------------------------------------------------- 1 | import type { GatewayGuildRoleUpdateDispatchData, ToEventProps } from "@discordjs/core"; 2 | import { GatewayDispatchEvents } from "@discordjs/core"; 3 | import EventHandler from "../../../lib/classes/EventHandler.js"; 4 | import type ExtendedClient from "../../../lib/extensions/ExtendedClient.js"; 5 | 6 | export default class GuildRoleUpdate extends EventHandler { 7 | public constructor(client: ExtendedClient) { 8 | super(client, GatewayDispatchEvents.GuildRoleUpdate, false); 9 | } 10 | 11 | /** 12 | * Sent when a guild role is updated. 13 | * 14 | * https://discord.com/developers/docs/topics/gateway-events#guild-role-update 15 | */ 16 | public override async run({ data }: ToEventProps) { 17 | const previousGuildRoles = this.client.guildRolesCache.get(data.guild_id) ?? new Map(); 18 | previousGuildRoles.set(data.guild_id, data.role); 19 | 20 | this.client.guildRolesCache.set(data.guild_id, previousGuildRoles); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // Mapped from https://www.typescriptlang.org/tsconfig 3 | "compilerOptions": { 4 | // Type Checking 5 | "allowUnreachableCode": false, 6 | "allowUnusedLabels": false, 7 | "exactOptionalPropertyTypes": true, 8 | "noFallthroughCasesInSwitch": true, 9 | "noImplicitOverride": true, 10 | "noImplicitReturns": false, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "strict": true, 14 | "useUnknownInCatchVariables": true, 15 | "noUncheckedIndexedAccess": true, 16 | "skipLibCheck": true, 17 | 18 | // Modules 19 | "module": "ESNext", 20 | "moduleResolution": "node", 21 | "resolveJsonModule": true, 22 | 23 | // Emit 24 | "declaration": true, 25 | "declarationMap": true, 26 | "importHelpers": true, 27 | "inlineSources": true, 28 | "newLine": "lf", 29 | "noEmitHelpers": true, 30 | "outDir": "dist", 31 | "removeComments": false, 32 | "sourceMap": true, 33 | "esModuleInterop": true, 34 | "forceConsistentCasingInFileNames": true, 35 | 36 | // Language and Environment, we use DOM here because of fetch. 37 | "experimentalDecorators": true, 38 | "lib": ["ESNext", "DOM"], 39 | "target": "ES2022", 40 | "useDefineForClassFields": true 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /lib/classes/AutoComplete.ts: -------------------------------------------------------------------------------- 1 | import type { APIApplicationCommandAutocompleteInteraction } from "@discordjs/core"; 2 | import type { APIInteractionWithArguments } from "../../typings"; 3 | import type ExtendedClient from "../extensions/ExtendedClient.js"; 4 | import type Language from "./Language.js"; 5 | 6 | export default class AutoComplete { 7 | /** 8 | * A list of strings that this autocomplete should listen to. 9 | */ 10 | public readonly accepts: string[]; 11 | 12 | /** 13 | * Our extended client. 14 | */ 15 | public readonly client: ExtendedClient; 16 | 17 | /** 18 | * Create a new application command. 19 | * 20 | * @param accepts A list of strings that this autocomplete should listen to. 21 | * @param client Our extended client. 22 | */ 23 | public constructor(accepts: string[], client: ExtendedClient) { 24 | this.accepts = accepts; 25 | this.client = client; 26 | } 27 | 28 | /** 29 | * Run this auto complete. 30 | * 31 | * @param _options The options to run this application command. 32 | * @param _options.interaction The interaction to pre-check. 33 | * @param _options.language The language to use when replying to the interaction. 34 | * @param _options.shardId The shard ID to use when replying to the interaction. 35 | */ 36 | public async run(_options: { 37 | interaction: APIInteractionWithArguments; 38 | language: Language; 39 | shardId: number; 40 | }) {} 41 | } 42 | -------------------------------------------------------------------------------- /src/bot/events/guildDelete.ts: -------------------------------------------------------------------------------- 1 | import type { GatewayGuildDeleteDispatchData, ToEventProps } from "@discordjs/core"; 2 | import { GatewayDispatchEvents } from "@discordjs/core"; 3 | import EventHandler from "../../../lib/classes/EventHandler.js"; 4 | import type ExtendedClient from "../../../lib/extensions/ExtendedClient.js"; 5 | 6 | export default class GuildDelete extends EventHandler { 7 | public constructor(client: ExtendedClient) { 8 | super(client, GatewayDispatchEvents.GuildDelete, false); 9 | } 10 | 11 | /** 12 | * Lazy-load for unavailable guild, guild became available, or user joined a new guild. 13 | * 14 | * https://discord.com/developers/docs/topics/gateway-events#guild-delete 15 | */ 16 | public override async run({ shardId, data }: ToEventProps) { 17 | if (data.unavailable) return; 18 | 19 | this.client.guildRolesCache.delete(data.id); 20 | this.client.guildOwnersCache.delete(data.id); 21 | 22 | this.client.logger.info( 23 | `Left guild ${data.id} on Shard ${shardId}. Now at ${this.client.guildOwnersCache.size} guilds with ${this.client.approximateUserCount} total users.`, 24 | ); 25 | 26 | return this.client.logger.webhookLog("guild", { 27 | content: `**__Left a Guild (${this.client.guildOwnersCache.size} Total)__**\n**Guild ID:** \`${ 28 | data.id 29 | }\`\n**Timestamp:** ${this.client.functions.generateTimestamp()}\n**Shard ID:** \`${shardId}\``, 30 | username: `${this.client.config.botName} | Console Logs`, 31 | allowed_mentions: { parse: [] }, 32 | }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/bot/applicationCommands/transcribe/transcribeEphemeralContextMenu.ts: -------------------------------------------------------------------------------- 1 | import type { APIMessageApplicationCommandInteraction } from "@discordjs/core"; 2 | import { ApplicationCommandType, ApplicationIntegrationType } from "@discordjs/core"; 3 | import type Language from "../../../../lib/classes/Language.js"; 4 | import type ExtendedClient from "../../../../lib/extensions/ExtendedClient.js"; 5 | import type { APIInteractionWithArguments } from "../../../../typings/index"; 6 | import { BaseTranscribeContextMenu } from "./transcribeContextMenu.js"; 7 | 8 | export default class TranscribeEphemeralContextMenu extends BaseTranscribeContextMenu { 9 | /** 10 | * Create our transcribe context menu command. 11 | * 12 | * @param client - Our extended client. 13 | */ 14 | public constructor(client: ExtendedClient) { 15 | super(client, { 16 | options: { 17 | ...client.languageHandler.generateLocalizationsForApplicationCommandOptionTypeStringWithChoices({ 18 | name: "TRANSCRIBE_EPHEMERAL_COMMAND_NAME", 19 | }), 20 | type: ApplicationCommandType.Message, 21 | integration_types: [ApplicationIntegrationType.GuildInstall, ApplicationIntegrationType.UserInstall], 22 | contexts: [0, 1, 2], 23 | }, 24 | }); 25 | } 26 | 27 | public override async run({ 28 | interaction, 29 | language, 30 | shardId, 31 | }: { 32 | interaction: APIInteractionWithArguments; 33 | language: Language; 34 | shardId: number; 35 | }) { 36 | return super.run({ interaction, language, shardId, ephemeral: true }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/bot/events/messageDelete.ts: -------------------------------------------------------------------------------- 1 | import type { GatewayMessageDeleteDispatchData, ToEventProps } from "@discordjs/core"; 2 | import { GatewayDispatchEvents } from "@discordjs/core"; 3 | import EventHandler from "../../../lib/classes/EventHandler.js"; 4 | import type ExtendedClient from "../../../lib/extensions/ExtendedClient.js"; 5 | 6 | export default class MessageDelete extends EventHandler { 7 | public constructor(client: ExtendedClient) { 8 | super(client, GatewayDispatchEvents.MessageDelete); 9 | } 10 | 11 | /** 12 | * Sent when a message is deleted. 13 | * 14 | * https://discord.com/developers/docs/topics/gateway-events#message-delete 15 | */ 16 | public override async run({ data: message }: ToEventProps) { 17 | const [transcription, job] = await Promise.all([ 18 | this.client.prisma.transcription.findUnique({ 19 | where: { initialMessageId: message.id }, 20 | }), 21 | this.client.prisma.job.findFirst({ 22 | where: { initialMessageId: message.id }, 23 | }), 24 | ]); 25 | 26 | if (job) { 27 | return Promise.all([ 28 | this.client.functions.cancelJob(job), 29 | this.client.prisma.job.delete({ where: { id: job.id } }), 30 | this.client.api.channels.deleteMessage(message.channel_id, job.responseMessageId), 31 | ]); 32 | } 33 | 34 | if (transcription) { 35 | return Promise.all([ 36 | this.client.prisma.transcription.delete({ where: { initialMessageId: message.id } }), 37 | this.client.api.channels.deleteMessage(message.channel_id, transcription.responseMessageId), 38 | transcription?.threadId ? this.client.api.channels.delete(transcription.threadId) : null, 39 | ]); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | datasource db { 2 | // Change provider to whichever provide you're using. 3 | provider = "postgresql" 4 | url = env("DATABASE_URL") 5 | } 6 | 7 | generator client { 8 | provider = "prisma-client-js" 9 | previewFeatures = ["tracing"] 10 | } 11 | 12 | enum CommandType { 13 | TEXT_COMMAND 14 | APPLICATION_COMMAND 15 | } 16 | 17 | enum PurchaseType { 18 | ONCE 19 | RECURRING 20 | } 21 | 22 | enum IgnoreType { 23 | CONTEXT_MENU 24 | AUTO_TRANSCRIPTION 25 | ALL 26 | } 27 | 28 | model Cooldown { 29 | userId String 30 | commandName String 31 | 32 | expiresAt DateTime 33 | 34 | commandType CommandType 35 | 36 | @@id([commandName, commandType, userId]) 37 | @@map("command_cooldowns") 38 | } 39 | 40 | model UserLanguage { 41 | userId String @id 42 | languageId String 43 | 44 | @@map("user_languages") 45 | } 46 | 47 | model Transcription { 48 | initialMessageId String @id 49 | responseMessageId String 50 | threadId String? 51 | 52 | @@map("transcriptions") 53 | } 54 | 55 | model AutoTranscriptVoiceMessages { 56 | guildId String @id 57 | 58 | @@map("auto_transcript_voice_messages") 59 | } 60 | 61 | model Job { 62 | id String @id 63 | attachmentUrl String 64 | initialMessageId String 65 | responseMessageId String 66 | guildId String 67 | model String 68 | channelId String 69 | interactionId String? 70 | interactionToken String? 71 | 72 | @@map("jobs") 73 | } 74 | 75 | model IgnoredUser { 76 | userId String @id 77 | type IgnoreType 78 | 79 | @@map("ignored_users") 80 | } 81 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { env } from "node:process"; 2 | import { REST } from "@discordjs/rest"; 3 | import { CompressionMethod, WebSocketManager, WebSocketShardEvents, WorkerShardingStrategy } from "@discordjs/ws"; 4 | import { load } from "dotenv-extended"; 5 | import botConfig from "../config/bot.config.js"; 6 | import Logger from "../lib/classes/Logger.js"; 7 | import Server from "../lib/classes/Server.js"; 8 | import ExtendedClient from "../lib/extensions/ExtendedClient.js"; 9 | 10 | load({ 11 | path: env.NODE_ENV === "production" ? ".env.prod" : ".env.dev", 12 | }); 13 | 14 | // Create REST and WebSocket managers directly. 15 | const rest = new REST({ version: "10" }).setToken(env.DISCORD_TOKEN); 16 | const gateway = new WebSocketManager({ 17 | token: env.DISCORD_TOKEN, 18 | intents: botConfig.intents, 19 | initialPresence: botConfig.presence, 20 | compression: CompressionMethod.ZlibNative, 21 | rest, 22 | // This will cause 2 workers to spawn, 3 shards per worker. 23 | // "each shard gets its own bubble which handles decoding, heartbeats, etc. And your main thread just gets the final result" - Vlad. 24 | buildStrategy: (manager) => new WorkerShardingStrategy(manager, { shardsPerWorker: 3 }), 25 | }); 26 | 27 | await new Server(Number.parseInt(env.WEB_PORT, 10)).start(); 28 | 29 | const client = new ExtendedClient({ rest, gateway }); 30 | await client.start(); 31 | 32 | await gateway.connect().then(async () => { 33 | await client.applicationCommandHandler.registerApplicationCommands(); 34 | Logger.info("All shards have started."); 35 | }); 36 | 37 | if (env.NODE_ENV === "development") { 38 | gateway.on(WebSocketShardEvents.Debug, (message, shardId) => { 39 | Logger.debug(`[SHARD ${shardId}] ${message}`); 40 | }); 41 | 42 | gateway.on(WebSocketShardEvents.Ready, (_message, shardId) => { 43 | Logger.debug(`[SHARD ${shardId}] Ready`); 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /lib/utilities/metrics.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandType } from "@discordjs/core"; 2 | import { metrics } from "@opentelemetry/api"; 3 | import botConfig from "../../config/bot.config.js"; 4 | import type ApplicationCommand from "../classes/ApplicationCommand"; 5 | import type TextCommand from "../classes/TextCommand"; 6 | 7 | const meter = metrics.getMeter(botConfig.botName); 8 | 9 | /** 10 | * Counter: Counter is a metric value that can only increase or reset i.e. the value cannot reduce than the previous value. 11 | * It can be used for metrics like the number of requests, no of errors, etc. 12 | * 13 | * Gauge: Gauge is a number which can either go up or down. 14 | * It can be used for metrics like the number of pods in a cluster, the number of events in a queue, etc. 15 | */ 16 | 17 | const commandUsageMeter = meter.createCounter("command_used"); 18 | 19 | export function logCommandUsage(command: ApplicationCommand | TextCommand, shardId: number, success: boolean) { 20 | commandUsageMeter.add(1, { 21 | command: command.name, 22 | type: "type" in command ? "text" : ApplicationCommandType.ChatInput ? "slash" : "context", 23 | success, 24 | shard: shardId, 25 | }); 26 | } 27 | 28 | export const approximateUserCountGauge = meter.createGauge("approximate_user_count"); 29 | export const userInstallationGauge = meter.createGauge("user_installations"); 30 | export const guildCountGauge = meter.createGauge("guild_count"); 31 | export const guildGauge = meter.createGauge("guilds"); 32 | 33 | export const autoCompleteMetric = meter.createCounter("autocomplete_responses"); 34 | export const websocketEventMetric = meter.createCounter("websocket_events"); 35 | export const interactionsMetric = meter.createCounter("interactions_created"); 36 | export const userLocalesMetric = meter.createCounter("user_locales"); 37 | 38 | // Bot Specific 39 | export const transcriptionsMetric = meter.createCounter("transcriptions"); 40 | -------------------------------------------------------------------------------- /prisma/migrations/20240307221730_base_migration/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "CommandType" AS ENUM ('TEXT_COMMAND', 'APPLICATION_COMMAND'); 3 | 4 | -- CreateEnum 5 | CREATE TYPE "InfrastructureUsed" AS ENUM ('SERVERLESS', 'ENDPOINT'); 6 | 7 | -- CreateEnum 8 | CREATE TYPE "PurchaseType" AS ENUM ('ONCE', 'RECURRING'); 9 | 10 | -- CreateTable 11 | CREATE TABLE "command_cooldowns" ( 12 | "userId" TEXT NOT NULL, 13 | "commandName" TEXT NOT NULL, 14 | "expiresAt" TIMESTAMP(3) NOT NULL, 15 | "commandType" "CommandType" NOT NULL, 16 | 17 | CONSTRAINT "command_cooldowns_pkey" PRIMARY KEY ("commandName","commandType","userId") 18 | ); 19 | 20 | -- CreateTable 21 | CREATE TABLE "user_languages" ( 22 | "userId" TEXT NOT NULL, 23 | "languageId" TEXT NOT NULL, 24 | 25 | CONSTRAINT "user_languages_pkey" PRIMARY KEY ("userId") 26 | ); 27 | 28 | -- CreateTable 29 | CREATE TABLE "transcriptions" ( 30 | "initialMessageId" TEXT NOT NULL, 31 | "responseMessageId" TEXT NOT NULL, 32 | "threadId" TEXT, 33 | 34 | CONSTRAINT "transcriptions_pkey" PRIMARY KEY ("initialMessageId") 35 | ); 36 | 37 | -- CreateTable 38 | CREATE TABLE "auto_transcript_voice_messages" ( 39 | "guildId" TEXT NOT NULL, 40 | 41 | CONSTRAINT "auto_transcript_voice_messages_pkey" PRIMARY KEY ("guildId") 42 | ); 43 | 44 | -- CreateTable 45 | CREATE TABLE "jobs" ( 46 | "id" TEXT NOT NULL, 47 | "attachmentUrl" TEXT NOT NULL, 48 | "initialMessageId" TEXT NOT NULL, 49 | "responseMessageId" TEXT NOT NULL, 50 | "guildId" TEXT NOT NULL, 51 | "infrastructureUsed" "InfrastructureUsed" NOT NULL, 52 | "channelId" TEXT NOT NULL, 53 | "interactionId" TEXT, 54 | "interactionToken" TEXT, 55 | 56 | CONSTRAINT "jobs_pkey" PRIMARY KEY ("id") 57 | ); 58 | 59 | -- CreateTable 60 | CREATE TABLE "ignored_users" ( 61 | "userId" TEXT NOT NULL, 62 | 63 | CONSTRAINT "ignored_users_pkey" PRIMARY KEY ("userId") 64 | ); 65 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "vcs": { 4 | "enabled": true, 5 | "clientKind": "git", 6 | "useIgnoreFile": true, 7 | "defaultBranch": "main" 8 | }, 9 | "files": { 10 | "ignore": ["typings/language.d.ts", "package.json"] 11 | }, 12 | "formatter": { 13 | "enabled": true, 14 | "indentStyle": "tab", 15 | "indentWidth": 2, 16 | "lineWidth": 120 17 | }, 18 | "organizeImports": { 19 | "enabled": true 20 | }, 21 | "linter": { 22 | "enabled": true, 23 | "rules": { 24 | "recommended": true, 25 | "complexity": { 26 | "noUselessStringConcat": "error", 27 | "noUselessUndefinedInitialization": "error", 28 | "noVoid": "off", 29 | "useDateNow": "error" 30 | }, 31 | "correctness": { 32 | "noNewSymbol": "error", 33 | "noUnusedPrivateClassMembers": "off", 34 | "noUnusedVariables": "error", 35 | "useArrayLiterals": "error" 36 | }, 37 | "nursery": { 38 | "useSortedClasses": "warn" 39 | }, 40 | "style": { 41 | "noNonNullAssertion": "off", 42 | "useCollapsedElseIf": "error", 43 | "useConsistentArrayType": { 44 | "level": "error", 45 | "options": { 46 | "syntax": "shorthand" 47 | } 48 | }, 49 | "useDefaultSwitchClause": "error", 50 | "useFilenamingConvention": { 51 | "level": "off", 52 | "options": { 53 | "requireAscii": true, 54 | "filenameCases": ["kebab-case"] 55 | } 56 | }, 57 | "useNamingConvention": { 58 | "level": "off", 59 | "options": { 60 | "strictCase": false 61 | } 62 | }, 63 | "useShorthandAssign": "error", 64 | "useThrowNewError": "error", 65 | "useThrowOnlyError": "error" 66 | }, 67 | "suspicious": { 68 | "noImplicitAnyLet": "off", 69 | "noExplicitAny": "off", 70 | "noEmptyBlockStatements": "off", 71 | "useErrorMessage": "error", 72 | "useNumberToFixedDigitsArgument": "error" 73 | } 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/bot/events/ready.ts: -------------------------------------------------------------------------------- 1 | import { setInterval } from "node:timers"; 2 | import type { GatewayReadyDispatchData, ToEventProps } from "@discordjs/core"; 3 | import { GatewayDispatchEvents } from "@discordjs/core"; 4 | import EventHandler from "../../../lib/classes/EventHandler.js"; 5 | import type ExtendedClient from "../../../lib/extensions/ExtendedClient.js"; 6 | import { 7 | approximateUserCountGauge, 8 | guildCountGauge, 9 | guildGauge, 10 | userInstallationGauge, 11 | } from "../../../lib/utilities/metrics.js"; 12 | 13 | export default class Ready extends EventHandler { 14 | public constructor(client: ExtendedClient) { 15 | super(client, GatewayDispatchEvents.Ready, true); 16 | } 17 | 18 | /** 19 | * Contains the initial state information. 20 | * 21 | * https://discord.com/developers/docs/topics/gateway-events#ready 22 | */ 23 | public override async run({ data }: ToEventProps) { 24 | guildCountGauge.record(data.guilds.length, { 25 | shard: data.shard?.[0], 26 | }); 27 | 28 | for (const guild of data.guilds) this.client.guildOwnersCache.set(guild.id, ""); 29 | 30 | const me = await this.client.api.applications.getCurrent(); 31 | 32 | userInstallationGauge.record((me as any).approximate_user_install_count); 33 | 34 | this.client.logger.info( 35 | `Logged in as ${data.user.username}#${data.user.discriminator} [${data.user.id}] on Shard ${data.shard?.[0]} with ${data.guilds.length} guilds and ${(me as any).approximate_user_install_count} user installations.`, 36 | ); 37 | 38 | setInterval(() => { 39 | guildGauge.record(this.client.guildOwnersCache.size); 40 | approximateUserCountGauge.record(this.client.approximateUserCount); 41 | }, 10_000); 42 | 43 | return this.client.logger.webhookLog("console", { 44 | content: `${this.client.functions.generateTimestamp()} Logged in as ${data.user.username}#${ 45 | data.user.discriminator 46 | } [\`${data.user.id}\`] on Shard ${data.shard?.[0]} with ${data.guilds.length} guilds and ${(me as any).approximate_user_install_count} user installations.`, 47 | allowed_mentions: { parse: [] }, 48 | username: `${this.client.config.botName} | Console Logs`, 49 | }); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/bot/events/guildCreate.ts: -------------------------------------------------------------------------------- 1 | import type { GatewayGuildCreateDispatchData, ToEventProps } from "@discordjs/core"; 2 | import { GatewayDispatchEvents } from "@discordjs/core"; 3 | import EventHandler from "../../../lib/classes/EventHandler.js"; 4 | import type ExtendedClient from "../../../lib/extensions/ExtendedClient.js"; 5 | 6 | export default class GuildCreate extends EventHandler { 7 | public constructor(client: ExtendedClient) { 8 | super(client, GatewayDispatchEvents.GuildCreate, false); 9 | } 10 | 11 | /** 12 | * Lazy-load for unavailable guild, guild became available, or user joined a new guild. 13 | * 14 | * https://discord.com/developers/docs/topics/gateway-events#guild-create 15 | */ 16 | public override async run({ shardId, data }: ToEventProps) { 17 | const guildRoles = new Map(); 18 | 19 | for (const guildRole of data.roles) guildRoles.set(guildRole.id, guildRole); 20 | 21 | this.client.guildRolesCache.set(data.id, guildRoles); 22 | if (this.client.guildOwnersCache.get(data.id) === undefined) { 23 | this.client.guildOwnersCache.set(data.id, data.owner_id); 24 | this.client.approximateUserCount += data.member_count; 25 | 26 | this.client.logger.info( 27 | `Joined ${data.name} [${data.id}] with ${data.member_count} members on Shard ${shardId}. Now at ${this.client.guildOwnersCache.size} guilds with ${this.client.approximateUserCount} total users.`, 28 | ); 29 | 30 | return this.client.logger.webhookLog("guild", { 31 | content: `**__Joined a New Guild (${this.client.guildOwnersCache.size} Total)__**\n**Guild Name:** \`${ 32 | data.name 33 | }\`\n**Guild ID:** \`${data.id}\`\n**Guild Owner:** <@${data.owner_id}> \`[${ 34 | data.owner_id 35 | }]\`\n**Guild Member Count:** \`${ 36 | data.member_count 37 | }\`\n**Timestamp:** ${this.client.functions.generateTimestamp()}\n**Shard ID:** \`${shardId}\``, 38 | username: `${this.client.config.botName} | Console Logs`, 39 | allowed_mentions: { parse: [] }, 40 | }); 41 | } 42 | 43 | if (this.client.guildOwnersCache.get(data.id) !== data.owner_id) { 44 | return this.client.guildOwnersCache.set(data.id, data.owner_id); 45 | } 46 | 47 | return true; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_STORE 2 | 3 | # Logs 4 | *.log 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # TypeScript v1 declaration files 46 | # typings/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Microbundle cache 58 | .rpt2_cache/ 59 | .rts2_cache_cjs/ 60 | .rts2_cache_es/ 61 | .rts2_cache_umd/ 62 | 63 | # Optional REPL history 64 | .node_repl_history 65 | 66 | # Output of 'npm pack' 67 | *.tgz 68 | 69 | # Yarn Integrity file 70 | .yarn-integrity 71 | 72 | # dotenv environment variables file 73 | .env 74 | .env.prod 75 | .env.dev 76 | .env.test 77 | 78 | # parcel-bundler cache (https://parceljs.org/) 79 | .cache 80 | 81 | # Next.js build output 82 | .next 83 | 84 | # Nuxt.js build / generate output 85 | .nuxt 86 | dist 87 | 88 | # Gatsby files 89 | .cache/ 90 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 91 | # https://nextjs.org/blog/next-9-1#public-directory-support 92 | # public 93 | 94 | # vuepress build output 95 | .vuepress/dist 96 | 97 | # Serverless directories 98 | .serverless/ 99 | 100 | # FuseBox cache 101 | .fusebox/ 102 | 103 | # DynamoDB Local files 104 | .dynamodb/ 105 | 106 | # TernJS port file 107 | .tern-port 108 | 109 | .idea/ 110 | -------------------------------------------------------------------------------- /config/bot.config.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from "node:child_process"; 2 | import { env } from "node:process"; 3 | import type { GatewayPresenceUpdateData } from "@discordjs/core"; 4 | import { ActivityType, GatewayIntentBits, PermissionFlagsBits } from "@discordjs/core"; 5 | 6 | export default { 7 | /** 8 | * The prefix the bot will use for text commands, the prefix is different depending on the NODE_ENV. 9 | */ 10 | prefixes: env.NODE_ENV === "production" ? ["y!"] : ["y!!"], 11 | 12 | /** 13 | * The name the bot should use across the bot. 14 | */ 15 | botName: "Yapper", 16 | 17 | /** 18 | * A list of file types that the bot will transcribe. 19 | */ 20 | allowedFileTypes: ["audio/ogg", "audio/mpeg", "audio/mp4", "video/mp4", "video/webm", "video/quicktime"], 21 | 22 | /** 23 | * The bot's current version, this is the first 7 characters of the current Git commit hash. 24 | */ 25 | version: env.NODE_ENV === "production" ? execSync("git rev-parse --short HEAD").toString().trim() : "dev", 26 | 27 | /** 28 | * A list of users that are marked as administrators of the bot, these users have access to eval commands. 29 | */ 30 | admins: ["619284841187246090", "194861788926443520"], 31 | 32 | /** 33 | * The presence that should be displayed when the bot starts running. 34 | */ 35 | presence: { 36 | status: "online", 37 | activities: [ 38 | { 39 | type: ActivityType.Listening, 40 | name: "voice messages.", 41 | }, 42 | ], 43 | } as GatewayPresenceUpdateData, 44 | 45 | /** 46 | * The hastebin server that we should use for uploading logs. 47 | */ 48 | hastebin: "https://hst.sh", 49 | 50 | /** 51 | * An object of the type Record, the key corelating to when the value (a hexadecimal code) should be used. 52 | */ 53 | colors: { 54 | primary: 0x5865f2, 55 | success: 0x57f287, 56 | warning: 0xfee75c, 57 | error: 0xed4245, 58 | }, 59 | 60 | /** 61 | * The list of intents the bot requires to function. 62 | */ 63 | intents: GatewayIntentBits.Guilds | GatewayIntentBits.GuildMessages | GatewayIntentBits.MessageContent, 64 | 65 | /** 66 | * A list of permissions that the bot needs to function at all. 67 | */ 68 | requiredPermissions: PermissionFlagsBits.SendMessages | PermissionFlagsBits.EmbedLinks, 69 | }; 70 | -------------------------------------------------------------------------------- /lib/classes/StopWatch.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2019 dirigeants, MIT License 2 | 3 | import { performance } from "node:perf_hooks"; 4 | 5 | /** 6 | * Our Stopwatch class, uses native node to replicate/extend previous performance now dependency. 7 | */ 8 | export default class Stopwatch { 9 | /** 10 | * The number of digits to appear after the decimal point when returning the friendly duration. 11 | */ 12 | public digits: number; 13 | 14 | private _start: number; 15 | 16 | private _end?: number | undefined; 17 | 18 | /** 19 | * Starts a new Stopwatch 20 | * 21 | * @param [digits] The number of digits to appear after the decimal point when returning the friendly duration 22 | */ 23 | public constructor(digits = 2) { 24 | this.digits = digits; 25 | this._start = performance.now(); 26 | this._end = undefined; 27 | } 28 | 29 | /** 30 | * The duration of this stopwatch since start or start to end if this stopwatch has stopped. 31 | */ 32 | private get duration() { 33 | return this._end ? this._end - this._start : performance.now() - this._start; 34 | } 35 | 36 | /** 37 | * If the stopwatch is running or not 38 | */ 39 | private get running() { 40 | return Boolean(!this._end); 41 | } 42 | 43 | /** 44 | * Restarts the Stopwatch (Returns a running state) 45 | */ 46 | public restart() { 47 | this._start = performance.now(); 48 | this._end = undefined; 49 | return this; 50 | } 51 | 52 | /** 53 | * Resets the Stopwatch to 0 duration (Returns a stopped state) 54 | */ 55 | public reset() { 56 | this._start = performance.now(); 57 | this._end = this._start; 58 | return this; 59 | } 60 | 61 | /** 62 | * Starts the Stopwatch 63 | */ 64 | public start() { 65 | if (!this.running) { 66 | this._start = performance.now() - this.duration; 67 | this._end = undefined; 68 | } 69 | 70 | return this; 71 | } 72 | 73 | /** 74 | * Stops the Stopwatch, freezing the duration 75 | */ 76 | public stop() { 77 | if (this.running) this._end = performance.now(); 78 | return this; 79 | } 80 | 81 | /** 82 | * Defines toString behavior 83 | */ 84 | public toString() { 85 | const time = this.duration; 86 | if (time >= 1_000) return `${(time / 1_000).toFixed(this.digits)}s`; 87 | if (time >= 1) return `${time.toFixed(this.digits)}ms`; 88 | return `${(time * 1_000).toFixed(this.digits)}μs`; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /lib/classes/EventHandler.ts: -------------------------------------------------------------------------------- 1 | import type { MappedEvents } from "@discordjs/core"; 2 | import type ExtendedClient from "../extensions/ExtendedClient.js"; 3 | import { websocketEventMetric } from "../utilities/metrics.js"; 4 | 5 | export default class EventHandler { 6 | /** 7 | * The name of our event, this is what we will use to listen to the event. 8 | */ 9 | public readonly name: keyof MappedEvents; 10 | 11 | /** 12 | * Our extended client. 13 | */ 14 | public readonly client: ExtendedClient; 15 | 16 | /** 17 | * The listener for our events; 18 | */ 19 | private readonly _listener; 20 | 21 | /** 22 | * Whether or not this event should only be handled once. 23 | */ 24 | private readonly once: boolean; 25 | 26 | /** 27 | * Create our event handler. 28 | * 29 | * @param client Our extended client. 30 | * @param name The name of our event, this is what we will use to listen to the event. 31 | * @param once Whether or not this event should only be handled once. 32 | */ 33 | public constructor(client: ExtendedClient, name: keyof MappedEvents, once = false) { 34 | this.name = name; 35 | this.client = client; 36 | this.once = once; 37 | this._listener = this._run.bind(this); 38 | } 39 | 40 | /** 41 | * Handle the execution of this event, with some error handling. 42 | * 43 | * @param args The arguments for our event. 44 | * @returns The result of our event. 45 | */ 46 | private async _run(...args: any[]) { 47 | websocketEventMetric.add(1, { 48 | type: this.name, 49 | }); 50 | 51 | try { 52 | return await this.run(...args); 53 | } catch (error) { 54 | this.client.logger.error(error); 55 | await this.client.logger.sentry.captureWithExtras(error, { 56 | Event: this.name, 57 | Arguments: args, 58 | }); 59 | } 60 | } 61 | 62 | /** 63 | * Handle the execution of this event. 64 | * 65 | * @param _args The arguments for our event. 66 | */ 67 | public async run(..._args: any): Promise {} 68 | 69 | /** 70 | * Start listening for this event. 71 | */ 72 | public listen() { 73 | if (this.once) return this.client.once(this.name, this._listener); 74 | 75 | return this.client.on(this.name, this._listener); 76 | } 77 | 78 | /** 79 | * Stop listening for this event. 80 | */ 81 | public removeListener() { 82 | return this.client.off(this.name, this._listener); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/bot/applicationCommands/misc/ping.ts: -------------------------------------------------------------------------------- 1 | import type { APIApplicationCommandInteraction } from "@discordjs/core"; 2 | import { ApplicationCommandType, MessageFlags } from "@discordjs/core"; 3 | import { DiscordSnowflake } from "@sapphire/snowflake"; 4 | import ApplicationCommand from "../../../../lib/classes/ApplicationCommand.js"; 5 | import type Language from "../../../../lib/classes/Language.js"; 6 | import type ExtendedClient from "../../../../lib/extensions/ExtendedClient.js"; 7 | import type { APIInteractionWithArguments } from "../../../../typings/index.js"; 8 | 9 | export default class Ping extends ApplicationCommand { 10 | /** 11 | * Create our ping command. 12 | * 13 | * @param client - Our extended client. 14 | */ 15 | public constructor(client: ExtendedClient) { 16 | super(client, { 17 | options: { 18 | ...client.languageHandler.generateLocalizationsForApplicationCommandOptionType({ 19 | name: "PING_COMMAND_NAME", 20 | description: "PING_COMMAND_DESCRIPTION", 21 | }), 22 | type: ApplicationCommandType.ChatInput, 23 | }, 24 | }); 25 | } 26 | 27 | /** 28 | * Run this application command. 29 | * 30 | * @param options - The options for this command. 31 | * @param options.shardId - The shard ID that this interaction was received on. 32 | * @param options.language - The language to use when replying to the interaction. 33 | * @param options.interaction - The interaction to run this command on. 34 | */ 35 | public override async run({ 36 | interaction, 37 | language, 38 | }: { 39 | interaction: APIInteractionWithArguments; 40 | language: Language; 41 | shardId: number; 42 | }) { 43 | await this.client.api.interactions.reply(interaction.id, interaction.token, { 44 | content: language.get("PING"), 45 | allowed_mentions: { parse: [], replied_user: true }, 46 | flags: MessageFlags.Ephemeral, 47 | }); 48 | 49 | const message = await this.client.api.interactions.getOriginalReply(interaction.application_id, interaction.token); 50 | 51 | const hostLatency = new Date(message.timestamp).getTime() - DiscordSnowflake.timestampFrom(interaction.id); 52 | 53 | return this.client.api.interactions.editReply(interaction.application_id, interaction.token, { 54 | content: language.get("PONG", { 55 | hostLatency, 56 | }), 57 | allowed_mentions: { parse: [], replied_user: true }, 58 | }); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yapper", 3 | "main": "dist/src/index.js", 4 | "type": "module", 5 | "packageManager": "pnpm@9.15.5+sha512.845196026aab1cc3f098a0474b64dfbab2afe7a1b4e91dd86895d8e4aa32a7a6d03049e2d0ad770bbe4de023a7122fb68c1a1d6e0d033c7076085f9d5d4800d4", 6 | "scripts": { 7 | "build": "tsc && cross-env NODE_ENV=development node .", 8 | "start": "cross-env NODE_ENV=production node .", 9 | "translate": "tsx languages/utils/interface.ts && biome format typings/language.d.ts --write --no-errors-on-unmatched", 10 | "prepare": "husky && prisma migrate dev && pnpm run translate", 11 | "tunnel": "cloudflared tunnel run" 12 | }, 13 | "lint-staged": { 14 | "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}": [ 15 | "biome check --write --no-errors-on-unmatched" 16 | ], 17 | "languages/{en-US.json,interface.ts}": [ 18 | "pnpm translate", 19 | "git add typings/language.d.ts" 20 | ] 21 | }, 22 | "dependencies": { 23 | "@discordjs/core": "^2.0.0", 24 | "@discordjs/rest": "^2.4.0", 25 | "@discordjs/ws": "^2.0.0", 26 | "@hono/node-server": "^1.13.2", 27 | "@opentelemetry/api": "^1.9.0", 28 | "@opentelemetry/exporter-metrics-otlp-grpc": "^0.53.0", 29 | "@opentelemetry/exporter-trace-otlp-grpc": "^0.53.0", 30 | "@opentelemetry/resource-detector-docker": "^0.1.2", 31 | "@opentelemetry/resources": "^1.26.0", 32 | "@opentelemetry/sdk-metrics": "^1.26.0", 33 | "@opentelemetry/sdk-node": "^0.53.0", 34 | "@opentelemetry/semantic-conventions": "^1.27.0", 35 | "@prisma/client": "^5.21.1", 36 | "@prisma/instrumentation": "^5.21.1", 37 | "@sapphire/bitfield": "^1.2.2", 38 | "@sapphire/snowflake": "^3.5.3", 39 | "@sentry/node": "^8.34.0", 40 | "@sentry/opentelemetry": "^8.34.0", 41 | "@sentry/tracing": "^7.117.0", 42 | "bufferutil": "^4.0.8", 43 | "colorette": "^2.0.20", 44 | "dotenv-extended": "^2.9.0", 45 | "hono": "^4.6.5", 46 | "husky": "^9.1.6", 47 | "i18next": "^23.16.0", 48 | "i18next-intervalplural-postprocessor": "^3.0.0", 49 | "stripe": "^17.2.1", 50 | "utf-8-validate": "^6.0.4", 51 | "zlib-sync": "^0.1.9" 52 | }, 53 | "devDependencies": { 54 | "@biomejs/biome": "^1.9.4", 55 | "@sentry/types": "^8.34.0", 56 | "@types/node": "^22.7.6", 57 | "cross-env": "^7.0.3", 58 | "lint-staged": "^15.2.10", 59 | "prisma": "^5.21.1", 60 | "tsx": "^4.19.1", 61 | "typescript": "^5.6.3" 62 | }, 63 | "pnpm": { 64 | "overrides": { 65 | "discord-api-types": "0.37.93" 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /lib/classes/Language.ts: -------------------------------------------------------------------------------- 1 | import type { LocaleString } from "@discordjs/core"; 2 | import type { TOptions } from "i18next"; 3 | import enUS from "../../languages/en-US.js"; 4 | import type { LanguageValues } from "../../typings/language"; 5 | import type ExtendedClient from "../extensions/ExtendedClient.js"; 6 | 7 | export type LanguageKeys = keyof typeof enUS; 8 | export type LanguageOptions = Partial; 9 | 10 | export default class Language { 11 | /** 12 | * Our extended client 13 | */ 14 | public readonly client: ExtendedClient; 15 | 16 | /** 17 | * The ID of our language. 18 | */ 19 | public readonly id: LocaleString; 20 | 21 | /** 22 | * Whether or not this language is enabled. 23 | */ 24 | public enabled: boolean; 25 | 26 | /** 27 | * All of the key value pairs for our language. 28 | */ 29 | public language: LanguageOptions; 30 | 31 | /** 32 | * Create our Language class. 33 | * 34 | * @param client Our client. 35 | * @param id The language id. 36 | * @param options The options for our language. 37 | * @param options.enabled Whether or not this language is enabled. 38 | * @param options.language The language options. 39 | */ 40 | public constructor( 41 | client: ExtendedClient, 42 | id: LocaleString, 43 | options: { enabled: boolean; language?: LanguageOptions } = { enabled: true }, 44 | ) { 45 | this.id = id; 46 | this.client = client; 47 | 48 | this.enabled = options.enabled; 49 | // @ts-expect-error 50 | this.language = options.language ?? enUS.default; 51 | } 52 | 53 | /** 54 | * Initialize our language in the i18next instance. 55 | */ 56 | public init() { 57 | this.client.i18n.addResourceBundle( 58 | this.id, 59 | this.client.config.botName.toLowerCase().split(" ").join("_"), 60 | this.language, 61 | true, 62 | true, 63 | ); 64 | } 65 | 66 | /** 67 | * Check if our language has a key. 68 | * 69 | * @param key The key to check for. 70 | * @returns Whether the key exists. 71 | */ 72 | public has(key: string) { 73 | return ( 74 | this.client.i18n.t(key, { 75 | lng: this.enabled ? this.id : "en-US", 76 | }) !== key 77 | ); 78 | } 79 | 80 | /** 81 | * Translate a key. 82 | * 83 | * @param key The key to translate. 84 | * @param args The arguments for the key. 85 | * @returns The translated key. 86 | */ 87 | public get(key: K, args?: O & TOptions) { 88 | if (args && !("interpolation" in args)) args.interpolation = { escapeValue: false }; 89 | 90 | if (!this.enabled) return this.client.i18n.t(key, { ...args }); 91 | if (!this.has(key)) return `"${key} has not been localized for any languages yet."`; 92 | return this.client.i18n.t(key, { ...args, lng: this.id }); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /typings/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | APIApplicationCommandInteractionDataBooleanOption, 3 | APIApplicationCommandInteractionDataIntegerOption, 4 | APIApplicationCommandInteractionDataMentionableOption, 5 | APIApplicationCommandInteractionDataNumberOption, 6 | APIApplicationCommandInteractionDataStringOption, 7 | APIApplicationCommandInteractionDataSubcommandGroupOption, 8 | APIApplicationCommandInteractionDataSubcommandOption, 9 | APIAttachment, 10 | APIInteractionDataResolvedChannel, 11 | APIInteractionDataResolvedGuildMember, 12 | APIRole, 13 | APIUser, 14 | } from "@discordjs/core"; 15 | import type { TranscriptionState } from "../lib/utilities/functions.js"; 16 | 17 | export interface InteractionArguments { 18 | attachments?: Record; 19 | booleans?: Record; 20 | channels?: Record; 21 | focused?: 22 | | APIApplicationCommandInteractionDataIntegerOption 23 | | APIApplicationCommandInteractionDataNumberOption 24 | | APIApplicationCommandInteractionDataStringOption; 25 | integers?: Record; 26 | members?: Record; 27 | mentionables?: Record; 28 | numbers?: Record; 29 | roles?: Record; 30 | strings?: Record; 31 | subCommand?: APIApplicationCommandInteractionDataSubcommandOption; 32 | subCommandGroup?: APIApplicationCommandInteractionDataSubcommandGroupOption; 33 | users?: Record; 34 | } 35 | 36 | export type APIInteractionWithArguments = T & { 37 | arguments: InteractionArguments; 38 | }; 39 | 40 | export interface RunResponse { 41 | id: string; 42 | status: TranscriptionState; 43 | } 44 | 45 | export interface RunPodRunSyncResponse extends RunResponse { 46 | delayTime: number; 47 | executionTime: number; 48 | output: { 49 | detected_language: string; 50 | device: string; 51 | model: string; 52 | segments: { 53 | avg_logprob: number; 54 | compression_ratio: number; 55 | end: number; 56 | id: number; 57 | no_speech_prob: number; 58 | seek: number; 59 | start: number; 60 | temperature: number; 61 | text: string; 62 | tokens: number[]; 63 | }[]; 64 | transcription: string; 65 | translation: string; 66 | }; 67 | status: TranscriptionState.COMPLETED; 68 | webhook?: string; 69 | } 70 | 71 | export interface RunPodHealthResponse { 72 | jobs: { 73 | completed: number; 74 | failed: number; 75 | inProgress: number; 76 | inQueue: number; 77 | retried: number; 78 | }; 79 | workers: { 80 | idle: number; 81 | initializing: number; 82 | ready: number; 83 | running: number; 84 | throttled: number; 85 | }; 86 | } 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Yapper 2 | 3 | Welcome to the repository for Yapper, a Discord bot created to transcribe Discord voice messages. 4 | 5 | If you'd like to add Yapper to your own server instead of self hosting it, click [here](https://discord.com/oauth2/authorize?client_id=1189388666699788349). 6 | 7 | ## Self Hosting 8 | 9 | ### Running with Docker: 10 | 11 | Please duplicate `.env.example` to `.env.prod` and `.env.dev`, then modify all the values accordingly and do the same for `./prisma/.env`. 12 | 13 | The default fields here are already configured for running in Docker. 14 | 15 | Then: `docker compose up` :) 16 | 17 | 18 | ### Running Locally: 19 | To run this bot you will need Node.js `v18.20.2` or higher. Then, using a system installed `pnpm` (or, `pnpm` provided by corepack with `corepack prepare`) run `pnpm install` to install the bot's dependencies. 20 | 21 | Then, please duplicate `.env.example` to `.env.prod` and `.env.dev`, then modify all the values accordingly and do the same for `./prisma/.env`. 22 | 23 | This bot uses PostgreSQL! The format for a `DATABASE_URL` is: 24 | ``` 25 | postgresql://[user[:password]@][host][:port][/dbname] 26 | ``` 27 | 28 |
29 | Using RunPod 30 |
31 | 32 | You will be asked for an `RUNPOD_API_KEY`, `RUNPOD_LQ_ENDPOINT_ID`, and `RUNPOD_HQ_ENDPOINT_ID`. 33 | 34 | From the [RunPod Console](https://runpod.io/console), select ["Serverless"](https://www.runpod.io/console/serverless), then ["Quick Deploy"](https://www.runpod.io/console/serverless/quick-deploy) and select "Faster Whisper". RunPod will recommend a 24 GB GPU, this is perfectly fine. However, feel free to switch to the "16 GB GPU". 35 | 36 | For many developers, you may set your `RUNPOD_LQ_ENDPOINT_ID` *and* `RUNPOD_HQ_ENDPOINT_ID` to the value under the name "Faster Whisper", or whatever custom name you've provided: 37 | ![image](docs/runpod_endpoint.png) 38 | 39 | Next, select ["Settings"](https://runpod.io/console/serverless/user/settings), expand "API Keys" and create a new API Key with "Read" permission. Write permissions will allow this API key to modify your account, which is probably not what you want. This key is your `RUNPOD_API_KEY`. 40 |
41 | 42 | ## Running without Docker: 43 | 44 | * Ready the Database with `pnpm prisma migrate dev`. 45 | 46 | Then, to run the bot in production mode use `pnpm start`. 47 | 48 | Or, to run the bot in development mode use `pnpm build`. 49 | 50 | > [!TIP] 51 | > Using development mode will provide you with more detailed logs and push guild commands in the specified `DEVELOPMENT_GUILD_ID` instead of global commands. 52 | 53 | If you run into any problems with either [please create an issue](/issues/new). 54 | 55 | ### Need a Tunnel to Develop Locally? 56 | 57 | Follow the instructions here: 58 | https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/get-started/create-local-tunnel 59 | 60 | After that's complete, use `pnpm tunnel`! 61 | -------------------------------------------------------------------------------- /src/bot/events/interactionCreate.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | APIInteraction, 3 | APIMessageComponentButtonInteraction, 4 | APIMessageComponentInteraction, 5 | APIMessageComponentSelectMenuInteraction, 6 | ToEventProps, 7 | } from "@discordjs/core"; 8 | import { ComponentType, GatewayDispatchEvents, InteractionContextType, InteractionType } from "@discordjs/core"; 9 | import EventHandler from "../../../lib/classes/EventHandler.js"; 10 | import type ExtendedClient from "../../../lib/extensions/ExtendedClient.js"; 11 | import { interactionsMetric, userLocalesMetric } from "../../../lib/utilities/metrics.js"; 12 | 13 | export default class InteractionCreate extends EventHandler { 14 | public constructor(client: ExtendedClient) { 15 | super(client, GatewayDispatchEvents.InteractionCreate); 16 | } 17 | 18 | /** 19 | * Handle the creation of a new interaction. 20 | * 21 | * https://discord.com/developers/docs/topics/gateway-events#interaction-create 22 | */ 23 | public override async run({ shardId, data }: ToEventProps) { 24 | // This is very cursed, but it works. 25 | const dd = data.data as any; 26 | 27 | interactionsMetric.add(1, { 28 | name: dd.name ?? dd.custom_id ?? "null", 29 | type: data.type, 30 | context: data.context ? InteractionContextType[data.context] : "UNKNOWN", 31 | shard: shardId, 32 | }); 33 | 34 | userLocalesMetric.add(1, { 35 | locale: (data.member ?? data).user?.locale ?? this.client.languageHandler.defaultLanguage?.id, 36 | shard: shardId, 37 | }); 38 | 39 | if (data.type === InteractionType.ApplicationCommand) { 40 | return this.client.applicationCommandHandler.handleApplicationCommand({ 41 | data, 42 | shardId, 43 | }); 44 | } 45 | 46 | if (data.type === InteractionType.ApplicationCommandAutocomplete) { 47 | return this.client.autoCompleteHandler.handleAutoComplete({ 48 | data, 49 | shardId, 50 | }); 51 | } 52 | 53 | if (data.type === InteractionType.MessageComponent) { 54 | if (isMessageComponentButtonInteraction(data)) return this.client.buttonHandler.handleButton({ data, shardId }); 55 | if (isMessageComponentSelectMenuInteraction(data)) 56 | return this.client.selectMenuHandler.handleSelectMenu({ data, shardId }); 57 | } else if (data.type === InteractionType.ModalSubmit) { 58 | return this.client.modalHandler.handleModal({ 59 | data, 60 | shardId, 61 | }); 62 | } 63 | } 64 | } 65 | 66 | // https://github.com/discordjs/discord-api-types/blob/189e91d62cb898b418ca11434280558d50948dd8/utils/v10.ts#L137-L159 67 | 68 | function isMessageComponentButtonInteraction( 69 | interaction: APIMessageComponentInteraction, 70 | ): interaction is APIMessageComponentButtonInteraction { 71 | return interaction.data.component_type === ComponentType.Button; 72 | } 73 | 74 | function isMessageComponentSelectMenuInteraction( 75 | interaction: APIMessageComponentInteraction, 76 | ): interaction is APIMessageComponentSelectMenuInteraction { 77 | return [ 78 | ComponentType.StringSelect, 79 | ComponentType.UserSelect, 80 | ComponentType.RoleSelect, 81 | ComponentType.MentionableSelect, 82 | ComponentType.ChannelSelect, 83 | ].includes(interaction.data.component_type); 84 | } 85 | -------------------------------------------------------------------------------- /lib/classes/Type.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2019 dirigeants, MIT License 2 | 3 | /** 4 | * The class for deep checking Types 5 | */ 6 | export default class Type { 7 | /** 8 | * The value to generate a deep Type of 9 | */ 10 | public value: any; 11 | 12 | /** 13 | * The shallow type of this 14 | */ 15 | public is: string; 16 | 17 | /** 18 | * The parent of this type 19 | */ 20 | private readonly parent?: Type | undefined; 21 | 22 | /** 23 | * The child keys of this Type 24 | */ 25 | private readonly childKeys: Map; 26 | 27 | /** 28 | * The child values of this Type 29 | */ 30 | private readonly childValues: Map; 31 | 32 | /** 33 | * @param value The value to generate a deep Type of 34 | * @param [parent] The parent value used in recursion 35 | */ 36 | public constructor(value: any, parent?: Type) { 37 | this.value = value; 38 | this.is = Type.resolve(value); 39 | this.parent = parent; 40 | this.childKeys = new Map(); 41 | this.childValues = new Map(); 42 | } 43 | 44 | /** 45 | * The type string for the children of this Type 46 | */ 47 | private get childTypes() { 48 | if (!this.childValues.size) return ""; 49 | return `<${(this.childKeys.size ? `${Type.list(this.childKeys)}, ` : "") + Type.list(this.childValues)}>`; 50 | } 51 | 52 | /** 53 | * The full type string generated. 54 | */ 55 | public toString(): string { 56 | this.check(); 57 | return this.is + this.childTypes; 58 | } 59 | 60 | /** 61 | * The subtype to create based on this.value's sub value. 62 | */ 63 | public addValue(value: any) { 64 | const child = new Type(value, this); 65 | this.childValues.set(child.is, child); 66 | } 67 | 68 | /** 69 | * The subtype to create based on this.value's entries. 70 | */ 71 | private addEntry([key, value]: [string, any]) { 72 | const child = new Type(key, this); 73 | this.childKeys.set(child.is, child); 74 | this.addValue(value); 75 | } 76 | 77 | /** 78 | * Walks the linked list backwards, for checking circulars. 79 | */ 80 | private *parents() { 81 | let current: Type | undefined = this; 82 | // biome-ignore lint/suspicious/noAssignInExpressions: 83 | while ((current = this.parent)) yield current; 84 | } 85 | 86 | /** 87 | * Get the deep type name that defines the input. 88 | */ 89 | private check() { 90 | if (Object.isFrozen(this)) return; 91 | if (typeof this.value === "object" && this.isCircular()) this.is = `[Circular:${this.is}]`; 92 | else if (this.value instanceof Map) for (const entry of this.value) this.addEntry(entry); 93 | else if (Array.isArray(this.value) || this.value instanceof Set) 94 | for (const value of this.value) this.addValue(value); 95 | else if (this.is === "Object") this.is = "any"; 96 | Object.freeze(this); 97 | } 98 | 99 | /** 100 | * Checks if the value of this Type is a circular reference to any parent. 101 | */ 102 | private isCircular(): boolean { 103 | for (const parent of this.parents()) if (parent.value === this.value) return true; 104 | return false; 105 | } 106 | 107 | /** 108 | * Resolves the type name that defines the input. 109 | */ 110 | private static resolve(value: any): string { 111 | const type = typeof value; 112 | switch (type) { 113 | case "object": 114 | return value === null ? "null" : value.constructor?.name || "any"; 115 | case "function": 116 | return `${value.constructor.name}(${value.length}-arity)`; 117 | case "undefined": 118 | return "void"; 119 | default: 120 | return type; 121 | } 122 | } 123 | 124 | /** 125 | * Joins the list of child types. 126 | */ 127 | private static list(values: Map): string { 128 | return values.has("any") ? "any" : [...values.values()].sort().join(" | "); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /lib/classes/Logger.ts: -------------------------------------------------------------------------------- 1 | import { env } from "node:process"; 2 | import { inspect } from "node:util"; 3 | import type { RESTPostAPIWebhookWithTokenJSONBody } from "@discordjs/core"; 4 | import { bgGreenBright, bgMagentaBright, bgRedBright, bgYellowBright, bold } from "colorette"; 5 | import { sentry } from "../utilities/instrumentation.js"; 6 | 7 | export class Logger { 8 | /** 9 | * Our Sentry client. 10 | */ 11 | public readonly sentry; 12 | 13 | /** 14 | * A Map whose key value pair correlates to the type of log we want and the WebhookClient for the log. 15 | */ 16 | private readonly webhooks: Map; 17 | 18 | /** 19 | * Create our logger. 20 | */ 21 | public constructor() { 22 | this.sentry = sentry; 23 | this.webhooks = new Map(); 24 | } 25 | 26 | /** 27 | * Get the current timestamp. 28 | * 29 | * @returns The current timestamp in the format of MM/DD/YYYY @ HH:mm:SS. 30 | */ 31 | public get timestamp(): string { 32 | const nowISOString = new Date().toISOString(); 33 | const [year, month, day] = nowISOString.slice(0, 10).split("-"); 34 | return `${month}/${day}/${year} @ ${nowISOString.slice(11, 19)}`; 35 | } 36 | 37 | /** 38 | * Log out a debug statement. 39 | * 40 | * @param args The arguments to log out. 41 | */ 42 | public debug(...args: any[]): void { 43 | console.log( 44 | bold(bgMagentaBright(`[${this.timestamp}]`)), 45 | bold(args.map((arg) => (typeof arg === "string" ? arg : inspect(arg, { depth: 1 }))).join(" ")), 46 | ); 47 | } 48 | 49 | /** 50 | * Log out an info statement. 51 | * 52 | * @param args The arguments to log out. 53 | */ 54 | public info(...args: any[]): void { 55 | console.log( 56 | bold(bgGreenBright(`[${this.timestamp}]`)), 57 | bold(args.map((arg) => (typeof arg === "string" ? arg : inspect(arg, { depth: 1 }))).join(" ")), 58 | ); 59 | } 60 | 61 | /** 62 | * Log out a warn statement. 63 | * 64 | * @param args The arguments to log out. 65 | */ 66 | public warn(...args: any[]): void { 67 | console.log( 68 | bold(bgYellowBright(`[${this.timestamp}]`)), 69 | bold(args.map((arg) => (typeof arg === "string" ? arg : inspect(arg, { depth: 1 }))).join(" ")), 70 | ); 71 | } 72 | 73 | /** 74 | * Log out an error statement. 75 | * 76 | * @param error The error to log out. 77 | * @param args The arguments to log out. 78 | */ 79 | public error(error: any | null, ...args: any[]): void { 80 | if (error) 81 | console.log( 82 | bold(bgRedBright(`[${this.timestamp}]`)), 83 | error, 84 | bold(args.map((arg) => (typeof arg === "string" ? arg : inspect(arg, { depth: 1 }))).join(" ")), 85 | ); 86 | else 87 | console.log( 88 | bold(bgRedBright(`[${this.timestamp}]`)), 89 | bold(args.map((arg) => (typeof arg === "string" ? arg : inspect(arg, { depth: 1 }))).join(" ")), 90 | ); 91 | } 92 | 93 | /** 94 | * Log a message to Discord through a webhook. 95 | * 96 | * @param type The webhook type to log out to, make sure that the webhook provided in your .env file is in the format ${TYPE}_HOOK=... 97 | * @returns The message that was sent. 98 | */ 99 | public async webhookLog(type: string, options: RESTPostAPIWebhookWithTokenJSONBody) { 100 | if (!type) throw new Error("No webhook type has been provided!"); 101 | if (!this.webhooks.get(type.toLowerCase())) { 102 | const webhookURL = env[`${type.toUpperCase()}_HOOK`]; 103 | if (!webhookURL) { 104 | this.warn(`No webhook URL has been provided for ${type}!`); 105 | return; 106 | } 107 | 108 | this.webhooks.set(type.toLowerCase(), webhookURL); 109 | } 110 | 111 | const webhookURL = this.webhooks.get(type.toLowerCase()); 112 | 113 | return fetch(webhookURL!, { 114 | method: "POST", 115 | body: JSON.stringify(options), 116 | headers: { "Content-Type": "application/json" }, 117 | }); 118 | } 119 | } 120 | 121 | export default new Logger(); 122 | -------------------------------------------------------------------------------- /src/bot/events/messageCreate.ts: -------------------------------------------------------------------------------- 1 | import type { APIMessage, GatewayMessageCreateDispatchData, ToEventProps } from "@discordjs/core"; 2 | import { ButtonStyle, ComponentType, GatewayDispatchEvents, MessageFlags, RESTJSONErrorCodes } from "@discordjs/core"; 3 | import { DiscordAPIError } from "@discordjs/rest"; 4 | import EventHandler from "../../../lib/classes/EventHandler.js"; 5 | import type ExtendedClient from "../../../lib/extensions/ExtendedClient.js"; 6 | import Functions, { TranscriptionModel } from "../../../lib/utilities/functions.js"; 7 | 8 | export default class MessageCreate extends EventHandler { 9 | public constructor(client: ExtendedClient) { 10 | super(client, GatewayDispatchEvents.MessageCreate); 11 | } 12 | 13 | /** 14 | * Sent when a message is created. The inner payload is a message object with the following extra fields: 15 | * 16 | * https://discord.com/developers/docs/topics/gateway-events#message-create 17 | */ 18 | public override async run({ shardId, data: message }: ToEventProps) { 19 | if (message.author.bot) return; 20 | 21 | if (message.guild_id && (message.flags ?? 0) & MessageFlags.IsVoiceMessage) { 22 | const ignoredUser = await this.client.prisma.ignoredUser.findUnique({ 23 | where: { userId: message.author.id }, 24 | }); 25 | 26 | if (ignoredUser && (ignoredUser.type === "ALL" || ignoredUser?.type === "AUTO_TRANSCRIPTION")) { 27 | return this.client.textCommandHandler.handleTextCommand({ data: message, shardId }); 28 | } 29 | 30 | const autoTranscriptionsEnabled = await this.client.prisma.autoTranscriptVoiceMessages.findUnique({ 31 | where: { guildId: message.guild_id }, 32 | }); 33 | 34 | if (!autoTranscriptionsEnabled) { 35 | return this.client.textCommandHandler.handleTextCommand({ data: message, shardId }); 36 | } 37 | 38 | const attachment = message.attachments.find((attachment) => 39 | this.client.config.allowedFileTypes.includes(attachment.content_type ?? ""), 40 | )!; 41 | 42 | let responseMessage: APIMessage; 43 | 44 | try { 45 | responseMessage = await this.client.api.channels.createMessage(message.channel_id, { 46 | content: ":writing_hand: Transcribing, this may take a moment...", 47 | message_reference: { message_id: message.id }, 48 | allowed_mentions: { parse: [] }, 49 | }); 50 | } catch (error) { 51 | if ( 52 | error instanceof DiscordAPIError && 53 | error.code === RESTJSONErrorCodes.CannotReplyWithoutPermissionToReadMessageHistory 54 | ) { 55 | responseMessage = await this.client.api.channels.createMessage(message.channel_id, { 56 | content: ":writing_hand: Transcribing, this may take a moment...", 57 | allowed_mentions: { parse: [] }, 58 | components: [ 59 | { 60 | components: [ 61 | { 62 | type: ComponentType.Button, 63 | style: ButtonStyle.Link, 64 | url: `https://discord.com/channels/${message.guild_id ?? "@me"}/${message.channel_id}/${message.id}`, 65 | label: "Transcribed Message", 66 | }, 67 | ], 68 | type: ComponentType.ActionRow, 69 | }, 70 | ], 71 | }); 72 | } else throw error; 73 | } 74 | 75 | const endpointHealth = await Functions.getEndpointHealth(TranscriptionModel.LARGEV3); 76 | 77 | let job; 78 | 79 | if (endpointHealth.workers.running <= 0) { 80 | job = await Functions.transcribeAudio(attachment.url, "run", TranscriptionModel.MEDIUM); 81 | } else { 82 | job = await Functions.transcribeAudio(attachment.url, "run", TranscriptionModel.LARGEV3); 83 | } 84 | 85 | return this.client.prisma.job.create({ 86 | data: { 87 | id: job.id, 88 | attachmentUrl: attachment.url, 89 | model: endpointHealth.workers.running <= 0 ? TranscriptionModel.MEDIUM : TranscriptionModel.LARGEV3, 90 | channelId: message.channel_id, 91 | guildId: message.guild_id ?? "@me", 92 | initialMessageId: message.id, 93 | responseMessageId: responseMessage.id, 94 | }, 95 | }); 96 | } 97 | 98 | return this.client.textCommandHandler.handleTextCommand({ data: message, shardId }); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /lib/utilities/sentry.ts: -------------------------------------------------------------------------------- 1 | import { env } from "node:process"; 2 | import { format } from "node:util"; 3 | import type { APIInteraction, APIMessage } from "@discordjs/core"; 4 | import * as Sentry from "@sentry/node"; 5 | import { load } from "dotenv-extended"; 6 | import type { HonoRequest } from "hono"; 7 | 8 | load({ 9 | path: env.NODE_ENV === "production" ? ".env.prod" : ".env.dev", 10 | }); 11 | 12 | /** 13 | * We basically extend the functionality of Sentry's init function here as we tack on a couple of our own custom error handlers. 14 | */ 15 | export default function init(): typeof Sentry & { 16 | captureWithExtras(error: any, extras: Record): Promise; 17 | captureWithInteraction(error: any, interaction: APIInteraction): Promise; 18 | captureWithMessage(error: any, message: APIMessage): Promise; 19 | captureWithRequest( 20 | error: any, 21 | request: HonoRequest, 22 | response: Response, 23 | query: Record, 24 | ): Promise; 25 | } { 26 | Sentry.init({ 27 | tracesSampleRate: 1, 28 | dsn: env.SENTRY_DSN, 29 | }); 30 | 31 | return { 32 | ...Sentry, 33 | 34 | /** 35 | * Capture a Sentry error about an interaction. 36 | * 37 | * @param error The error to capture. 38 | * @param interaction The interaction that caused the error. 39 | * @return The sentry error ID. 40 | */ 41 | captureWithInteraction: async (error: any, interaction: APIInteraction): Promise => { 42 | return new Promise((resolve) => { 43 | Sentry.withScope((scope) => { 44 | scope.setExtra("Environment", env.NODE_ENV); 45 | scope.setUser({ 46 | username: (interaction.member?.user ?? interaction.user!).username, 47 | id: (interaction.member ?? interaction).user!.id, 48 | }); 49 | scope.setExtra("Interaction", format(interaction)); 50 | 51 | resolve(Sentry.captureException(error)); 52 | }); 53 | }); 54 | }, 55 | 56 | /** 57 | * Capture a Sentry error about a message. 58 | * 59 | * @param error The error to capture. 60 | * @param message The message that caused the error. 61 | * @return The sentry error ID. 62 | */ 63 | captureWithMessage: async (error: any, message: APIMessage): Promise => { 64 | return new Promise((resolve) => { 65 | Sentry.withScope((scope) => { 66 | scope.setExtra("Environment", env.NODE_ENV); 67 | scope.setUser({ 68 | username: `${message.author.username}#${message.author.discriminator}`, 69 | id: message.author.id, 70 | }); 71 | scope.setExtra("Message", format(message)); 72 | 73 | resolve(Sentry.captureException(error)); 74 | }); 75 | }); 76 | }, 77 | 78 | captureWithRequest: async ( 79 | error: any, 80 | request: HonoRequest, 81 | response: Response, 82 | query: Record, 83 | ): Promise => { 84 | return new Promise((resolve) => { 85 | Sentry.withScope((scope) => { 86 | scope.setExtra("Environment", env.NODE_ENV); 87 | scope.setExtra("Method", request.method); 88 | scope.setExtra("X-Forwarded-For", request.header("X-Forwarded-For")); 89 | 90 | scope.setExtra("User Agent", request.header("user-agent")); 91 | 92 | if (request.url) { 93 | scope.setExtra("Path", request.url.split("?")[0]); 94 | scope.setExtra("Path + Query", request.url); 95 | scope.setExtra("Query", JSON.stringify(query, null, 4)); 96 | } 97 | 98 | scope.setExtra("Request", JSON.stringify(request, null, 4)); 99 | scope.setExtra("Response", JSON.stringify(response, null, 4)); 100 | scope.setExtra("Cookie", request.raw.headers.get("cookie")); 101 | 102 | resolve(Sentry.captureException(error)); 103 | }); 104 | }); 105 | }, 106 | 107 | /** 108 | * Capture a Sentry error with extra details. 109 | * 110 | * @param error The error to capture. 111 | * @param extras Extra details to add to the error. 112 | * @return The sentry error ID. 113 | */ 114 | captureWithExtras: async (error: any, extras: Record) => { 115 | return new Promise((resolve) => { 116 | Sentry.withScope((scope) => { 117 | scope.setExtra("Environment", env.NODE_ENV); 118 | for (const [key, value] of Object.entries(extras)) scope.setExtra(key, format(value)); 119 | resolve(Sentry.captureException(error)); 120 | }); 121 | }); 122 | }, 123 | }; 124 | } 125 | -------------------------------------------------------------------------------- /src/bot/applicationCommands/config/ignore.ts: -------------------------------------------------------------------------------- 1 | import type { APIApplicationCommandInteraction } from "@discordjs/core"; 2 | import { ApplicationCommandOptionType, ApplicationCommandType, MessageFlags } from "@discordjs/core"; 3 | import type { IgnoreType } from "@prisma/client"; 4 | import ApplicationCommand from "../../../../lib/classes/ApplicationCommand.js"; 5 | import type Language from "../../../../lib/classes/Language.js"; 6 | import type ExtendedClient from "../../../../lib/extensions/ExtendedClient.js"; 7 | import type { APIInteractionWithArguments } from "../../../../typings/index.js"; 8 | 9 | export default class Ignore extends ApplicationCommand { 10 | /** 11 | * Create our ignore command. 12 | * 13 | * @param client - Our extended client. 14 | */ 15 | public constructor(client: ExtendedClient) { 16 | super(client, { 17 | options: { 18 | ...client.languageHandler.generateLocalizationsForApplicationCommandOptionType({ 19 | name: "IGNORE_COMMAND_NAME", 20 | description: "IGNORE_COMMAND_DESCRIPTION", 21 | }), 22 | options: [ 23 | { 24 | ...client.languageHandler.generateLocalizationsForApplicationCommandOptionType({ 25 | name: "IGNORE_COMMAND_CONTEXT_MENU_SUB_COMMAND_NAME", 26 | description: "IGNORE_COMMAND_CONTEXT_MENU_SUB_COMMAND_DESCRIPTION", 27 | }), 28 | type: ApplicationCommandOptionType.Subcommand, 29 | }, 30 | { 31 | ...client.languageHandler.generateLocalizationsForApplicationCommandOptionType({ 32 | name: "IGNORE_COMMAND_AUTO_TRANSCRIPTION_SUB_COMMAND_NAME", 33 | description: "IGNORE_COMMAND_AUTO_TRANSCRIPTION_SUB_COMMAND_DESCRIPTION", 34 | }), 35 | type: ApplicationCommandOptionType.Subcommand, 36 | }, 37 | { 38 | ...client.languageHandler.generateLocalizationsForApplicationCommandOptionType({ 39 | name: "IGNORE_COMMAND_ALL_SUB_COMMAND_NAME", 40 | description: "IGNORE_COMMAND_ALL_SUB_COMMAND_DESCRIPTION", 41 | }), 42 | type: ApplicationCommandOptionType.Subcommand, 43 | }, 44 | ], 45 | type: ApplicationCommandType.ChatInput, 46 | }, 47 | }); 48 | } 49 | 50 | /** 51 | * Run this application command. 52 | * 53 | * @param options - The options for this command. 54 | * @param options.shardId - The shard ID that this interaction was received on. 55 | * @param options.language - The language to use when replying to the interaction. 56 | * @param options.interaction - The interaction to run this command on. 57 | */ 58 | public override async run({ 59 | interaction, 60 | language, 61 | }: { 62 | interaction: APIInteractionWithArguments; 63 | language: Language; 64 | shardId: number; 65 | }) { 66 | const isAlreadyBlocked = await this.client.prisma.ignoredUser.findUnique({ 67 | where: { 68 | userId: (interaction.member ?? interaction).user!.id, 69 | }, 70 | }); 71 | 72 | if (isAlreadyBlocked && isAlreadyBlocked.type === interaction.arguments.subCommand?.name.toUpperCase()) 73 | return Promise.all([ 74 | this.client.prisma.ignoredUser.delete({ 75 | where: { 76 | userId: (interaction.member ?? interaction).user!.id, 77 | }, 78 | }), 79 | this.client.api.interactions.reply(interaction.id, interaction.token, { 80 | embeds: [ 81 | { 82 | title: language.get("UNIGORED_SUCCESSFULLY_TITLE"), 83 | description: language.get("UNIGORED_SUCCESSFULLY_DESCRIPTION"), 84 | color: this.client.config.colors.success, 85 | }, 86 | ], 87 | flags: MessageFlags.Ephemeral, 88 | allowed_mentions: { parse: [], replied_user: true }, 89 | }), 90 | ]); 91 | 92 | return Promise.all([ 93 | this.client.prisma.ignoredUser.create({ 94 | data: { 95 | userId: (interaction.member ?? interaction).user!.id, 96 | type: interaction.arguments.subCommand?.name.toUpperCase() as IgnoreType, 97 | }, 98 | }), 99 | this.client.api.interactions.reply(interaction.id, interaction.token, { 100 | embeds: [ 101 | { 102 | title: language.get("IGNORED_SUCCESSFULLY_TITLE"), 103 | description: language.get("IGNORED_SUCCESSFULLY_DESCRIPTION"), 104 | color: this.client.config.colors.success, 105 | }, 106 | ], 107 | flags: MessageFlags.Ephemeral, 108 | allowed_mentions: { parse: [], replied_user: true }, 109 | }), 110 | ]); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/bot/textCommands/admin/eval.ts: -------------------------------------------------------------------------------- 1 | import { inspect } from "node:util"; 2 | import type { GatewayMessageCreateDispatchData } from "@discordjs/core"; 3 | import type Language from "../../../../lib/classes/Language.js"; 4 | import StopWatch from "../../../../lib/classes/StopWatch.js"; 5 | import TextCommand from "../../../../lib/classes/TextCommand.js"; 6 | import Type from "../../../../lib/classes/Type.js"; 7 | import type ExtendedClient from "../../../../lib/extensions/ExtendedClient.js"; 8 | 9 | export default class Eval extends TextCommand { 10 | /** 11 | * Create our eval command. 12 | * 13 | * @param client Our extended client. 14 | */ 15 | public constructor(client: ExtendedClient) { 16 | super(client, { 17 | name: "eval", 18 | description: "Evaluates arbitrary JavaScript code.", 19 | devOnly: true, 20 | }); 21 | } 22 | 23 | /** 24 | * Run this text command. 25 | * 26 | * @param options The options for this command. 27 | * @param options.args The arguments for this command. 28 | * @param options.language The language for this command. 29 | * @param options.message The message that triggered this command. 30 | * @param options.shardId The shard ID that this command was triggered on. 31 | */ 32 | public override async run({ 33 | message, 34 | args, 35 | }: { 36 | args: string[]; 37 | language: Language; 38 | message: GatewayMessageCreateDispatchData; 39 | shardId: number; 40 | }) { 41 | this.client.logger.info( 42 | `${message.author.username}#${message.author.discriminator} ran eval in ${message.guild_id}, ${args.join(" ")}`, 43 | ); 44 | 45 | const { success, result, time, type } = await this.eval(message, args.join(" ")); 46 | if (message.content.includes("--silent")) return null; 47 | 48 | if (result.length > 4_087) 49 | return this.client.api.channels.createMessage(message.channel_id, { 50 | embeds: [ 51 | { 52 | title: success ? "🆗 Evaluated successfully." : "🆘 JavaScript failed.", 53 | description: `Output too long for Discord, view it [here](${await this.client.functions.uploadToHastebin( 54 | result, 55 | { type: "ts" }, 56 | )}).`, 57 | fields: [ 58 | { 59 | name: "Type", 60 | value: `\`\`\`ts\n${type}\`\`\`\n${time}`, 61 | }, 62 | ], 63 | color: success ? this.client.config.colors.success : this.client.config.colors.error, 64 | }, 65 | ], 66 | allowed_mentions: { parse: [], replied_user: true }, 67 | }); 68 | 69 | return this.client.api.channels.createMessage(message.channel_id, { 70 | embeds: [ 71 | { 72 | title: success ? "🆗 Evaluated successfully." : "🆘 JavaScript failed.", 73 | description: `\`\`\`js\n${result}\`\`\``, 74 | fields: [ 75 | { 76 | name: "Type", 77 | value: `\`\`\`ts\n${type}\`\`\`\n${time}`, 78 | }, 79 | ], 80 | color: success ? this.client.config.colors.success : this.client.config.colors.error, 81 | }, 82 | ], 83 | allowed_mentions: { parse: [], replied_user: true }, 84 | }); 85 | } 86 | 87 | private async eval(message: GatewayMessageCreateDispatchData, code: string) { 88 | // biome-ignore lint/style/noParameterAssign: sanitizing 89 | code = code.replaceAll(/[“”]/g, '"').replaceAll(/[‘’]/g, "'"); 90 | const stopwatch = new StopWatch(); 91 | let success; 92 | let syncTime; 93 | let asyncTime; 94 | let result; 95 | let thenable = false; 96 | let type; 97 | try { 98 | // biome-ignore lint/style/noParameterAssign: formatting 99 | if (message.content.includes("--async")) code = `(async () => {\n${code}\n})();`; 100 | // biome-ignore lint/security/noGlobalEval: this is the eval command 101 | result = eval(code); 102 | syncTime = stopwatch.toString(); 103 | type = new Type(result); 104 | if (this.client.functions.isThenable(result)) { 105 | thenable = true; 106 | stopwatch.restart(); 107 | result = await result; 108 | asyncTime = stopwatch.toString(); 109 | type.addValue(result); 110 | } 111 | 112 | success = true; 113 | } catch (error: any) { 114 | if (!syncTime) syncTime = stopwatch.toString(); 115 | if (!type) type = new Type(error); 116 | if (thenable && !asyncTime) asyncTime = stopwatch.toString(); 117 | result = error; 118 | success = false; 119 | } 120 | 121 | stopwatch.stop(); 122 | return { 123 | success, 124 | type, 125 | time: this.formatTime(syncTime, asyncTime), 126 | result: inspect(result), 127 | }; 128 | } 129 | 130 | private formatTime(syncTime: string, asyncTime?: string) { 131 | return asyncTime ? `⏱ ${asyncTime}<${syncTime}>` : `⏱ ${syncTime}`; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/bot/applicationCommands/config/config.ts: -------------------------------------------------------------------------------- 1 | import type { APIApplicationCommandInteraction } from "@discordjs/core"; 2 | import { ApplicationCommandOptionType, ApplicationCommandType, PermissionFlagsBits } from "@discordjs/core"; 3 | import ApplicationCommand from "../../../../lib/classes/ApplicationCommand.js"; 4 | import type Language from "../../../../lib/classes/Language.js"; 5 | import type ExtendedClient from "../../../../lib/extensions/ExtendedClient.js"; 6 | import type { APIInteractionWithArguments } from "../../../../typings/index.js"; 7 | 8 | export default class Config extends ApplicationCommand { 9 | /** 10 | * Create our config command. 11 | * 12 | * @param client - Our extended client. 13 | */ 14 | public constructor(client: ExtendedClient) { 15 | super(client, { 16 | options: { 17 | ...client.languageHandler.generateLocalizationsForApplicationCommandOptionType({ 18 | name: "CONFIG_COMMAND_NAME", 19 | description: "CONFIG_COMMAND_DESCRIPTION", 20 | }), 21 | options: [ 22 | { 23 | ...client.languageHandler.generateLocalizationsForApplicationCommandOptionType({ 24 | name: "CONFIG_COMMAND_AUTO_TRANSCRIPT_VOICE_MESSAGES_SUB_COMMAND_GROUP_NAME", 25 | description: "CONFIG_COMMAND_AUTO_TRANSCRIPT_VOICE_MESSAGES_SUB_COMMAND_GROUP_DESCRIPTION", 26 | }), 27 | options: [ 28 | { 29 | ...client.languageHandler.generateLocalizationsForApplicationCommandOptionType({ 30 | name: "CONFIG_COMMAND_AUTO_TRANSCRIPT_VOICE_MESSAGES_SUB_COMMAND_GROUP_ENABLE_SUB_COMMAND_NAME", 31 | description: 32 | "CONFIG_COMMAND_AUTO_TRANSCRIPT_VOICE_MESSAGES_SUB_COMMAND_GROUP_ENABLE_SUB_COMMAND_DESCRIPTION", 33 | }), 34 | type: ApplicationCommandOptionType.Subcommand, 35 | }, 36 | { 37 | ...client.languageHandler.generateLocalizationsForApplicationCommandOptionType({ 38 | name: "CONFIG_COMMAND_AUTO_TRANSCRIPT_VOICE_MESSAGES_SUB_COMMAND_GROUP_DISABLE_SUB_COMMAND_NAME", 39 | description: 40 | "CONFIG_COMMAND_AUTO_TRANSCRIPT_VOICE_MESSAGES_SUB_COMMAND_GROUP_DISABLE_SUB_COMMAND_DESCRIPTION", 41 | }), 42 | type: ApplicationCommandOptionType.Subcommand, 43 | }, 44 | ], 45 | type: ApplicationCommandOptionType.SubcommandGroup, 46 | }, 47 | ], 48 | default_member_permissions: PermissionFlagsBits.ManageGuild.toString(), 49 | type: ApplicationCommandType.ChatInput, 50 | }, 51 | }); 52 | } 53 | 54 | /** 55 | * Run this application command. 56 | * 57 | * @param options - The options for this command. 58 | * @param options.shardId - The shard ID that this interaction was received on. 59 | * @param options.language - The language to use when replying to the interaction. 60 | * @param options.interaction - The interaction to run this command on. 61 | */ 62 | public override async run({ 63 | interaction, 64 | language, 65 | }: { 66 | interaction: APIInteractionWithArguments; 67 | language: Language; 68 | shardId: number; 69 | }) { 70 | if ( 71 | interaction.arguments.subCommandGroup?.name === 72 | this.client.languageHandler.defaultLanguage?.get( 73 | "CONFIG_COMMAND_AUTO_TRANSCRIPT_VOICE_MESSAGES_SUB_COMMAND_GROUP_NAME", 74 | ) 75 | ) { 76 | if ( 77 | interaction.arguments.subCommand?.name === 78 | this.client.languageHandler.defaultLanguage?.get( 79 | "CONFIG_COMMAND_AUTO_TRANSCRIPT_VOICE_MESSAGES_SUB_COMMAND_GROUP_ENABLE_SUB_COMMAND_NAME", 80 | ) 81 | ) 82 | return Promise.all([ 83 | this.client.prisma.autoTranscriptVoiceMessages.upsert({ 84 | where: { guildId: interaction.guild_id ?? "@me" }, 85 | create: { guildId: interaction.guild_id ?? "@me" }, 86 | update: {}, 87 | }), 88 | this.client.api.interactions.reply(interaction.id, interaction.token, { 89 | embeds: [ 90 | { 91 | title: language.get("AUTO_TRANSCRIPT_VOICE_MESSAGES_ENABLED_TITLE"), 92 | description: language.get("AUTO_TRANSCRIPT_VOICE_MESSAGES_ENABLED_DESCRIPTION"), 93 | color: this.client.config.colors.success, 94 | }, 95 | ], 96 | allowed_mentions: { parse: [] }, 97 | }), 98 | ]); 99 | 100 | return Promise.all([ 101 | this.client.prisma.autoTranscriptVoiceMessages.deleteMany({ 102 | where: { guildId: interaction.guild_id ?? "@me" }, 103 | }), 104 | this.client.api.interactions.reply(interaction.id, interaction.token, { 105 | embeds: [ 106 | { 107 | title: language.get("AUTO_TRANSCRIPT_VOICE_MESSAGES_DISABLED_TITLE"), 108 | description: language.get("AUTO_TRANSCRIPT_VOICE_MESSAGES_DISABLED_DESCRIPTION"), 109 | color: this.client.config.colors.success, 110 | }, 111 | ], 112 | allowed_mentions: { parse: [] }, 113 | }), 114 | ]); 115 | } 116 | 117 | return ""; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /lib/classes/LanguageHandler.ts: -------------------------------------------------------------------------------- 1 | import type { LocaleString } from "@discordjs/core"; 2 | import type { TOptions } from "i18next"; 3 | import type { LanguageValues } from "../../typings/language.js"; 4 | import type ExtendedClient from "../extensions/ExtendedClient.js"; 5 | import type { LanguageKeys, LanguageOptions } from "./Language.js"; 6 | import Language from "./Language.js"; 7 | 8 | export default class LanguageHandler { 9 | /** 10 | * Our extended client 11 | */ 12 | public readonly client: ExtendedClient; 13 | 14 | /** 15 | * A set containing all of our languages. 16 | */ 17 | public languages = new Set(); 18 | 19 | /** 20 | * The default language to resort to. 21 | */ 22 | public defaultLanguage: Language | null; 23 | 24 | /** 25 | * Create our LanguageHandler class. 26 | * 27 | * @param client Our client. 28 | */ 29 | public constructor(client: ExtendedClient) { 30 | this.client = client; 31 | 32 | this.defaultLanguage = null; 33 | } 34 | 35 | /** 36 | * Load all of our languages into our array. 37 | */ 38 | public async loadLanguages() { 39 | for (const fileName of this.client.functions.getFiles(`${this.client.__dirname}/dist/languages/`, ".js")) { 40 | const languageFile: LanguageOptions = await import(`../../languages/${fileName}`).then((file) => file.default); 41 | 42 | const language: Language = new Language(this.client, languageFile.LANGUAGE_ID! as LocaleString, { 43 | enabled: languageFile.LANGUAGE_ENABLED!, 44 | language: languageFile, 45 | }); 46 | 47 | this.languages.add(language); 48 | language.init(); 49 | } 50 | 51 | this.defaultLanguage = this.enabledLanguages.find((language) => language.id === "en-US")!; 52 | } 53 | 54 | /** 55 | * Get all enabled languages. 56 | */ 57 | public get enabledLanguages() { 58 | return [...this.languages].filter((language) => language.enabled); 59 | } 60 | 61 | /** 62 | * Get a language with a given ID. 63 | * 64 | * @param languageId The language id to get. 65 | * @returns The language with the given id. 66 | */ 67 | public getLanguage(languageId?: string) { 68 | if (!this.defaultLanguage) 69 | this.defaultLanguage = this.enabledLanguages.find((language) => language.id === "en-US")!; 70 | 71 | return this.enabledLanguages.find((language) => language.id === languageId) ?? this.defaultLanguage; 72 | } 73 | 74 | /** 75 | * Translate a key to all enabled languages. 76 | * 77 | * @param key The key to translate. 78 | * @param args The arguments for the key. 79 | * @returns The translated keys. 80 | */ 81 | public getFromAllLanguages(key: K, args?: O & TOptions) { 82 | if (args && !("interpolation" in args)) args.interpolation = { escapeValue: false }; 83 | 84 | const defaultResponse = this.defaultLanguage?.get(key, args); 85 | 86 | return this.enabledLanguages 87 | .map((language) => ({ 88 | id: language.id, 89 | value: language.get(key, args), 90 | })) 91 | .filter((response) => response.value !== defaultResponse) 92 | .reduce((newObject: Record, initialObject) => { 93 | newObject[initialObject.id] = initialObject.value; 94 | 95 | return newObject; 96 | }, {}); 97 | } 98 | 99 | /** 100 | * Generate localizations for a command option type. 101 | * 102 | * @param options The options to generate localizations with. 103 | * @param options.description The description key. 104 | * @param options.descriptionArgs The description arguments. 105 | * @param options.name The name key. 106 | * @param options.nameArgs The name arguments. 107 | * @returns The generated localizations. 108 | */ 109 | public generateLocalizationsForApplicationCommandOptionType< 110 | dK extends LanguageKeys, 111 | dO extends LanguageValues[dK], 112 | nK extends LanguageKeys, 113 | nO extends LanguageValues[nK], 114 | >({ 115 | description, 116 | descriptionArgs, 117 | name, 118 | nameArgs, 119 | }: { 120 | description: dK; 121 | descriptionArgs?: dO & TOptions; 122 | name: nK; 123 | nameArgs?: nO & TOptions; 124 | }) { 125 | return { 126 | name: this.defaultLanguage!.get(name, nameArgs), 127 | description: this.defaultLanguage!.get(description, descriptionArgs), 128 | name_localizations: this.getFromAllLanguages(name, nameArgs), 129 | description_localizations: this.getFromAllLanguages(description, descriptionArgs), 130 | }; 131 | } 132 | 133 | /** 134 | * Generate localizations for a command option string type, with choices. 135 | * 136 | * @param options The options to generate localizations with. 137 | * @param options.name The name key. 138 | * @param options.nameArgs The name arguments. 139 | * @returns The generated localizations. 140 | */ 141 | public generateLocalizationsForApplicationCommandOptionTypeStringWithChoices< 142 | nK extends LanguageKeys, 143 | nO extends LanguageValues[nK], 144 | >({ name, nameArgs }: { name: nK; nameArgs?: nO & TOptions }) { 145 | return { 146 | name: this.defaultLanguage!.get(name, nameArgs), 147 | name_localizations: this.getFromAllLanguages(name, nameArgs), 148 | }; 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /lib/utilities/instrumentation.ts: -------------------------------------------------------------------------------- 1 | import { env } from "node:process"; 2 | import { format } from "node:util"; 3 | import type { APIInteraction, APIMessage } from "@discordjs/core"; 4 | import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-grpc"; 5 | import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-grpc"; 6 | import { dockerCGroupV1Detector } from "@opentelemetry/resource-detector-docker"; 7 | import { Resource } from "@opentelemetry/resources"; 8 | import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics"; 9 | import { NodeSDK } from "@opentelemetry/sdk-node"; 10 | import { SEMRESATTRS_SERVICE_NAME, SEMRESATTRS_SERVICE_VERSION } from "@opentelemetry/semantic-conventions"; 11 | import PrismaInstrumentation from "@prisma/instrumentation"; 12 | import * as Sentry from "@sentry/node"; 13 | import type { HonoRequest } from "hono"; 14 | import botConfig from "../../config/bot.config.js"; 15 | 16 | function initSentry(): typeof Sentry & { 17 | captureWithExtras(error: any, extras: Record): Promise; 18 | captureWithInteraction(error: any, interaction: APIInteraction): Promise; 19 | captureWithMessage(error: any, message: APIMessage): Promise; 20 | captureWithRequest( 21 | error: any, 22 | request: HonoRequest, 23 | response: Response, 24 | query: Record, 25 | ): Promise; 26 | } { 27 | Sentry.init({ 28 | tracesSampleRate: 1, 29 | dsn: env.SENTRY_DSN, 30 | skipOpenTelemetrySetup: true, // we're doing that oureslves 31 | }); 32 | 33 | return { 34 | ...Sentry, 35 | 36 | /** 37 | * Capture a Sentry error about an interaction. 38 | * 39 | * @param error The error to capture. 40 | * @param interaction The interaction that caused the error. 41 | * @return The sentry error ID. 42 | */ 43 | captureWithInteraction: async (error: any, interaction: APIInteraction): Promise => { 44 | return new Promise((resolve) => { 45 | Sentry.withScope((scope) => { 46 | scope.setExtra("Environment", env.NODE_ENV); 47 | scope.setUser({ 48 | username: (interaction.member?.user ?? interaction.user!).username, 49 | id: (interaction.member ?? interaction).user!.id, 50 | }); 51 | scope.setExtra("Interaction", format(interaction)); 52 | 53 | resolve(Sentry.captureException(error)); 54 | }); 55 | }); 56 | }, 57 | 58 | /** 59 | * Capture a Sentry error about a message. 60 | * 61 | * @param error The error to capture. 62 | * @param message The message that caused the error. 63 | * @return The sentry error ID. 64 | */ 65 | captureWithMessage: async (error: any, message: APIMessage): Promise => { 66 | return new Promise((resolve) => { 67 | Sentry.withScope((scope) => { 68 | scope.setExtra("Environment", env.NODE_ENV); 69 | scope.setUser({ 70 | username: `${message.author.username}#${message.author.discriminator}`, 71 | id: message.author.id, 72 | }); 73 | scope.setExtra("Message", format(message)); 74 | 75 | resolve(Sentry.captureException(error)); 76 | }); 77 | }); 78 | }, 79 | 80 | captureWithRequest: async ( 81 | error: any, 82 | request: HonoRequest, 83 | response: Response, 84 | query: Record, 85 | ): Promise => { 86 | return new Promise((resolve) => { 87 | Sentry.withScope((scope) => { 88 | scope.setExtra("Environment", env.NODE_ENV); 89 | scope.setExtra("Method", request.method); 90 | scope.setExtra("X-Forwarded-For", request.header("X-Forwarded-For")); 91 | 92 | scope.setExtra("User Agent", request.header("user-agent")); 93 | 94 | if (request.url) { 95 | scope.setExtra("Path", request.url.split("?")[0]); 96 | scope.setExtra("Path + Query", request.url); 97 | scope.setExtra("Query", JSON.stringify(query, null, 4)); 98 | } 99 | 100 | scope.setExtra("Request", JSON.stringify(request, null, 4)); 101 | scope.setExtra("Response", JSON.stringify(response, null, 4)); 102 | scope.setExtra("Cookie", request.raw.headers.get("cookie")); 103 | 104 | resolve(Sentry.captureException(error)); 105 | }); 106 | }); 107 | }, 108 | 109 | /** 110 | * Capture a Sentry error with extra details. 111 | * 112 | * @param error The error to capture. 113 | * @param extras Extra details to add to the error. 114 | * @return The sentry error ID. 115 | */ 116 | captureWithExtras: async (error: any, extras: Record) => { 117 | return new Promise((resolve) => { 118 | Sentry.withScope((scope) => { 119 | scope.setExtra("Environment", env.NODE_ENV); 120 | for (const [key, value] of Object.entries(extras)) scope.setExtra(key, format(value)); 121 | resolve(Sentry.captureException(error)); 122 | }); 123 | }); 124 | }, 125 | }; 126 | } 127 | 128 | const sentry = initSentry(); 129 | 130 | const otel = new NodeSDK({ 131 | traceExporter: new OTLPTraceExporter({ 132 | url: "http://172.17.0.1:4310/v1/traces", 133 | }), 134 | metricReader: new PeriodicExportingMetricReader({ 135 | exporter: new OTLPMetricExporter({ 136 | url: "http://172.17.0.1:4310/v1/metrics", 137 | }), 138 | }), 139 | instrumentations: [new PrismaInstrumentation.PrismaInstrumentation()], 140 | resource: new Resource({ 141 | [SEMRESATTRS_SERVICE_NAME]: botConfig.botName, 142 | [SEMRESATTRS_SERVICE_VERSION]: botConfig.version, 143 | }), 144 | resourceDetectors: [dockerCGroupV1Detector], 145 | }); 146 | 147 | otel.start(); 148 | 149 | // Incase something goes wrong, take a look under the hood with: 150 | //import { diag, DiagConsoleLogger, DiagLogLevel } from "@opentelemetry/api"; 151 | //diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.DEBUG); 152 | 153 | export { sentry }; 154 | -------------------------------------------------------------------------------- /typings/language.d.ts: -------------------------------------------------------------------------------- 1 | export interface LanguageValues { 2 | LANGUAGE_ENABLED: {}, 3 | LANGUAGE_ID: {}, 4 | LANGUAGE_NAME: {}, 5 | PARSE_REGEX: {}, 6 | MS_OTHER: {}, 7 | SECOND_ONE: {}, 8 | SECOND_OTHER: {}, 9 | SECOND_SHORT: {}, 10 | MINUTE_ONE: {}, 11 | MINUTE_OTHER: {}, 12 | MINUTE_SHORT: {}, 13 | HOUR_ONE: {}, 14 | HOUR_OTHER: {}, 15 | HOUR_SHORT: {}, 16 | DAY_ONE: {}, 17 | DAY_OTHER: {}, 18 | DAY_SHORT: {}, 19 | YEAR_ONE: {}, 20 | YEAR_OTHER: {}, 21 | YEAR_SHORT: {}, 22 | CreateInstantInvite: {}, 23 | KickMembers: {}, 24 | BanMembers: {}, 25 | Administrator: {}, 26 | ManageChannels: {}, 27 | ManageGuild: {}, 28 | AddReactions: {}, 29 | ViewAuditLog: {}, 30 | PrioritySpeaker: {}, 31 | Stream: {}, 32 | ViewChannel: {}, 33 | SendMessages: {}, 34 | SendTTSMessages: {}, 35 | ManageMessages: {}, 36 | EmbedLinks: {}, 37 | AttachFiles: {}, 38 | ReadMessageHistory: {}, 39 | MentionEveryone: {}, 40 | UseExternalEmojis: {}, 41 | ViewGuildInsights: {}, 42 | Connect: {}, 43 | Speak: {}, 44 | MuteMembers: {}, 45 | DeafenMembers: {}, 46 | MoveMembers: {}, 47 | UseVAD: {}, 48 | ChangeNickname: {}, 49 | ManageNicknames: {}, 50 | ManageRoles: {}, 51 | ManageWebhooks: {}, 52 | ManageEmojisAndStickers: {}, 53 | ManageGuildExpressions: {}, 54 | UseApplicationCommands: {}, 55 | RequestToSpeak: {}, 56 | ManageEvents: {}, 57 | ManageThreads: {}, 58 | CreatePublicThreads: {}, 59 | CreatePrivateThreads: {}, 60 | UseExternalStickers: {}, 61 | SendMessagesInThreads: {}, 62 | UseEmbeddedActivities: {}, 63 | ModerateMembers: {}, 64 | ViewCreatorMonetizationAnalytics: {}, 65 | CreateGuildExpressions: {}, 66 | UseSoundboard: {}, 67 | CreateEvents: {}, 68 | UseExternalSounds: {}, 69 | SendVoiceMessages: {}, 70 | SendPolls: {}, 71 | UseExternalApps: {}, 72 | INVALID_ARGUMENT_TITLE: {}, 73 | INVALID_PATH_TITLE: {}, 74 | INVALID_PATH_DESCRIPTION: {}, 75 | INTERNAL_ERROR_TITLE: {}, 76 | INTERNAL_ERROR_DESCRIPTION: {}, 77 | SENTRY_EVENT_ID_FOOTER: {eventId: any}, 78 | NON_EXISTENT_APPLICATION_COMMAND_TITLE: {type: any}, 79 | NON_EXISTENT_APPLICATION_COMMAND_DESCRIPTION: {type: any}, 80 | MISSING_PERMISSIONS_BASE_TITLE: {}, 81 | MISSING_PERMISSIONS_OWNER_ONLY_DESCRIPTION: {type: any}, 82 | MISSING_PERMISSIONS_DEVELOPER_ONLY_DESCRIPTION: {type: any}, 83 | MISSING_PERMISSIONS_USER_PERMISSIONS_ONE_DESCRIPTION: {missingPermissions: any, type: any}, 84 | MISSING_PERMISSIONS_USER_PERMISSIONS_OTHER_DESCRIPTION: {missingPermissions: any, type: any}, 85 | MISSING_PERMISSIONS_CLIENT_PERMISSIONS_ONE_DESCRIPTION: {missingPermissions: any, type: any}, 86 | MISSING_PERMISSIONS_CLIENT_PERMISSIONS_OTHER_DESCRIPTION: {missingPermissions: any, type: any}, 87 | TYPE_ON_COOLDOWN_TITLE: {type: any}, 88 | TYPE_ON_COOLDOWN_DESCRIPTION: {type: any, formattedTime: any}, 89 | COOLDOWN_ON_TYPE_TITLE: {type: any}, 90 | COOLDOWN_ON_TYPE_DESCRIPTION: {type: any}, 91 | AN_ERROR_HAS_OCCURRED_TITLE: {}, 92 | AN_ERROR_HAS_OCCURRED_DESCRIPTION: {}, 93 | PING_COMMAND_NAME: {}, 94 | PING_COMMAND_DESCRIPTION: {}, 95 | PING: {}, 96 | PONG: {hostLatency: any}, 97 | TRANSCRIBE_COMMAND_NAME: {}, 98 | PREMIUM_COMMAND_NAME: {}, 99 | PREMIUM_COMMAND_DESCRIPTION: {}, 100 | PREMIUM_COMMAND_INFO_SUB_COMMAND_NAME: {}, 101 | PREMIUM_COMMAND_INFO_SUB_COMMAND_DESCRIPTION: {}, 102 | PREMIUM_COMMAND_APPLY_SUB_COMMAND_NAME: {}, 103 | PREMIUM_COMMAND_APPLY_SUB_COMMAND_DESCRIPTION: {}, 104 | PREMIUM_COMMAND_REMOVE_SUB_COMMAND_NAME: {}, 105 | PREMIUM_COMMAND_REMOVE_SUB_COMMAND_DESCRIPTION: {}, 106 | PREMIUM_COMMAND_REMOVE_SUB_COMMAND_SERVER_OPTION_NAME: {}, 107 | PREMIUM_COMMAND_REMOVE_SUB_COMMAND_SERVER_OPTION_DESCRIPTION: {}, 108 | PREMIUM_INFO_NO_PRODUCTS_TITLE: {}, 109 | PREMIUM_INFO_NO_PRODUCTS_DESCRIPTION: {}, 110 | PREMIUM_INFO_TITLE: {}, 111 | PREMIUM_INFO_DESCRIPTION: {subscriptionInfo: any}, 112 | SUBSCRIPTION_INFO: {appliedGuildCount: any, maxGuildCount: any, date: any}, 113 | CONFIG_COMMAND_NAME: {}, 114 | CONFIG_COMMAND_DESCRIPTION: {}, 115 | CONFIG_COMMAND_AUTO_TRANSCRIPT_VOICE_MESSAGES_SUB_COMMAND_GROUP_NAME: {}, 116 | CONFIG_COMMAND_AUTO_TRANSCRIPT_VOICE_MESSAGES_SUB_COMMAND_GROUP_DESCRIPTION: {}, 117 | CONFIG_COMMAND_AUTO_TRANSCRIPT_VOICE_MESSAGES_SUB_COMMAND_GROUP_ENABLE_SUB_COMMAND_NAME: {}, 118 | CONFIG_COMMAND_AUTO_TRANSCRIPT_VOICE_MESSAGES_SUB_COMMAND_GROUP_ENABLE_SUB_COMMAND_DESCRIPTION: {}, 119 | CONFIG_COMMAND_AUTO_TRANSCRIPT_VOICE_MESSAGES_SUB_COMMAND_GROUP_DISABLE_SUB_COMMAND_NAME: {}, 120 | CONFIG_COMMAND_AUTO_TRANSCRIPT_VOICE_MESSAGES_SUB_COMMAND_GROUP_DISABLE_SUB_COMMAND_DESCRIPTION: {}, 121 | AUTO_TRANSCRIPT_VOICE_MESSAGES_ENABLED_TITLE: {}, 122 | AUTO_TRANSCRIPT_VOICE_MESSAGES_ENABLED_DESCRIPTION: {}, 123 | AUTO_TRANSCRIPT_VOICE_MESSAGES_DISABLED_TITLE: {}, 124 | AUTO_TRANSCRIPT_VOICE_MESSAGES_DISABLED_DESCRIPTION: {}, 125 | NO_VALID_ATTACHMENTS_ERROR: {}, 126 | MESSAGE_STILL_BEING_TRANSCRIBED_ERROR: {}, 127 | TRANSCRIBING: {}, 128 | READ_MORE_BUTTON_LABEL: {}, 129 | TRANSCRIBED_MESSAGE_BUTTON_LABEL: {}, 130 | NOT_A_PREMIUM_GUILD_ERROR_TITLE: {}, 131 | NOT_A_PREMIUM_GUILD_CONTEXT_MENU_ERROR_DESCRIPTION: {}, 132 | NOT_A_PREMIUM_GUILD_FILES_ERROR_DESCRIPTION: {}, 133 | IGNORE_COMMAND_NAME: {}, 134 | IGNORE_COMMAND_DESCRIPTION: {}, 135 | IGNORE_COMMAND_CONTEXT_MENU_SUB_COMMAND_NAME: {}, 136 | IGNORE_COMMAND_CONTEXT_MENU_SUB_COMMAND_DESCRIPTION: {}, 137 | IGNORE_COMMAND_AUTO_TRANSCRIPTION_SUB_COMMAND_NAME: {}, 138 | IGNORE_COMMAND_AUTO_TRANSCRIPTION_SUB_COMMAND_DESCRIPTION: {}, 139 | IGNORE_COMMAND_ALL_SUB_COMMAND_NAME: {}, 140 | IGNORE_COMMAND_ALL_SUB_COMMAND_DESCRIPTION: {}, 141 | IGNORED_SUCCESSFULLY_TITLE: {}, 142 | IGNORED_SUCCESSFULLY_DESCRIPTION: {}, 143 | UNIGORED_SUCCESSFULLY_TITLE: {}, 144 | UNIGORED_SUCCESSFULLY_DESCRIPTION: {}, 145 | USER_IS_IGNORED_ERROR: {}, 146 | NOT_A_GUILD_TITLE: {}, 147 | NOT_A_GUILD_DESCRIPTION: {}, 148 | NOT_A_PREMIUM_USER_ERROR_TITLE: {}, 149 | NOT_A_PREMIUM_USER_ERROR_DESCRIPTION: {}, 150 | MAX_GUILD_COUNT_REACHED_ERROR_TITLE: {}, 151 | MAX_GUILD_COUNT_REACHED_ERROR_DESCRIPTION: {}, 152 | PREMIUM_APPLIED_TITLE: {}, 153 | PREMIUM_APPLIED_DESCRIPTION: {}, 154 | PREMIUM_REMOVED_TITLE: {}, 155 | PREMIUM_REMOVED_DESCRIPTION: {} 156 | } -------------------------------------------------------------------------------- /lib/classes/AutoCompleteHandler.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | APIApplicationCommandAutocompleteInteraction, 3 | APIInteractionDataResolved, 4 | ToEventProps, 5 | } from "@discordjs/core"; 6 | import { ApplicationCommandOptionType } from "@discordjs/core"; 7 | import type { APIInteractionWithArguments, InteractionArguments } from "../../typings"; 8 | import type ExtendedClient from "../extensions/ExtendedClient"; 9 | import { autoCompleteMetric } from "../utilities/metrics.js"; 10 | import applicationCommandOptionTypeReference from "../utilities/reference.js"; 11 | import type AutoComplete from "./AutoComplete"; 12 | import type Language from "./Language"; 13 | 14 | export default class AutoCompleteHandler { 15 | /** 16 | * Our extended client. 17 | */ 18 | public readonly client: ExtendedClient; 19 | 20 | /** 21 | * Create our auto complete handler. 22 | * 23 | * @param client Our extended client. 24 | */ 25 | public constructor(client: ExtendedClient) { 26 | this.client = client; 27 | } 28 | 29 | /** 30 | * Load all of the auto completes in the autoCompletes directory. 31 | */ 32 | public async loadAutoCompletes() { 33 | for (const parentFolder of this.client.functions.getFiles( 34 | `${this.client.__dirname}/dist/src/bot/autoCompletes`, 35 | "", 36 | true, 37 | )) 38 | for (const fileName of this.client.functions.getFiles( 39 | `${this.client.__dirname}/dist/src/bot/autoCompletes/${parentFolder}`, 40 | ".js", 41 | )) { 42 | const AutoCompleteFile = await import(`../../src/bot/autoCompletes/${parentFolder}/${fileName}`); 43 | 44 | const autoComplete = new AutoCompleteFile.default(this.client) as AutoComplete; 45 | 46 | this.client.autoCompletes.set(autoComplete.accepts, autoComplete); 47 | } 48 | } 49 | 50 | /** 51 | * Reload all of the auto completes. 52 | */ 53 | public async reloadAutoCompletes() { 54 | this.client.autoCompletes.clear(); 55 | await this.loadAutoCompletes(); 56 | } 57 | 58 | /** 59 | * Get an auto complete by its name. 60 | * 61 | * @param name The name of the auto complete. 62 | * @returns The auto complete with the specified name within the accepts field, otherwise undefined. 63 | */ 64 | private getAutoComplete(name: string) { 65 | return [...this.client.autoCompletes.values()].find((autoComplete) => autoComplete.accepts.includes(name)); 66 | } 67 | 68 | /** 69 | * Handle an interaction properly to ensure that it can invoke an auto complete. 70 | * 71 | * @param options The options to handle the auto complete. 72 | * @param options.data The interaction that is attempting to invoke an auto complete. 73 | * @param options.shardId The shard ID to use when replying to the interaction. 74 | */ 75 | public async handleAutoComplete({ 76 | data: interaction, 77 | shardId, 78 | }: Omit, "api">) { 79 | const name = [interaction.data.name]; 80 | 81 | const applicationCommandArguments = { 82 | attachments: {}, 83 | booleans: {}, 84 | channels: {}, 85 | integers: {}, 86 | mentionables: {}, 87 | numbers: {}, 88 | roles: {}, 89 | strings: {}, 90 | users: {}, 91 | } as InteractionArguments; 92 | 93 | let parentOptions = interaction.data.options ?? []; 94 | 95 | while (parentOptions.length) { 96 | const currentOption = parentOptions.pop(); 97 | 98 | if (!currentOption) continue; 99 | 100 | if (currentOption.type === ApplicationCommandOptionType.SubcommandGroup) { 101 | name.push(currentOption.name); 102 | applicationCommandArguments.subCommandGroup = currentOption; 103 | parentOptions = currentOption.options; 104 | } else if (currentOption.type === ApplicationCommandOptionType.Subcommand) { 105 | name.push(currentOption.name); 106 | applicationCommandArguments.subCommand = currentOption; 107 | parentOptions = currentOption.options ?? []; 108 | } else { 109 | const identifier = applicationCommandOptionTypeReference[currentOption.type] as keyof Omit< 110 | InteractionArguments, 111 | "focused" | "subCommand" | "subCommandGroup" 112 | >; 113 | 114 | if ( 115 | interaction.data.resolved && 116 | identifier in interaction.data.resolved && 117 | currentOption.name in interaction.data.resolved[identifier as keyof APIInteractionDataResolved]! 118 | ) { 119 | applicationCommandArguments[identifier]![currentOption.name] = interaction.data.resolved[ 120 | identifier as keyof APIInteractionDataResolved 121 | ]![currentOption.name] as any; 122 | continue; 123 | } 124 | 125 | applicationCommandArguments[identifier]![currentOption.name] = currentOption as any; 126 | 127 | if ((applicationCommandArguments[identifier]![currentOption.name] as any).focused) { 128 | applicationCommandArguments.focused = currentOption as any; 129 | name.push(currentOption.name); 130 | } 131 | } 132 | } 133 | 134 | const interactionWithArguments = { ...interaction, arguments: applicationCommandArguments }; 135 | 136 | const autoComplete = this.getAutoComplete(name.filter(Boolean).join("-")); 137 | if (!autoComplete) return; 138 | 139 | const userLanguage = await this.client.prisma.userLanguage.findUnique({ 140 | where: { userId: (interaction.member ?? interaction).user!.id }, 141 | }); 142 | const language = this.client.languageHandler.getLanguage(userLanguage?.languageId ?? interaction.locale); 143 | 144 | autoCompleteMetric.add(1, { 145 | name: name.join("-"), 146 | shard: shardId, 147 | }); 148 | 149 | return this.runAutoComplete(autoComplete, interactionWithArguments, language, shardId); 150 | } 151 | 152 | /** 153 | * Run an auto complete. 154 | * 155 | * @param autoComplete The auto complete we want to run. 156 | * @param interaction The interaction that invoked the auto complete. 157 | * @param language The language to use when replying to the interaction. 158 | */ 159 | private async runAutoComplete( 160 | autoComplete: AutoComplete, 161 | interaction: APIInteractionWithArguments, 162 | language: Language, 163 | shardId: number, 164 | ) { 165 | await autoComplete.run({ interaction, language, shardId }).catch(async (error) => { 166 | this.client.logger.error(error); 167 | 168 | await this.client.logger.sentry.captureWithInteraction(error, interaction); 169 | 170 | return this.client.api.interactions.createAutocompleteResponse(interaction.id, interaction.token, { 171 | choices: [], 172 | }); 173 | }); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /lib/classes/ModalHandler.ts: -------------------------------------------------------------------------------- 1 | import { setTimeout } from "node:timers"; 2 | import type { APIModalSubmitInteraction, RESTPostAPIWebhookWithTokenJSONBody, ToEventProps } from "@discordjs/core"; 3 | import { MessageFlags, RESTJSONErrorCodes } from "@discordjs/core"; 4 | import { DiscordAPIError } from "@discordjs/rest"; 5 | import type ExtendedClient from "../extensions/ExtendedClient.js"; 6 | import type Language from "./Language.js"; 7 | import type Modal from "./Modal.js"; 8 | 9 | export default class ModalHandler { 10 | /** 11 | * Our extended client. 12 | */ 13 | public readonly client: ExtendedClient; 14 | 15 | /** 16 | * How long a user must wait before being able to use a modal again. 17 | */ 18 | public readonly coolDownTime: number; 19 | 20 | /** 21 | * A list of user IDs that currently have a cooldown applied. 22 | */ 23 | public readonly cooldowns: Set; 24 | 25 | /** 26 | * Create our modal handler. 27 | * 28 | * @param client Our extended client. 29 | */ 30 | public constructor(client: ExtendedClient) { 31 | this.client = client; 32 | 33 | this.coolDownTime = 200; 34 | this.cooldowns = new Set(); 35 | } 36 | 37 | /** 38 | * Load all of the modals in the modals directory. 39 | */ 40 | public async loadModals() { 41 | for (const parentFolder of this.client.functions.getFiles( 42 | `${this.client.__dirname}/dist/src/bot/modals`, 43 | "", 44 | true, 45 | )) { 46 | for (const fileName of this.client.functions.getFiles( 47 | `${this.client.__dirname}/dist/src/bot/modals/${parentFolder}`, 48 | ".js", 49 | )) { 50 | const ModalFile = await import(`../../src/bot/modals/${parentFolder}/${fileName}`); 51 | 52 | const modal = new ModalFile.default(this.client) as Modal; 53 | 54 | this.client.modals.set(modal.name, modal); 55 | } 56 | } 57 | } 58 | 59 | /** 60 | * Reload all of the modals. 61 | */ 62 | public async reloadModals() { 63 | this.client.modals.clear(); 64 | await this.loadModals(); 65 | } 66 | 67 | /** 68 | * Get the modal that a customId qualifies for. 69 | * 70 | * @param customId The customId of the modal. 71 | * @returns The modal that the customId qualifies for. 72 | */ 73 | private async getModal(customId: string) { 74 | return [...this.client.modals.values()].find((modal) => customId.startsWith(modal.name)); 75 | } 76 | 77 | /** 78 | * Handle an interaction properly to ensure that it can invoke a modal. 79 | * 80 | * @param options The interaction that is attempted to invoke a modal. 81 | * @param options.data The interaction data. 82 | * @param options.shardId The shard ID that the interaction was received on. 83 | */ 84 | public async handleModal({ data: interaction, shardId }: Omit, "api">) { 85 | const userLanguage = await this.client.prisma.userLanguage.findUnique({ 86 | where: { 87 | userId: (interaction.member ?? interaction).user!.id, 88 | }, 89 | }); 90 | const language = this.client.languageHandler.getLanguage(userLanguage?.languageId ?? interaction.locale); 91 | 92 | const modal = await this.getModal(interaction.data.custom_id); 93 | 94 | if (!modal) return; 95 | 96 | const missingPermissions = await modal.validate({ 97 | interaction, 98 | language, 99 | shardId, 100 | }); 101 | if (missingPermissions) 102 | return this.client.api.interactions.reply(interaction.id, interaction.token, { 103 | embeds: [ 104 | { 105 | ...missingPermissions, 106 | color: this.client.config.colors.error, 107 | }, 108 | ], 109 | flags: MessageFlags.Ephemeral, 110 | allowed_mentions: { parse: [], replied_user: true }, 111 | }); 112 | 113 | const [preChecked, preCheckedResponse] = await modal.preCheck({ 114 | interaction, 115 | language, 116 | shardId, 117 | }); 118 | if (!preChecked) { 119 | if (preCheckedResponse) 120 | await this.client.api.interactions.reply(interaction.id, interaction.token, { 121 | embeds: [ 122 | { 123 | ...preCheckedResponse, 124 | color: this.client.config.colors.error, 125 | }, 126 | ], 127 | flags: MessageFlags.Ephemeral, 128 | allowed_mentions: { parse: [], replied_user: true }, 129 | }); 130 | 131 | return; 132 | } 133 | 134 | return this.runModal(modal, interaction, shardId, language); 135 | } 136 | 137 | /** 138 | * Run a modal. 139 | * 140 | * @param modal The modal we want to run. 141 | * @param interaction The interaction that invoked the modal. 142 | * @param shardId The shard ID that the interaction was received on. 143 | * @param language The language to use when replying to the interaction. 144 | */ 145 | private async runModal(modal: Modal, interaction: APIModalSubmitInteraction, shardId: number, language: Language) { 146 | if (this.cooldowns.has((interaction.member ?? interaction).user!.id)) 147 | return this.client.api.interactions.reply(interaction.id, interaction.token, { 148 | embeds: [ 149 | { 150 | title: language.get("COOLDOWN_ON_TYPE_TITLE", { 151 | type: "Modals", 152 | }), 153 | description: language.get("COOLDOWN_ON_TYPE_DESCRIPTION", { 154 | type: "modal", 155 | }), 156 | color: this.client.config.colors.error, 157 | }, 158 | ], 159 | flags: MessageFlags.Ephemeral, 160 | allowed_mentions: { parse: [], replied_user: true }, 161 | }); 162 | 163 | try { 164 | await modal.run({ 165 | interaction, 166 | language, 167 | shardId, 168 | }); 169 | } catch (error) { 170 | this.client.logger.error(error); 171 | 172 | const eventId = await this.client.logger.sentry.captureWithInteraction(error, interaction); 173 | 174 | const toSend = { 175 | embeds: [ 176 | { 177 | title: language.get("AN_ERROR_HAS_OCCURRED_TITLE"), 178 | description: language.get("AN_ERROR_HAS_OCCURRED_DESCRIPTION"), 179 | footer: { 180 | text: language.get("SENTRY_EVENT_ID_FOOTER", { 181 | eventId, 182 | }), 183 | }, 184 | color: this.client.config.colors.error, 185 | }, 186 | ], 187 | flags: MessageFlags.Ephemeral, 188 | } satisfies RESTPostAPIWebhookWithTokenJSONBody; 189 | 190 | try { 191 | await this.client.api.interactions.reply(interaction.id, interaction.token, toSend); 192 | return; 193 | } catch (error) { 194 | if (error instanceof DiscordAPIError && error.code === RESTJSONErrorCodes.InteractionHasAlreadyBeenAcknowledged) 195 | return this.client.api.interactions.followUp(interaction.application_id, interaction.token, toSend); 196 | 197 | await this.client.logger.sentry.captureWithInteraction(error, interaction); 198 | throw error; 199 | } 200 | } 201 | 202 | this.cooldowns.add((interaction.member ?? interaction).user!.id); 203 | setTimeout(() => this.cooldowns.delete((interaction.member ?? interaction).user!.id), this.coolDownTime); 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/bot/applicationCommands/transcribe/transcribeContextMenu.ts: -------------------------------------------------------------------------------- 1 | import type { APIMessageApplicationCommandInteraction } from "@discordjs/core"; 2 | import { 3 | ApplicationCommandType, 4 | ApplicationIntegrationType, 5 | ButtonStyle, 6 | ComponentType, 7 | MessageFlags, 8 | } from "@discordjs/core"; 9 | import ApplicationCommand from "../../../../lib/classes/ApplicationCommand.js"; 10 | import type Language from "../../../../lib/classes/Language.js"; 11 | import type ExtendedClient from "../../../../lib/extensions/ExtendedClient.js"; 12 | import Functions, { TranscriptionModel } from "../../../../lib/utilities/functions.js"; 13 | import type { APIInteractionWithArguments } from "../../../../typings/index"; 14 | 15 | export class BaseTranscribeContextMenu extends ApplicationCommand { 16 | /** 17 | * Run this application command. 18 | * 19 | * @param options - The options for this command. 20 | * @param options.shardId - The shard ID that this interaction was received on. 21 | * @param options.language - The language to use when replying to the interaction. 22 | * @param options.interaction - The interaction to run this command on. 23 | */ 24 | public override async run({ 25 | interaction, 26 | language, 27 | ephemeral, 28 | }: { 29 | interaction: APIInteractionWithArguments; 30 | language: Language; 31 | shardId: number; 32 | ephemeral: boolean; 33 | }) { 34 | const message = interaction.data.resolved.messages[interaction.data.target_id]; 35 | 36 | if (!message) return; // doesnt matter 37 | 38 | const ignoredUser = await this.client.prisma.ignoredUser.findUnique({ 39 | where: { userId: message.author.id }, 40 | }); 41 | 42 | if ( 43 | ignoredUser && 44 | ignoredUser.userId !== interaction.user?.id && 45 | (ignoredUser.type === "ALL" || ignoredUser?.type === "CONTEXT_MENU") 46 | ) 47 | return this.client.api.interactions.reply(interaction.id, interaction.token, { 48 | content: language.get("USER_IS_IGNORED_ERROR"), 49 | flags: MessageFlags.Ephemeral, 50 | allowed_mentions: { parse: [] }, 51 | }); 52 | 53 | const [existingTranscription, jobExists] = await Promise.all([ 54 | this.client.prisma.transcription.findUnique({ 55 | where: { 56 | initialMessageId: interaction.data.target_id, 57 | }, 58 | }), 59 | this.client.prisma.job.findFirst({ 60 | where: { 61 | initialMessageId: interaction.data.target_id, 62 | guildId: interaction.guild_id ?? "@me", 63 | }, 64 | }), 65 | ]); 66 | 67 | if (existingTranscription) { 68 | const message = await this.client.api.channels.getMessage( 69 | interaction.channel.id, 70 | existingTranscription.responseMessageId, 71 | ); 72 | 73 | return this.client.api.interactions.reply(interaction.id, interaction.token, { 74 | content: message.content, 75 | flags: MessageFlags.Ephemeral, 76 | components: [ 77 | { 78 | components: [ 79 | { 80 | type: ComponentType.Button, 81 | style: ButtonStyle.Link, 82 | url: existingTranscription.threadId 83 | ? `https://discord.com/channels/${interaction.guild_id}/${existingTranscription.threadId}` 84 | : `https://discord.com/channels/${interaction.guild_id}/${interaction.channel.id}/${existingTranscription.initialMessageId}`, 85 | label: existingTranscription.threadId 86 | ? language.get("READ_MORE_BUTTON_LABEL") 87 | : language.get("TRANSCRIBED_MESSAGE_BUTTON_LABEL"), 88 | }, 89 | ], 90 | type: ComponentType.ActionRow, 91 | }, 92 | ], 93 | allowed_mentions: { parse: [] }, 94 | }); 95 | } 96 | 97 | if (jobExists) { 98 | return this.client.api.interactions.reply(interaction.id, interaction.token, { 99 | content: language.get("MESSAGE_STILL_BEING_TRANSCRIBED_ERROR"), 100 | flags: MessageFlags.Ephemeral, 101 | allowed_mentions: { parse: [] }, 102 | }); 103 | } 104 | 105 | let attachmentUrl = message.attachments.find((attachment) => 106 | this.client.config.allowedFileTypes.includes(attachment.content_type ?? ""), 107 | )?.url; 108 | 109 | if (!attachmentUrl && message.embeds?.[0]?.video?.url) { 110 | attachmentUrl = message.embeds[0].video.url; 111 | } 112 | 113 | if ( 114 | !attachmentUrl || 115 | ["https://www.tiktok.com", "https://www.youtube.com"].some((url) => attachmentUrl?.startsWith(url)) 116 | ) 117 | return this.client.api.interactions.reply(interaction.id, interaction.token, { 118 | content: language.get("NO_VALID_ATTACHMENTS_ERROR"), 119 | allowed_mentions: { parse: [] }, 120 | flags: MessageFlags.Ephemeral, 121 | }); 122 | 123 | await this.client.api.interactions.reply(interaction.id, interaction.token, { 124 | content: language.get("TRANSCRIBING"), 125 | allowed_mentions: { parse: [] }, 126 | ...(ephemeral && { flags: MessageFlags.Ephemeral }), 127 | }); 128 | 129 | const endpointHealth = await Functions.getEndpointHealth(TranscriptionModel.LARGEV3); 130 | 131 | const [job, reply] = await Promise.all([ 132 | endpointHealth.workers.running <= 0 133 | ? Functions.transcribeAudio(attachmentUrl, "run", TranscriptionModel.MEDIUM) 134 | : Functions.transcribeAudio(attachmentUrl, "run", TranscriptionModel.LARGEV3), 135 | this.client.api.interactions.getOriginalReply(interaction.application_id, interaction.token), 136 | ]); 137 | 138 | return this.client.prisma.job.create({ 139 | data: { 140 | id: job.id, 141 | model: endpointHealth.workers.running <= 0 ? TranscriptionModel.MEDIUM : TranscriptionModel.LARGEV3, 142 | attachmentUrl, 143 | channelId: interaction.channel.id, 144 | guildId: interaction.guild_id ?? "@me", 145 | interactionId: interaction.id, 146 | interactionToken: interaction.token, 147 | initialMessageId: interaction.data.target_id, 148 | responseMessageId: reply.id, 149 | }, 150 | }); 151 | } 152 | } 153 | 154 | export default class TranscribeContextMenu extends BaseTranscribeContextMenu { 155 | /** 156 | * Create our transcribe context menu command. 157 | * 158 | * @param client - Our extended client. 159 | */ 160 | public constructor(client: ExtendedClient) { 161 | super(client, { 162 | options: { 163 | ...client.languageHandler.generateLocalizationsForApplicationCommandOptionTypeStringWithChoices({ 164 | name: "TRANSCRIBE_COMMAND_NAME", 165 | }), 166 | type: ApplicationCommandType.Message, 167 | integration_types: [ApplicationIntegrationType.GuildInstall, ApplicationIntegrationType.UserInstall], 168 | contexts: [0, 1, 2], 169 | }, 170 | }); 171 | } 172 | 173 | public override async run({ 174 | interaction, 175 | language, 176 | shardId, 177 | }: { 178 | interaction: APIInteractionWithArguments; 179 | language: Language; 180 | shardId: number; 181 | }) { 182 | return super.run({ interaction, language, shardId, ephemeral: false }); 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /lib/classes/ButtonHandler.ts: -------------------------------------------------------------------------------- 1 | import { setTimeout } from "node:timers"; 2 | import type { 3 | APIMessageComponentButtonInteraction, 4 | RESTPostAPIWebhookWithTokenJSONBody, 5 | ToEventProps, 6 | } from "@discordjs/core"; 7 | import { MessageFlags, RESTJSONErrorCodes } from "@discordjs/core"; 8 | import { DiscordAPIError } from "@discordjs/rest"; 9 | import type ExtendedClient from "../extensions/ExtendedClient.js"; 10 | import type Button from "./Button.js"; 11 | import type Language from "./Language.js"; 12 | 13 | export default class ButtonHandler { 14 | /** 15 | * Our extended client. 16 | */ 17 | public readonly client: ExtendedClient; 18 | 19 | /** 20 | * How long a user must wait before being able to run a button again. 21 | */ 22 | public readonly coolDownTime: number; 23 | 24 | /** 25 | * A list of user IDs that currently have a cooldown applied. 26 | */ 27 | public readonly cooldowns: Set; 28 | 29 | /** 30 | * Create our button handler/ 31 | * 32 | * @param client Our extended client. 33 | */ 34 | public constructor(client: ExtendedClient) { 35 | this.client = client; 36 | 37 | this.coolDownTime = 200; 38 | this.cooldowns = new Set(); 39 | } 40 | 41 | /** 42 | * Load all of the buttons in the buttons directory. 43 | */ 44 | public async loadButtons() { 45 | for (const parentFolder of this.client.functions.getFiles( 46 | `${this.client.__dirname}/dist/src/bot/buttons`, 47 | "", 48 | true, 49 | )) { 50 | for (const fileName of this.client.functions.getFiles( 51 | `${this.client.__dirname}/dist/src/bot/buttons/${parentFolder}`, 52 | ".js", 53 | )) { 54 | const ButtonFile = await import(`../../src/bot/buttons/${parentFolder}/${fileName}`); 55 | 56 | const button = new ButtonFile.default(this.client) as Button; 57 | 58 | this.client.buttons.set(button.name, button); 59 | } 60 | } 61 | } 62 | 63 | /** 64 | * Reload all of the buttons. 65 | */ 66 | public async reloadButtons() { 67 | this.client.buttons.clear(); 68 | await this.loadButtons(); 69 | } 70 | 71 | /** 72 | * Get the button that a customId qualifies for. 73 | * 74 | * @param customId The customId of the button. 75 | * @returns The button that the customId qualifies for. 76 | */ 77 | private async getButton(customId: string) { 78 | return [...this.client.buttons.values()].find((button) => customId.startsWith(button.name)); 79 | } 80 | 81 | /** 82 | * Handle an interaction properly to ensure that it can invoke a button. 83 | * 84 | * @param options The interaction that is attempted to invoke a button. 85 | * @param options.data The interaction data. 86 | * @param options.shardId The shard ID that the interaction was received on. 87 | */ 88 | public async handleButton({ 89 | data: interaction, 90 | shardId, 91 | }: Omit, "api">) { 92 | const userLanguage = await this.client.prisma.userLanguage.findUnique({ 93 | where: { 94 | userId: (interaction.member ?? interaction).user!.id, 95 | }, 96 | }); 97 | const language = this.client.languageHandler.getLanguage(userLanguage?.languageId ?? interaction.locale); 98 | 99 | const button = await this.getButton(interaction.data.custom_id); 100 | 101 | if (!button) return; 102 | 103 | const missingPermissions = await button.validate({ 104 | interaction, 105 | language, 106 | shardId, 107 | }); 108 | if (missingPermissions) 109 | return this.client.api.interactions.reply(interaction.id, interaction.token, { 110 | embeds: [ 111 | { 112 | ...missingPermissions, 113 | color: this.client.config.colors.error, 114 | }, 115 | ], 116 | flags: MessageFlags.Ephemeral, 117 | allowed_mentions: { parse: [], replied_user: true }, 118 | }); 119 | 120 | const [preChecked, preCheckedResponse] = await button.preCheck({ 121 | interaction, 122 | language, 123 | shardId, 124 | }); 125 | if (!preChecked) { 126 | if (preCheckedResponse) 127 | await this.client.api.interactions.reply(interaction.id, interaction.token, { 128 | embeds: [ 129 | { 130 | ...preCheckedResponse, 131 | color: this.client.config.colors.error, 132 | }, 133 | ], 134 | flags: MessageFlags.Ephemeral, 135 | allowed_mentions: { parse: [], replied_user: true }, 136 | }); 137 | 138 | return; 139 | } 140 | 141 | return this.runButton(button, interaction, shardId, language); 142 | } 143 | 144 | /** 145 | * Run a button. 146 | * 147 | * @param button The button we want to run. 148 | * @param interaction The interaction that invoked the button. 149 | * @param shardId The shard ID that the interaction was received on. 150 | * @param language The language to use when replying to the interaction. 151 | */ 152 | private async runButton( 153 | button: Button, 154 | interaction: APIMessageComponentButtonInteraction, 155 | shardId: number, 156 | language: Language, 157 | ) { 158 | if (this.cooldowns.has((interaction.member ?? interaction).user!.id)) { 159 | return this.client.api.interactions.reply(interaction.id, interaction.token, { 160 | embeds: [ 161 | { 162 | title: language.get("COOLDOWN_ON_TYPE_TITLE", { 163 | type: "Buttons", 164 | }), 165 | description: language.get("COOLDOWN_ON_TYPE_DESCRIPTION", { 166 | type: "button", 167 | }), 168 | color: this.client.config.colors.error, 169 | }, 170 | ], 171 | flags: MessageFlags.Ephemeral, 172 | allowed_mentions: { parse: [], replied_user: true }, 173 | }); 174 | } 175 | 176 | try { 177 | await button.run({ 178 | interaction, 179 | language, 180 | shardId, 181 | }); 182 | } catch (error) { 183 | this.client.logger.error(error); 184 | 185 | const eventId = await this.client.logger.sentry.captureWithInteraction(error, interaction); 186 | 187 | const toSend = { 188 | embeds: [ 189 | { 190 | title: language.get("AN_ERROR_HAS_OCCURRED_TITLE"), 191 | description: language.get("AN_ERROR_HAS_OCCURRED_DESCRIPTION"), 192 | footer: { 193 | text: language.get("SENTRY_EVENT_ID_FOOTER", { 194 | eventId, 195 | }), 196 | }, 197 | color: this.client.config.colors.error, 198 | }, 199 | ], 200 | flags: MessageFlags.Ephemeral, 201 | } satisfies RESTPostAPIWebhookWithTokenJSONBody; 202 | 203 | try { 204 | await this.client.api.interactions.reply(interaction.id, interaction.token, toSend); 205 | return; 206 | } catch (error) { 207 | if ( 208 | error instanceof DiscordAPIError && 209 | error.code === RESTJSONErrorCodes.InteractionHasAlreadyBeenAcknowledged 210 | ) { 211 | return this.client.api.interactions.followUp(interaction.application_id, interaction.token, toSend); 212 | } 213 | 214 | await this.client.logger.sentry.captureWithInteraction(error, interaction); 215 | throw error; 216 | } 217 | } 218 | 219 | this.cooldowns.add((interaction.member ?? interaction).user!.id); 220 | setTimeout(() => this.cooldowns.delete((interaction.member ?? interaction).user!.id), this.coolDownTime); 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /lib/classes/SelectMenuHandler.ts: -------------------------------------------------------------------------------- 1 | import { setTimeout } from "node:timers"; 2 | import type { 3 | APIMessageComponentSelectMenuInteraction, 4 | RESTPostAPIWebhookWithTokenJSONBody, 5 | ToEventProps, 6 | } from "@discordjs/core"; 7 | import { MessageFlags, RESTJSONErrorCodes } from "@discordjs/core"; 8 | import { DiscordAPIError } from "@discordjs/rest"; 9 | import type ExtendedClient from "../extensions/ExtendedClient.js"; 10 | import type Language from "./Language.js"; 11 | import type SelectMenu from "./SelectMenu.js"; 12 | 13 | export default class SelectMenuHandler { 14 | /** 15 | * Our extended client. 16 | */ 17 | public readonly client: ExtendedClient; 18 | 19 | /** 20 | * How long a user must wait before being able to run a select menu again. 21 | */ 22 | public readonly coolDownTime: number; 23 | 24 | /** 25 | * A list of user IDs that currently have a cooldown applied. 26 | */ 27 | public readonly cooldowns: Set; 28 | 29 | /** 30 | * Create our select menu handler. 31 | * 32 | * @param client Our extended client. 33 | */ 34 | public constructor(client: ExtendedClient) { 35 | this.client = client; 36 | 37 | this.coolDownTime = 200; 38 | this.cooldowns = new Set(); 39 | } 40 | 41 | /** 42 | * Load all of the select menus in the selectMenus directory. 43 | */ 44 | public async loadSelectMenus() { 45 | for (const parentFolder of this.client.functions.getFiles( 46 | `${this.client.__dirname}/dist/src/bot/selectMenus`, 47 | "", 48 | true, 49 | )) { 50 | for (const fileName of this.client.functions.getFiles( 51 | `${this.client.__dirname}/dist/src/bot/selectMenus/${parentFolder}`, 52 | ".js", 53 | )) { 54 | const SelectMenuFile = await import(`../../src/bot/selectMenus/${parentFolder}/${fileName}`); 55 | 56 | const selectMenu = new SelectMenuFile.default(this.client) as SelectMenu; 57 | 58 | this.client.selectMenus.set(selectMenu.name, selectMenu); 59 | } 60 | } 61 | } 62 | 63 | /** 64 | * Reload all of the select menus. 65 | */ 66 | public async reloadSelectMenus() { 67 | this.client.selectMenus.clear(); 68 | await this.loadSelectMenus(); 69 | } 70 | 71 | /** 72 | * Get the select menu that a customId qualifies for. 73 | * 74 | * @param customId The customId of the select menu. 75 | * @returns The select menu that the customId qualifies for. 76 | */ 77 | private async getSelectMenu(customId: string) { 78 | return [...this.client.selectMenus.values()].find((selectMenu) => customId.startsWith(selectMenu.name)); 79 | } 80 | 81 | /** 82 | * Handle an interaction properly to ensure that it can invoke a select menu. 83 | * 84 | * @param options The interaction that is attempted to invoke a select menu. 85 | * @param options.data The interaction data. 86 | * @param options.shardId The shard ID that the interaction was received on. 87 | */ 88 | public async handleSelectMenu({ 89 | data: interaction, 90 | shardId, 91 | }: Omit, "api">) { 92 | const userLanguage = await this.client.prisma.userLanguage.findUnique({ 93 | where: { 94 | userId: (interaction.member ?? interaction).user!.id, 95 | }, 96 | }); 97 | const language = this.client.languageHandler.getLanguage(userLanguage?.languageId ?? interaction.locale); 98 | 99 | const selectMenu = await this.getSelectMenu(interaction.data.custom_id); 100 | 101 | if (!selectMenu) return; 102 | 103 | const missingPermissions = await selectMenu.validate({ 104 | interaction, 105 | language, 106 | shardId, 107 | }); 108 | if (missingPermissions) 109 | return this.client.api.interactions.reply(interaction.id, interaction.token, { 110 | embeds: [ 111 | { 112 | ...missingPermissions, 113 | color: this.client.config.colors.error, 114 | }, 115 | ], 116 | flags: MessageFlags.Ephemeral, 117 | allowed_mentions: { parse: [], replied_user: true }, 118 | }); 119 | 120 | const [preChecked, preCheckedResponse] = await selectMenu.preCheck({ 121 | interaction, 122 | language, 123 | shardId, 124 | }); 125 | if (!preChecked) { 126 | if (preCheckedResponse) 127 | await this.client.api.interactions.reply(interaction.id, interaction.token, { 128 | embeds: [ 129 | { 130 | ...preCheckedResponse, 131 | color: this.client.config.colors.error, 132 | }, 133 | ], 134 | flags: MessageFlags.Ephemeral, 135 | allowed_mentions: { parse: [], replied_user: true }, 136 | }); 137 | 138 | return; 139 | } 140 | 141 | return this.runSelectMenu(selectMenu, interaction, shardId, language); 142 | } 143 | 144 | /** 145 | * Run a select menu. 146 | * 147 | * @param selectMenu The select menu we want to run. 148 | * @param interaction The interaction that invoked the select menu. 149 | * @param shardId The shard ID that the interaction was received on. 150 | * @param language The language to use when replying to the interaction. 151 | */ 152 | private async runSelectMenu( 153 | selectMenu: SelectMenu, 154 | interaction: APIMessageComponentSelectMenuInteraction, 155 | shardId: number, 156 | language: Language, 157 | ) { 158 | if (this.cooldowns.has((interaction.member ?? interaction).user!.id)) 159 | return this.client.api.interactions.reply(interaction.id, interaction.token, { 160 | embeds: [ 161 | { 162 | title: language.get("COOLDOWN_ON_TYPE_TITLE", { 163 | type: "Buttons", 164 | }), 165 | description: language.get("COOLDOWN_ON_TYPE_DESCRIPTION", { 166 | type: "button", 167 | }), 168 | color: this.client.config.colors.error, 169 | }, 170 | ], 171 | flags: MessageFlags.Ephemeral, 172 | allowed_mentions: { parse: [], replied_user: true }, 173 | }); 174 | 175 | try { 176 | await selectMenu.run({ 177 | interaction, 178 | language, 179 | shardId, 180 | }); 181 | } catch (error) { 182 | this.client.logger.error(error); 183 | 184 | const eventId = await this.client.logger.sentry.captureWithInteraction(error, interaction); 185 | 186 | const toSend = { 187 | embeds: [ 188 | { 189 | title: language.get("AN_ERROR_HAS_OCCURRED_TITLE"), 190 | description: language.get("AN_ERROR_HAS_OCCURRED_DESCRIPTION"), 191 | footer: { 192 | text: language.get("SENTRY_EVENT_ID_FOOTER", { 193 | eventId, 194 | }), 195 | }, 196 | color: this.client.config.colors.error, 197 | }, 198 | ], 199 | flags: MessageFlags.Ephemeral, 200 | } satisfies RESTPostAPIWebhookWithTokenJSONBody; 201 | 202 | try { 203 | await this.client.api.interactions.reply(interaction.id, interaction.token, toSend); 204 | return; 205 | } catch (error) { 206 | if (error instanceof DiscordAPIError && error.code === RESTJSONErrorCodes.InteractionHasAlreadyBeenAcknowledged) 207 | return this.client.api.interactions.followUp(interaction.application_id, interaction.token, toSend); 208 | 209 | await this.client.logger.sentry.captureWithInteraction(error, interaction); 210 | throw error; 211 | } 212 | } 213 | 214 | this.cooldowns.add((interaction.member ?? interaction).user!.id); 215 | setTimeout(() => this.cooldowns.delete((interaction.member ?? interaction).user!.id), this.coolDownTime); 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /lib/classes/TextCommandHandler.ts: -------------------------------------------------------------------------------- 1 | import { setTimeout } from "node:timers"; 2 | import type { GatewayMessageCreateDispatchData, ToEventProps } from "@discordjs/core"; 3 | import type ExtendedClient from "../extensions/ExtendedClient.js"; 4 | import { logCommandUsage } from "../utilities/metrics.js"; 5 | import type Language from "./Language.js"; 6 | import type TextCommand from "./TextCommand"; 7 | 8 | export default class TextCommandHandler { 9 | /** 10 | * Our extended client. 11 | */ 12 | public readonly client: ExtendedClient; 13 | 14 | /** 15 | * How long a user must wait before being able to run a text command again. 16 | */ 17 | public readonly coolDownTime: number; 18 | 19 | /** 20 | * A list of user IDs that currently have a cooldown applied. 21 | */ 22 | public readonly cooldowns: Set; 23 | 24 | /** 25 | * Create our text command handler. 26 | * 27 | * @param client Our extended client. 28 | */ 29 | public constructor(client: ExtendedClient) { 30 | this.client = client; 31 | 32 | this.coolDownTime = 200; 33 | this.cooldowns = new Set(); 34 | } 35 | 36 | /** 37 | * Load all of the text commands in the textCommands directory. 38 | */ 39 | public async loadTextCommands() { 40 | for (const parentFolder of this.client.functions.getFiles( 41 | `${this.client.__dirname}/dist/src/bot/textCommands`, 42 | "", 43 | true, 44 | )) { 45 | for (const fileName of this.client.functions.getFiles( 46 | `${this.client.__dirname}/dist/src/bot/textCommands/${parentFolder}`, 47 | ".js", 48 | )) { 49 | const CommandFile = await import(`../../src/bot/textCommands/${parentFolder}/${fileName}`); 50 | 51 | const command = new CommandFile.default(this.client) as TextCommand; 52 | 53 | this.client.textCommands.set(command.name, command); 54 | } 55 | } 56 | } 57 | 58 | /** 59 | * Reload all of the text commands. 60 | */ 61 | public async reloadTextCommands() { 62 | this.client.textCommands.clear(); 63 | await this.loadTextCommands(); 64 | } 65 | 66 | /** 67 | * Get a text command by its name. 68 | * 69 | * @param name The name of the text command. 70 | * @returns The text command with the specified name if it exists, otherwise undefined. 71 | */ 72 | private getTextCommand(name: string) { 73 | return this.client.textCommands.get(name); 74 | } 75 | 76 | /** 77 | * Handle a message properly to ensure that it can invoke a text command. 78 | * 79 | * @param options The interaction that is attempting to invoke a text command. 80 | * @param options.data The message data. 81 | * @param options.shardId The shard ID that the message was received on. 82 | */ 83 | public async handleTextCommand({ 84 | data: message, 85 | shardId, 86 | }: Omit, "api">) { 87 | const validPrefix = this.client.config.prefixes.find((prefix) => message.content.startsWith(prefix)); 88 | if (!validPrefix) return; 89 | 90 | const userLanguage = await this.client.prisma.userLanguage.findUnique({ 91 | where: { 92 | userId: message.author.id, 93 | }, 94 | }); 95 | 96 | const language = this.client.languageHandler.getLanguage(userLanguage?.languageId); 97 | 98 | const textCommandArguments = message.content.slice(validPrefix.length).trim().split(/ +/g); 99 | const textCommandName = textCommandArguments.shift()?.toLowerCase(); 100 | 101 | const textCommand = this.getTextCommand(textCommandName ?? ""); 102 | if (!textCommand) return; 103 | 104 | const missingPermissions = await textCommand.validate({ language, message, shardId, args: textCommandArguments }); 105 | if (missingPermissions) 106 | return this.client.api.channels.createMessage(message.channel_id, { 107 | embeds: [ 108 | { 109 | ...missingPermissions, 110 | color: this.client.config.colors.error, 111 | }, 112 | ], 113 | message_reference: { 114 | message_id: message.id, 115 | fail_if_not_exists: false, 116 | }, 117 | allowed_mentions: { parse: [], replied_user: true }, 118 | }); 119 | 120 | const [preChecked, preCheckedResponse] = await textCommand.preCheck({ 121 | message, 122 | language, 123 | shardId, 124 | args: textCommandArguments, 125 | }); 126 | if (!preChecked) { 127 | if (preCheckedResponse) { 128 | return this.client.api.channels.createMessage(message.channel_id, { 129 | embeds: [ 130 | { 131 | ...preCheckedResponse, 132 | color: this.client.config.colors.error, 133 | }, 134 | ], 135 | message_reference: { 136 | message_id: message.id, 137 | fail_if_not_exists: false, 138 | }, 139 | allowed_mentions: { parse: [], replied_user: true }, 140 | }); 141 | } 142 | 143 | return; 144 | } 145 | 146 | return this.runTextCommand(textCommand, message, shardId, language, textCommandArguments); 147 | } 148 | 149 | /** 150 | * Run a text command. 151 | * 152 | * @param textCommand The text command we want to run. 153 | * @param message The message that invoked the text command. 154 | * @param shardId The shard ID that the message was received on. 155 | * @param language The language to use when replying to the interaction. 156 | * @param args The arguments to pass to the text command. 157 | * @returns The result of the text command. 158 | */ 159 | private async runTextCommand( 160 | textCommand: TextCommand, 161 | message: GatewayMessageCreateDispatchData, 162 | shardId: number, 163 | language: Language, 164 | args: string[], 165 | ) { 166 | if (this.cooldowns.has(message.author.id)) 167 | return this.client.api.channels.createMessage(message.channel_id, { 168 | embeds: [ 169 | { 170 | title: language.get("COOLDOWN_ON_TYPE_TITLE", { 171 | type: "Command", 172 | }), 173 | description: language.get("COOLDOWN_ON_TYPE_DESCRIPTION", { type: "command" }), 174 | color: this.client.config.colors.error, 175 | }, 176 | ], 177 | message_reference: { 178 | message_id: message.id, 179 | fail_if_not_exists: false, 180 | }, 181 | allowed_mentions: { parse: [], replied_user: true }, 182 | }); 183 | 184 | try { 185 | await textCommand.run({ 186 | args, 187 | language, 188 | message, 189 | shardId, 190 | }); 191 | 192 | if (textCommand.cooldown) await textCommand.applyCooldown(message.author.id); 193 | 194 | logCommandUsage(textCommand, shardId, true); 195 | } catch (error) { 196 | logCommandUsage(textCommand, shardId, false); 197 | this.client.logger.error(error); 198 | 199 | const eventId = await this.client.logger.sentry.captureWithMessage(error, message); 200 | 201 | return this.client.api.channels.createMessage(message.channel_id, { 202 | embeds: [ 203 | { 204 | title: language.get("AN_ERROR_HAS_OCCURRED_TITLE"), 205 | description: language.get("AN_ERROR_HAS_OCCURRED_DESCRIPTION"), 206 | footer: { 207 | text: language.get("SENTRY_EVENT_ID_FOOTER", { 208 | eventId, 209 | }), 210 | }, 211 | color: this.client.config.colors.error, 212 | }, 213 | ], 214 | allowed_mentions: { parse: [], replied_user: true }, 215 | }); 216 | } 217 | 218 | this.cooldowns.add(message.author.id); 219 | return setTimeout(() => this.cooldowns.delete(message.author.id), this.coolDownTime); 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /lib/classes/Modal.ts: -------------------------------------------------------------------------------- 1 | import type { APIEmbed, APIModalSubmitInteraction, Permissions } from "@discordjs/core"; 2 | import { RESTJSONErrorCodes } from "@discordjs/core"; 3 | import { DiscordAPIError } from "@discordjs/rest"; 4 | import type ExtendedClient from "../extensions/ExtendedClient.js"; 5 | import PermissionsBitField from "../utilities/permissions.js"; 6 | import type Language from "./Language.js"; 7 | 8 | export default class Modal { 9 | /** 10 | * Our extended client. 11 | */ 12 | public readonly client: ExtendedClient; 13 | 14 | /** 15 | * The name of this modal, we look for this at the start of each interaction. 16 | * 17 | * For example, if we have a modal with the name addRoles-1234567890, the modal name 18 | * should be addRoles, as this will trigger the modal no matter what ID is provided. 19 | */ 20 | public readonly name: string; 21 | 22 | /** 23 | * The permissions the user requires to run this modal. 24 | */ 25 | public readonly permissions: Permissions; 26 | 27 | /** 28 | * The permissions the client requires to run this modal. 29 | */ 30 | public readonly clientPermissions: Permissions; 31 | 32 | /** 33 | * Whether or no this modal can only be run by developers. 34 | */ 35 | public readonly devOnly: boolean; 36 | 37 | /** 38 | * Whether or not this modal can only be run by the guild owner. 39 | */ 40 | public readonly ownerOnly: boolean; 41 | 42 | /** 43 | * Create a new modal. 44 | * 45 | * @param client Our extended client. 46 | * @param options The options for this modal. 47 | * @param options.clientPermissions The permissions the client requires to run this modal. 48 | * @param options.devOnly Whether or not this modal can only be run by the developers. 49 | * @param options.name The name of this modal, we look for this at the start of each interaction. 50 | * @param options.ownerOnly Whether or not this modal can only be run by the guild owner. 51 | * @param options.permissions The permissions the user requires to run this modal. 52 | */ 53 | public constructor( 54 | client: ExtendedClient, 55 | options: { 56 | clientPermissions?: Permissions; 57 | devOnly?: boolean; 58 | name: string; 59 | ownerOnly?: boolean; 60 | permissions?: Permissions; 61 | }, 62 | ) { 63 | this.client = client; 64 | 65 | this.name = options.name; 66 | 67 | this.permissions = options.permissions ?? "0"; 68 | this.clientPermissions = options.clientPermissions ?? "0"; 69 | 70 | this.devOnly = options.devOnly ?? false; 71 | this.ownerOnly = options.ownerOnly ?? false; 72 | } 73 | 74 | /** 75 | * Validate that the interaction provided is valid. 76 | * 77 | * @param options The options for this function. 78 | * @param options.interaction The interaction to validate. 79 | * @param options.language The language to use when replying to the interaction. 80 | * @param options.shardId The shard ID to use when replying to the interaction. 81 | * @returns An APIEmbed if the interaction is invalid, null if the interaction is valid. 82 | */ 83 | public async validate({ 84 | interaction, 85 | language, 86 | }: { 87 | interaction: APIModalSubmitInteraction; 88 | language: Language; 89 | shardId: number; 90 | }): Promise { 91 | if (this.ownerOnly && interaction.guild_id) { 92 | if (!this.client.guildOwnersCache.has(interaction.guild_id)) 93 | try { 94 | const guild = await this.client.api.guilds.get(interaction.guild_id); 95 | 96 | this.client.guildOwnersCache.set(interaction.guild_id, guild.owner_id); 97 | } catch (error) { 98 | if (error instanceof DiscordAPIError && error.code === RESTJSONErrorCodes.UnknownGuild) { 99 | const eventId = await this.client.logger.sentry.captureWithInteraction(error, interaction); 100 | 101 | return { 102 | title: language.get("INTERNAL_ERROR_TITLE"), 103 | description: language.get("INTERNAL_ERROR_DESCRIPTION"), 104 | footer: { 105 | text: language.get("SENTRY_EVENT_ID_FOOTER", { 106 | eventId, 107 | }), 108 | }, 109 | }; 110 | } 111 | 112 | await this.client.logger.sentry.captureWithInteraction(error, interaction); 113 | throw error; 114 | } 115 | 116 | const guildOwnerId = this.client.guildOwnersCache.get(interaction.guild_id); 117 | 118 | if (guildOwnerId !== interaction.member!.user.id) 119 | return { 120 | title: language.get("MISSING_PERMISSIONS_BASE_TITLE"), 121 | description: language.get("MISSING_PERMISSIONS_OWNER_ONLY_DESCRIPTION", { type: "modal" }), 122 | }; 123 | } else if (this.devOnly && !this.client.config.admins.includes((interaction.member ?? interaction).user!.id)) 124 | return { 125 | title: language.get("MISSING_PERMISSIONS_BASE_TITLE"), 126 | description: language.get("MISSING_PERMISSIONS_DEVELOPER_ONLY_DESCRIPTION", { type: "modal" }), 127 | }; 128 | else if ( 129 | interaction.guild_id && 130 | this.permissions !== "0" && 131 | !PermissionsBitField.has(BigInt(interaction.member?.permissions ?? 0), BigInt(this.permissions)) 132 | ) { 133 | const missingPermissions = PermissionsBitField.toArray( 134 | PermissionsBitField.difference(BigInt(this.permissions), BigInt(interaction.member?.permissions ?? 0)), 135 | ); 136 | 137 | return { 138 | title: language.get("MISSING_PERMISSIONS_BASE_TITLE"), 139 | description: language.get( 140 | missingPermissions.length === 1 141 | ? "MISSING_PERMISSIONS_USER_PERMISSIONS_ONE_DESCRIPTION" 142 | : "MISSING_PERMISSIONS_USER_PERMISSIONS_OTHER_DESCRIPTION", 143 | { 144 | type: "modal", 145 | missingPermissions: missingPermissions 146 | .map((missingPermission) => `**${language.get(missingPermission)}**`) 147 | .join(", "), 148 | }, 149 | ), 150 | }; 151 | } else if ( 152 | interaction.guild_id && 153 | this.clientPermissions !== "0" && 154 | !PermissionsBitField.has(BigInt(interaction.app_permissions ?? 0), BigInt(this.clientPermissions)) 155 | ) { 156 | const missingPermissions = PermissionsBitField.toArray( 157 | PermissionsBitField.difference(BigInt(this.clientPermissions), BigInt(interaction.app_permissions ?? 0)), 158 | ); 159 | 160 | return { 161 | title: language.get("MISSING_PERMISSIONS_BASE_TITLE"), 162 | description: language.get( 163 | missingPermissions.length === 1 164 | ? "MISSING_PERMISSIONS_CLIENT_PERMISSIONS_ONE_DESCRIPTION" 165 | : "MISSING_PERMISSIONS_CLIENT_PERMISSIONS_OTHER_DESCRIPTION", 166 | { 167 | type: "modal", 168 | missingPermissions: missingPermissions 169 | .map((missingPermission) => `**${language.get(missingPermission)}**`) 170 | .join(", "), 171 | }, 172 | ), 173 | }; 174 | } 175 | 176 | return null; 177 | } 178 | 179 | /** 180 | * Pre check the provided interaction after validating it. 181 | * 182 | * @param _options The options to pre-check. 183 | * @param _options.interaction The interaction to pre-check. 184 | * @param _options.language The language to use when replying to the interaction. 185 | * @param _options.shardId The shard ID to use when replying to the interaction. 186 | * @returns A tuple containing a boolean and an APIEmbed if the interaction is invalid, a boolean if the interaction is valid. 187 | */ 188 | public async preCheck(_options: { 189 | interaction: APIModalSubmitInteraction; 190 | language: Language; 191 | shardId: number; 192 | }): Promise<[boolean, APIEmbed?]> { 193 | return [true]; 194 | } 195 | 196 | /** 197 | * Run this modal. 198 | * 199 | * @param _options The options to run this modal. 200 | * @param _options.interaction The interaction to run this modal. 201 | * @param _options.language The language to use when replying to the interaction. 202 | * @param _options.shardId The shard ID to use when replying to the interaction. 203 | */ 204 | public async run(_options: { 205 | interaction: APIModalSubmitInteraction; 206 | language: Language; 207 | shardId: number; 208 | }): Promise {} 209 | } 210 | -------------------------------------------------------------------------------- /lib/classes/Button.ts: -------------------------------------------------------------------------------- 1 | import type { APIEmbed, APIMessageComponentButtonInteraction, Permissions } from "@discordjs/core"; 2 | import { RESTJSONErrorCodes } from "@discordjs/core"; 3 | import { DiscordAPIError } from "@discordjs/rest"; 4 | import type ExtendedClient from "../extensions/ExtendedClient.js"; 5 | import PermissionsBitField from "../utilities/permissions.js"; 6 | import type Language from "./Language.js"; 7 | 8 | export default class Button { 9 | /** 10 | * Our extended client. 11 | */ 12 | public readonly client: ExtendedClient; 13 | 14 | /** 15 | * The name of this button, we look for this at the start of each interaction. 16 | * 17 | * For example, if we have a button with the name addRole-1234567890, the button name 18 | * should be addRole, as this will trigger the button no matter what role ID is provided. 19 | */ 20 | public readonly name: string; 21 | 22 | /** 23 | * The permissions the user requires to run this button. 24 | */ 25 | public readonly permissions: Permissions; 26 | 27 | /** 28 | * The permissions the client requires to run this button. 29 | */ 30 | public readonly clientPermissions: Permissions; 31 | 32 | /** 33 | * Whether or not this button can only be run by developers. 34 | */ 35 | public readonly devOnly: boolean; 36 | 37 | /** 38 | * Whether or not this application command can only be run by the guild owner. 39 | */ 40 | public readonly ownerOnly: boolean; 41 | 42 | /** 43 | * Create a new button. 44 | * 45 | * @param client Our extended client. 46 | * @param options The options for this button. 47 | * @param options.clientPermissions The permissions the client requires to run this button. 48 | * @param options.devOnly Whether or not this button can only be run by developers. 49 | * @param options.name The name of this button, we look for this at the start of each interaction. 50 | * @param options.ownerOnly Whether or not this button can only be run by the guild owner. 51 | * @param options.permissions The permissions the user requires to run this button. 52 | */ 53 | public constructor( 54 | client: ExtendedClient, 55 | options: { 56 | clientPermissions?: Permissions; 57 | devOnly?: boolean; 58 | name: string; 59 | ownerOnly?: boolean; 60 | permissions?: Permissions; 61 | }, 62 | ) { 63 | this.client = client; 64 | 65 | this.name = options.name; 66 | 67 | this.permissions = options.permissions ?? "0"; 68 | this.clientPermissions = options.clientPermissions ?? "0"; 69 | 70 | this.devOnly = options.devOnly ?? false; 71 | this.ownerOnly = options.ownerOnly ?? false; 72 | } 73 | 74 | /** 75 | * Validate that the interaction provided is valid. 76 | * 77 | * @param options The options for this function. 78 | * @param options.interaction The interaction to validate. 79 | * @param options.language The language to use when replying to the interaction. 80 | * @param options.shardId The shard ID to use when replying to the interaction. 81 | * @returns An APIEmbed if the interaction is invalid, null if the interaction is valid. 82 | */ 83 | public async validate({ 84 | interaction, 85 | language, 86 | }: { 87 | interaction: APIMessageComponentButtonInteraction; 88 | language: Language; 89 | shardId: number; 90 | }): Promise { 91 | if (this.ownerOnly && interaction.guild_id) { 92 | if (!this.client.guildOwnersCache.has(interaction.guild_id)) 93 | try { 94 | const guild = await this.client.api.guilds.get(interaction.guild_id); 95 | 96 | this.client.guildOwnersCache.set(interaction.guild_id, guild.owner_id); 97 | } catch (error) { 98 | if (error instanceof DiscordAPIError && error.code === RESTJSONErrorCodes.UnknownGuild) { 99 | const eventId = await this.client.logger.sentry.captureWithInteraction(error, interaction); 100 | 101 | return { 102 | title: language.get("INTERNAL_ERROR_TITLE"), 103 | description: language.get("INTERNAL_ERROR_DESCRIPTION"), 104 | footer: { 105 | text: language.get("SENTRY_EVENT_ID_FOOTER", { 106 | eventId, 107 | }), 108 | }, 109 | }; 110 | } 111 | 112 | await this.client.logger.sentry.captureWithInteraction(error, interaction); 113 | throw error; 114 | } 115 | 116 | const guildOwnerId = this.client.guildOwnersCache.get(interaction.guild_id); 117 | 118 | if (guildOwnerId !== interaction.member!.user.id) 119 | return { 120 | title: language.get("MISSING_PERMISSIONS_BASE_TITLE"), 121 | description: language.get("MISSING_PERMISSIONS_OWNER_ONLY_DESCRIPTION", { 122 | type: "button", 123 | }), 124 | }; 125 | } else if (this.devOnly && !this.client.config.admins.includes((interaction.member ?? interaction).user!.id)) 126 | return { 127 | title: language.get("MISSING_PERMISSIONS_BASE_TITLE"), 128 | description: language.get("MISSING_PERMISSIONS_DEVELOPER_ONLY_DESCRIPTION", { 129 | type: "button", 130 | }), 131 | }; 132 | else if ( 133 | interaction.guild_id && 134 | this.permissions !== "0" && 135 | !PermissionsBitField.has(BigInt(interaction.member?.permissions ?? 0), BigInt(this.permissions)) 136 | ) { 137 | const missingPermissions = PermissionsBitField.toArray( 138 | PermissionsBitField.difference(BigInt(this.permissions), BigInt(interaction.member?.permissions ?? 0)), 139 | ); 140 | 141 | return { 142 | title: language.get("MISSING_PERMISSIONS_BASE_TITLE"), 143 | description: language.get( 144 | missingPermissions.length === 1 145 | ? "MISSING_PERMISSIONS_USER_PERMISSIONS_ONE_DESCRIPTION" 146 | : "MISSING_PERMISSIONS_USER_PERMISSIONS_OTHER_DESCRIPTION", 147 | { 148 | type: "button", 149 | missingPermissions: missingPermissions 150 | .map((missingPermission) => `**${language.get(missingPermission)}**`) 151 | .join(", "), 152 | }, 153 | ), 154 | }; 155 | } else if ( 156 | interaction.guild_id && 157 | this.clientPermissions && 158 | !PermissionsBitField.has(BigInt(interaction.app_permissions ?? 0), BigInt(this.clientPermissions)) 159 | ) { 160 | const missingPermissions = PermissionsBitField.toArray( 161 | PermissionsBitField.difference(BigInt(this.clientPermissions), BigInt(interaction.app_permissions ?? 0)), 162 | ); 163 | 164 | return { 165 | title: language.get("MISSING_PERMISSIONS_BASE_TITLE"), 166 | description: language.get( 167 | missingPermissions.length === 1 168 | ? "MISSING_PERMISSIONS_CLIENT_PERMISSIONS_ONE_DESCRIPTION" 169 | : "MISSING_PERMISSIONS_CLIENT_PERMISSIONS_OTHER_DESCRIPTION", 170 | { 171 | type: "button", 172 | missingPermissions: missingPermissions 173 | .map((missingPermissions) => `**${language.get(missingPermissions)}**`) 174 | .join(", "), 175 | }, 176 | ), 177 | }; 178 | } 179 | 180 | return null; 181 | } 182 | 183 | /** 184 | * Pre check the provided interaction after validating it. 185 | * 186 | * @param _options The options to pre-check. 187 | * @param _options.interaction The interaction to pre-check. 188 | * @param _options.language The language to use when replying to the interaction. 189 | * @param _options.shardId The shard ID to use when replying to the interaction. 190 | * @returns A tuple containing a boolean and an APIEmbed if the interaction is invalid, a boolean if the interaction is valid. 191 | */ 192 | public async preCheck(_options: { 193 | interaction: APIMessageComponentButtonInteraction; 194 | language: Language; 195 | shardId: number; 196 | }): Promise<[boolean, APIEmbed?]> { 197 | return [true]; 198 | } 199 | 200 | /** 201 | * Run this button. 202 | * 203 | * @param _options The options to run this button. 204 | * @param _options.interaction The interaction that triggered this button. 205 | * @param _options.language The language to use when replying to the interaction. 206 | * @param _options.shardId The shard ID to use when replying to the interaction. 207 | */ 208 | public async run(_options: { 209 | interaction: APIMessageComponentButtonInteraction; 210 | language: Language; 211 | shardId: number; 212 | }): Promise {} 213 | } 214 | -------------------------------------------------------------------------------- /lib/classes/SelectMenu.ts: -------------------------------------------------------------------------------- 1 | import type { APIEmbed, APIMessageComponentSelectMenuInteraction, Permissions } from "@discordjs/core"; 2 | import { RESTJSONErrorCodes } from "@discordjs/core"; 3 | import { DiscordAPIError } from "@discordjs/rest"; 4 | import type ExtendedClient from "../extensions/ExtendedClient.js"; 5 | import PermissionsBitField from "../utilities/permissions.js"; 6 | import type Language from "./Language.js"; 7 | 8 | export default class SelectMenu { 9 | /** 10 | * Our extended client. 11 | */ 12 | public readonly client: ExtendedClient; 13 | 14 | /** 15 | * The name of this select menu, we look for this at the start of each interaction. 16 | * 17 | * For example, if we have a select menu with the name addRoles-1234567890, the select menu name 18 | * should be addRoles, as this will trigger the button no matter what ID is provided. 19 | */ 20 | public readonly name: string; 21 | 22 | /** 23 | * The permissions the user requires to run this button. 24 | */ 25 | public readonly permissions: Permissions; 26 | 27 | /** 28 | * The permissions the client requires to run this button. 29 | */ 30 | public readonly clientPermissions: Permissions; 31 | 32 | /** 33 | * Whether or no this button can only be run by developers. 34 | */ 35 | public readonly devOnly: boolean; 36 | 37 | /** 38 | * Whether or not this application command can only be run by the guild owner. 39 | */ 40 | public readonly ownerOnly: boolean; 41 | 42 | /** 43 | * Create a new select menu. 44 | * 45 | * @param client Our extended client. 46 | * @param options The options for this select menu. 47 | * @param options.clientPermissions The permissions the client requires to run this select menu. 48 | * @param options.devOnly Whether or not this select menu can only be run by the developers. 49 | * @param options.name The name of this select menu, we look for this at the start of each interaction. 50 | * @param options.ownerOnly Whether or not this select menu can only be run by the guild owner. 51 | * @param options.permissions The permissions the user requires to run this select menu. 52 | */ 53 | public constructor( 54 | client: ExtendedClient, 55 | options: { 56 | clientPermissions?: Permissions; 57 | devOnly?: boolean; 58 | name: string; 59 | ownerOnly?: boolean; 60 | permissions?: Permissions; 61 | }, 62 | ) { 63 | this.client = client; 64 | 65 | this.name = options.name; 66 | 67 | this.permissions = options.permissions ?? "0"; 68 | this.clientPermissions = options.clientPermissions ?? "0"; 69 | 70 | this.devOnly = options.devOnly ?? false; 71 | this.ownerOnly = options.ownerOnly ?? false; 72 | } 73 | 74 | /** 75 | * Validate that the interaction provided is valid. 76 | * 77 | * @param options The options for this function. 78 | * @param options.interaction The interaction to validate. 79 | * @param options.language The language to use when replying to the interaction. 80 | * @param options.shardId The shard ID to use when replying to the interaction. 81 | * @returns An APIEmbed if the interaction is invalid, null if the interaction is valid. 82 | */ 83 | public async validate({ 84 | interaction, 85 | language, 86 | }: { 87 | interaction: APIMessageComponentSelectMenuInteraction; 88 | language: Language; 89 | shardId: number; 90 | }): Promise { 91 | if (this.ownerOnly && interaction.guild_id) { 92 | if (!this.client.guildOwnersCache.has(interaction.guild_id)) 93 | try { 94 | const guild = await this.client.api.guilds.get(interaction.guild_id); 95 | 96 | this.client.guildOwnersCache.set(interaction.guild_id, guild.owner_id); 97 | } catch (error) { 98 | if (error instanceof DiscordAPIError && error.code === RESTJSONErrorCodes.UnknownGuild) { 99 | const eventId = await this.client.logger.sentry.captureWithInteraction(error, interaction); 100 | 101 | return { 102 | title: language.get("INTERNAL_ERROR_TITLE"), 103 | description: language.get("INTERNAL_ERROR_DESCRIPTION"), 104 | footer: { 105 | text: language.get("SENTRY_EVENT_ID_FOOTER", { 106 | eventId, 107 | }), 108 | }, 109 | }; 110 | } 111 | 112 | await this.client.logger.sentry.captureWithInteraction(error, interaction); 113 | throw error; 114 | } 115 | 116 | const guildOwnerId = this.client.guildOwnersCache.get(interaction.guild_id); 117 | 118 | if (guildOwnerId !== interaction.member!.user.id) 119 | return { 120 | title: language.get("MISSING_PERMISSIONS_BASE_TITLE"), 121 | description: language.get("MISSING_PERMISSIONS_OWNER_ONLY_DESCRIPTION", { type: "select menu" }), 122 | }; 123 | } else if (this.devOnly && !this.client.config.admins.includes((interaction.member ?? interaction).user!.id)) 124 | return { 125 | title: language.get("MISSING_PERMISSIONS_BASE_TITLE"), 126 | description: language.get("MISSING_PERMISSIONS_DEVELOPER_ONLY_DESCRIPTION", { type: "select menu" }), 127 | }; 128 | else if ( 129 | interaction.guild_id && 130 | this.permissions !== "0" && 131 | !PermissionsBitField.has(BigInt(interaction.member?.permissions ?? 0), BigInt(this.permissions)) 132 | ) { 133 | const missingPermissions = PermissionsBitField.toArray( 134 | PermissionsBitField.difference(BigInt(this.permissions), BigInt(interaction.member?.permissions ?? 0)), 135 | ); 136 | 137 | return { 138 | title: language.get("MISSING_PERMISSIONS_BASE_TITLE"), 139 | description: language.get( 140 | missingPermissions.length === 1 141 | ? "MISSING_PERMISSIONS_USER_PERMISSIONS_ONE_DESCRIPTION" 142 | : "MISSING_PERMISSIONS_USER_PERMISSIONS_OTHER_DESCRIPTION", 143 | { 144 | type: "select menu", 145 | missingPermissions: missingPermissions 146 | .map((missingPermission) => `**${language.get(missingPermission)}**`) 147 | .join(", "), 148 | }, 149 | ), 150 | }; 151 | } else if ( 152 | interaction.guild_id && 153 | this.clientPermissions !== "0" && 154 | !PermissionsBitField.has(BigInt(interaction.app_permissions ?? 0), BigInt(this.clientPermissions)) 155 | ) { 156 | const missingPermissions = PermissionsBitField.toArray( 157 | PermissionsBitField.difference(BigInt(this.clientPermissions), BigInt(interaction.app_permissions ?? 0)), 158 | ); 159 | 160 | return { 161 | title: language.get("MISSING_PERMISSIONS_BASE_TITLE"), 162 | description: language.get( 163 | missingPermissions.length === 1 164 | ? "MISSING_PERMISSIONS_CLIENT_PERMISSIONS_ONE_DESCRIPTION" 165 | : "MISSING_PERMISSIONS_CLIENT_PERMISSIONS_OTHER_DESCRIPTION", 166 | { 167 | type: "select menu", 168 | missingPermissions: missingPermissions 169 | .map((missingPermission) => `**${language.get(missingPermission)}**`) 170 | .join(", "), 171 | }, 172 | ), 173 | }; 174 | } 175 | 176 | return null; 177 | } 178 | 179 | /** 180 | * Pre check the provided interaction after validating it. 181 | * 182 | * @param _options The options to pre-check. 183 | * @param _options.interaction The interaction to pre-check. 184 | * @param _options.language The language to use when replying to the interaction. 185 | * @param _options.shardId The shard ID to use when replying to the interaction. 186 | * @returns A tuple containing a boolean and an APIEmbed if the interaction is invalid, a boolean if the interaction is valid. 187 | */ 188 | public async preCheck(_options: { 189 | interaction: APIMessageComponentSelectMenuInteraction; 190 | language: Language; 191 | shardId: number; 192 | }): Promise<[boolean, APIEmbed?]> { 193 | return [true]; 194 | } 195 | 196 | /** 197 | * Run this select menu. 198 | * 199 | * @param _options The options to run this select menu. 200 | * @param _options.interaction The interaction to run this select menu. 201 | * @param _options.language The language to use when replying to the interaction. 202 | * @param _options.shardId The shard ID to use when replying to the interaction. 203 | */ 204 | public async run(_options: { 205 | interaction: APIMessageComponentSelectMenuInteraction; 206 | language: Language; 207 | shardId: number; 208 | }): Promise {} 209 | } 210 | -------------------------------------------------------------------------------- /lib/extensions/ExtendedClient.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "node:path"; 2 | import { env } from "node:process"; 3 | import type { APIRole, ClientOptions, MappedEvents } from "@discordjs/core"; 4 | import { API, Client } from "@discordjs/core"; 5 | import { PrismaClient } from "@prisma/client"; 6 | import i18next from "i18next"; 7 | import intervalPlural from "i18next-intervalplural-postprocessor"; 8 | import Stripe from "stripe"; 9 | import Config from "../../config/bot.config.js"; 10 | import type ApplicationCommand from "../classes/ApplicationCommand.js"; 11 | import ApplicationCommandHandler from "../classes/ApplicationCommandHandler.js"; 12 | import type AutoComplete from "../classes/AutoComplete.js"; 13 | import AutoCompleteHandler from "../classes/AutoCompleteHandler.js"; 14 | import type Button from "../classes/Button.js"; 15 | import ButtonHandler from "../classes/ButtonHandler.js"; 16 | import type EventHandler from "../classes/EventHandler.js"; 17 | import LanguageHandler from "../classes/LanguageHandler.js"; 18 | import Logger from "../classes/Logger.js"; 19 | import type Modal from "../classes/Modal.js"; 20 | import ModalHandler from "../classes/ModalHandler.js"; 21 | import type SelectMenu from "../classes/SelectMenu.js"; 22 | import SelectMenuHandler from "../classes/SelectMenuHandler.js"; 23 | import type TextCommand from "../classes/TextCommand.js"; 24 | import TextCommandHandler from "../classes/TextCommandHandler.js"; 25 | import Functions from "../utilities/functions.js"; 26 | 27 | export default class ExtendedClient extends Client { 28 | /** 29 | * An API instance to make using Discord's API much easier. 30 | */ 31 | public override readonly api: API; 32 | 33 | // public override gateway: Gateway; 34 | 35 | /** 36 | * The configuration for our bot. 37 | */ 38 | public readonly config: typeof Config; 39 | 40 | /** 41 | * The logger for our bot. 42 | */ 43 | public readonly logger: typeof Logger; 44 | 45 | /** 46 | * The functions for our bot. 47 | */ 48 | public readonly functions: Functions; 49 | 50 | /** 51 | * The i18n instance for our bot. 52 | */ 53 | public readonly i18n: typeof i18next; 54 | 55 | /** 56 | * __dirname is not in our version of ECMA, this is a workaround. 57 | */ 58 | public readonly __dirname: string; 59 | 60 | /** 61 | * Our Prisma client, this is an ORM to interact with our PostgreSQL instance. 62 | */ 63 | public readonly prisma: PrismaClient<{ 64 | errorFormat: "pretty"; 65 | log: ( 66 | | { 67 | emit: "event"; 68 | level: "query"; 69 | } 70 | | { 71 | emit: "stdout"; 72 | level: "error"; 73 | } 74 | | { 75 | emit: "stdout"; 76 | level: "warn"; 77 | } 78 | )[]; 79 | }>; 80 | 81 | /** 82 | * All of the different gauges we use for Metrics with Prometheus and Grafana. 83 | */ 84 | // private readonly gauges: Map; 85 | 86 | /** 87 | * A map of guild ID to user ID, representing a guild and who owns it. 88 | */ 89 | public guildOwnersCache: Map; 90 | 91 | /** 92 | * Guild roles cache. 93 | */ 94 | 95 | public guildRolesCache: Map>; 96 | 97 | /** 98 | * An approximation of how many users the bot can see. 99 | */ 100 | public approximateUserCount: number; 101 | 102 | /** 103 | * The language handler for our bot. 104 | */ 105 | public readonly languageHandler: LanguageHandler; 106 | 107 | /** 108 | * A map of events that our client is listening to. 109 | */ 110 | public events: Map; 111 | 112 | /** 113 | * A map of the application commands that the bot is currently handling. 114 | */ 115 | public applicationCommands: Map; 116 | 117 | /** 118 | * The application command handler for our bot. 119 | */ 120 | public readonly applicationCommandHandler: ApplicationCommandHandler; 121 | 122 | /** 123 | * A map of the auto completes that the bot is currently handling. 124 | */ 125 | public autoCompletes: Map; 126 | 127 | /** 128 | * The auto complete handler for our bot. 129 | */ 130 | public readonly autoCompleteHandler: AutoCompleteHandler; 131 | 132 | /** 133 | * A map of the text commands that the bot is currently handling. 134 | */ 135 | public readonly textCommands: Map; 136 | 137 | /** 138 | * The text command handler for our bot. 139 | */ 140 | public readonly textCommandHandler: TextCommandHandler; 141 | 142 | /** 143 | * A map of the buttons that the bot is currently handling. 144 | */ 145 | public readonly buttons: Map; 146 | 147 | /** 148 | * The button handler for our bot. 149 | */ 150 | public readonly buttonHandler: ButtonHandler; 151 | 152 | /** 153 | * A map of the select menus the bot is currently handling. 154 | */ 155 | public readonly selectMenus: Map; 156 | 157 | /** 158 | * The select menu handler for our bot. 159 | */ 160 | public readonly selectMenuHandler: SelectMenuHandler; 161 | 162 | /** 163 | * A map of modals the bot is currently handling. 164 | */ 165 | public readonly modals: Map; 166 | 167 | /** 168 | * The modal handler for our bot. 169 | */ 170 | public readonly modalHandler: ModalHandler; 171 | 172 | /** 173 | * Our Stripe client 174 | */ 175 | public readonly stripe?: Stripe; 176 | 177 | public constructor({ rest, gateway }: ClientOptions) { 178 | super({ rest, gateway }); 179 | 180 | this.api = new API(rest); 181 | 182 | this.config = Config; 183 | this.logger = Logger; 184 | this.functions = new Functions(this); 185 | 186 | this.prisma = new PrismaClient({ 187 | errorFormat: "pretty", 188 | log: [ 189 | { 190 | level: "warn", 191 | emit: "stdout", 192 | }, 193 | { 194 | level: "error", 195 | emit: "stdout", 196 | }, 197 | { level: "query", emit: "event" }, 198 | ], 199 | }); 200 | 201 | this.guildOwnersCache = new Map(); 202 | this.guildRolesCache = new Map(); 203 | 204 | this.approximateUserCount = 0; 205 | 206 | // I forget what this is even used for, but Vlad from https://github.com/vladfrangu/highlight uses it and recommended me to use it a while ago. 207 | if (env.NODE_ENV === "development") { 208 | this.prisma.$use(async (params, next) => { 209 | const before = Date.now(); 210 | const result = await next(params); 211 | const after = Date.now(); 212 | 213 | this.logger.debug("prisma:query", `${params.model}.${params.action} took ${String(after - before)}ms`); 214 | 215 | return result; 216 | }); 217 | } 218 | 219 | if (env.STRIPE_KEY) { 220 | this.stripe = new Stripe(env.STRIPE_KEY); 221 | } 222 | 223 | this.i18n = i18next; 224 | 225 | this.__dirname = resolve(); 226 | 227 | this.languageHandler = new LanguageHandler(this); 228 | 229 | this.applicationCommands = new Map(); 230 | this.applicationCommandHandler = new ApplicationCommandHandler(this); 231 | 232 | this.autoCompletes = new Map(); 233 | this.autoCompleteHandler = new AutoCompleteHandler(this); 234 | 235 | this.textCommands = new Map(); 236 | this.textCommandHandler = new TextCommandHandler(this); 237 | 238 | this.buttons = new Map(); 239 | this.buttonHandler = new ButtonHandler(this); 240 | 241 | this.selectMenus = new Map(); 242 | this.selectMenuHandler = new SelectMenuHandler(this); 243 | 244 | this.modals = new Map(); 245 | this.modalHandler = new ModalHandler(this); 246 | 247 | this.events = new Map(); 248 | void this.loadEvents(); 249 | } 250 | 251 | /** 252 | * Start the client. 253 | */ 254 | public async start() { 255 | await this.i18n.use(intervalPlural).init({ 256 | fallbackLng: "en-US", 257 | resources: {}, 258 | fallbackNS: this.config.botName.toLowerCase().split(" ").join("_"), 259 | lng: "en-US", 260 | }); 261 | 262 | await this.languageHandler.loadLanguages(); 263 | await this.autoCompleteHandler.loadAutoCompletes(); 264 | await this.applicationCommandHandler.loadApplicationCommands(); 265 | await this.textCommandHandler.loadTextCommands(); 266 | await this.buttonHandler.loadButtons(); 267 | await this.selectMenuHandler.loadSelectMenus(); 268 | await this.modalHandler.loadModals(); 269 | } 270 | 271 | /** 272 | * Load all the events in the events directory. 273 | */ 274 | private async loadEvents() { 275 | for (const eventFileName of this.functions.getFiles(`${this.__dirname}/dist/src/bot/events`, ".js", true)) { 276 | const EventFile = await import(`../../src/bot/events/${eventFileName}`); 277 | 278 | const event = new EventFile.default(this) as EventHandler; 279 | 280 | event.listen(); 281 | 282 | this.events.set(event.name, event); 283 | } 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /languages/en-US.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | LANGUAGE_ENABLED: true, 3 | LANGUAGE_ID: "en-US", 4 | LANGUAGE_NAME: "English, US", 5 | 6 | PARSE_REGEX: 7 | /^(-?(?:\d+)?\.?\d+) *(m(?:illiseconds?|s(?:ecs?)?))?(s(?:ec(?:onds?|s)?)?)?(m(?:in(?:utes?|s)?)?)?(h(?:ours?|rs?)?)?(d(?:ays?)?)?(w(?:eeks?|ks?)?)?(y(?:ears?|rs?)?)?$/, 8 | MS_OTHER: "ms", 9 | SECOND_ONE: "second", 10 | SECOND_OTHER: "seconds", 11 | SECOND_SHORT: "s", 12 | MINUTE_ONE: "minute", 13 | MINUTE_OTHER: "minutes", 14 | MINUTE_SHORT: "m", 15 | HOUR_ONE: "hour", 16 | HOUR_OTHER: "hours", 17 | HOUR_SHORT: "h", 18 | DAY_ONE: "day", 19 | DAY_OTHER: "days", 20 | DAY_SHORT: "d", 21 | YEAR_ONE: "year", 22 | YEAR_OTHER: "years", 23 | YEAR_SHORT: "y", 24 | 25 | CreateInstantInvite: "Create Invite", 26 | KickMembers: "Kick Members", 27 | BanMembers: "Ban Members", 28 | Administrator: "Administrator", 29 | ManageChannels: "Manage Channels", 30 | ManageGuild: "Manage Server", 31 | AddReactions: "Add Reactions", 32 | ViewAuditLog: "View Audit Log", 33 | PrioritySpeaker: "Priority Speaker", 34 | Stream: "Video", 35 | ViewChannel: "View Channels", 36 | SendMessages: "Send Messages and Create Posts", 37 | SendTTSMessages: "Send Text-To-Speech Messages", 38 | ManageMessages: "Manage Messages", 39 | EmbedLinks: "Embed Links", 40 | AttachFiles: "Attach Files", 41 | ReadMessageHistory: "Read Message History", 42 | MentionEveryone: "Mention @everyone, @here, and All Roles", 43 | UseExternalEmojis: "Use External Emojis", 44 | ViewGuildInsights: "View Server Insights", 45 | Connect: "Connect", 46 | Speak: "Speak", 47 | MuteMembers: "Mute Members", 48 | DeafenMembers: "Deafen Members", 49 | MoveMembers: "Move Members", 50 | UseVAD: "Use Voice Activity", 51 | ChangeNickname: "Change Nickname", 52 | ManageNicknames: "Manage Nicknames", 53 | ManageRoles: "Manage Roles", 54 | ManageWebhooks: "Manage Webhooks", 55 | ManageEmojisAndStickers: "Manage Emojis and Stickers", 56 | ManageGuildExpressions: "Manage Expressions", 57 | UseApplicationCommands: "Use Application Commands", 58 | RequestToSpeak: "Request to Speak", 59 | ManageEvents: "Manage Events", 60 | ManageThreads: "Manage Threads and Posts", 61 | CreatePublicThreads: "Create Public Threads", 62 | CreatePrivateThreads: "Create Private Threads", 63 | UseExternalStickers: "Use External Stickers", 64 | SendMessagesInThreads: "Send Messages in Threads abd Posts", 65 | UseEmbeddedActivities: "Use Activities", 66 | ModerateMembers: "Timeout Members", 67 | ViewCreatorMonetizationAnalytics: "View Creator Monetization Analytics", 68 | CreateGuildExpressions: "Create Guild Expressions", 69 | UseSoundboard: "Use Soundboard", 70 | CreateEvents: "Create Events", 71 | UseExternalSounds: "Use External Sounds", 72 | SendVoiceMessages: "Send Voice Messages", 73 | SendPolls: "Send Polls", 74 | UseExternalApps: "Use External Apps", 75 | 76 | INVALID_ARGUMENT_TITLE: "Invalid Argument", 77 | 78 | INVALID_PATH_TITLE: "Invalid Command", 79 | INVALID_PATH_DESCRIPTION: "I have absolutely no idea how you reached this response.", 80 | 81 | INTERNAL_ERROR_TITLE: "Internal Error Encountered", 82 | INTERNAL_ERROR_DESCRIPTION: 83 | "An internal error has occurred, please try again later. This has already been reported to my developers.", 84 | SENTRY_EVENT_ID_FOOTER: "Sentry Event ID: {{eventId}}", 85 | 86 | NON_EXISTENT_APPLICATION_COMMAND_TITLE: "This {{type}} Does Not Exist", 87 | NON_EXISTENT_APPLICATION_COMMAND_DESCRIPTION: 88 | "You've somehow used a {{type}} that doesn't exist. I've removed the command so this won't happen in the future, this has already been reported to my developers.", 89 | 90 | MISSING_PERMISSIONS_BASE_TITLE: "Missing Permissions", 91 | MISSING_PERMISSIONS_OWNER_ONLY_DESCRIPTION: "This {{type}} can only be used by the owner of this server!", 92 | MISSING_PERMISSIONS_DEVELOPER_ONLY_DESCRIPTION: "This {{type}} can only be used by my developers!", 93 | MISSING_PERMISSIONS_USER_PERMISSIONS_ONE_DESCRIPTION: 94 | "You are missing the {{missingPermissions}} permission, which is required to use this {{type}}!", 95 | MISSING_PERMISSIONS_USER_PERMISSIONS_OTHER_DESCRIPTION: 96 | "You are missing the {{missingPermissions}} permissions, which are required to use this {{type}}!", 97 | MISSING_PERMISSIONS_CLIENT_PERMISSIONS_ONE_DESCRIPTION: 98 | "I am missing the {{missingPermissions}} permission, which I need to run this {{type}}!", 99 | MISSING_PERMISSIONS_CLIENT_PERMISSIONS_OTHER_DESCRIPTION: 100 | "I am missing the {{missingPermissions}} permissions, which I need to run this {{type}}!", 101 | 102 | TYPE_ON_COOLDOWN_TITLE: "{{type}} On Cooldown", 103 | TYPE_ON_COOLDOWN_DESCRIPTION: "This {{type}} is on cooldown for another {{formattedTime}}!", 104 | COOLDOWN_ON_TYPE_TITLE: "Cooldown On All {{type}}", 105 | COOLDOWN_ON_TYPE_DESCRIPTION: "Please wait a second before running another {{type}}!", 106 | 107 | AN_ERROR_HAS_OCCURRED_TITLE: "An Error Has Occurred", 108 | AN_ERROR_HAS_OCCURRED_DESCRIPTION: 109 | "An error has occurred, please try again later. This has already been reported to my developers.", 110 | 111 | PING_COMMAND_NAME: "ping", 112 | PING_COMMAND_DESCRIPTION: "Pong! Get the current ping / latency of Yapper.", 113 | 114 | PING: "Ping?", 115 | PONG: "Pong! (Host latency of {{hostLatency}}ms)", 116 | 117 | TRANSCRIBE_COMMAND_NAME: "Transcribe", 118 | TRANSCRIBE_EPHEMERAL_COMMAND_NAME: "Transcribe (Ephemeral)", 119 | 120 | PREMIUM_COMMAND_NAME: "premium", 121 | PREMIUM_COMMAND_DESCRIPTION: "Manage Yapper premium.", 122 | PREMIUM_COMMAND_INFO_SUB_COMMAND_NAME: "info", 123 | PREMIUM_COMMAND_INFO_SUB_COMMAND_DESCRIPTION: "Get information about Yapper premium or subscribe.", 124 | PREMIUM_COMMAND_APPLY_SUB_COMMAND_NAME: "apply", 125 | PREMIUM_COMMAND_APPLY_SUB_COMMAND_DESCRIPTION: "Apply premium to the current server.", 126 | PREMIUM_COMMAND_REMOVE_SUB_COMMAND_NAME: "remove", 127 | PREMIUM_COMMAND_REMOVE_SUB_COMMAND_DESCRIPTION: "Remove premium from a server.", 128 | PREMIUM_COMMAND_REMOVE_SUB_COMMAND_SERVER_OPTION_NAME: "server", 129 | PREMIUM_COMMAND_REMOVE_SUB_COMMAND_SERVER_OPTION_DESCRIPTION: 130 | "The server to remove premium from, defaults to the current server if not specified.", 131 | 132 | PREMIUM_INFO_NO_PRODUCTS_TITLE: "No Products Available", 133 | PREMIUM_INFO_NO_PRODUCTS_DESCRIPTION: 134 | "There are currently no products available for purchase, thus Premium is unavailable.", 135 | 136 | PREMIUM_INFO_TITLE: "Yapper Premium", 137 | PREMIUM_INFO_DESCRIPTION: 138 | "Yapper Premium offers transcriptions of audio and video attachments, removal of the five minute content length limit, as well as higher quality transcriptions. Subscribe to a plan by choosing one from the dropdown below!{{subscriptionInfo}}", 139 | SUBSCRIPTION_INFO: 140 | "\n\nYou are currently subscribed to Yapper Premium and have {{appliedGuildCount}}/{{maxGuildCount}} active premium guilds. Your subscription will renew on {{date}}!", 141 | 142 | CONFIG_COMMAND_NAME: "config", 143 | CONFIG_COMMAND_DESCRIPTION: "Configure Yapper for your server.", 144 | CONFIG_COMMAND_AUTO_TRANSCRIPT_VOICE_MESSAGES_SUB_COMMAND_GROUP_NAME: "auto_transcript_voice_messages", 145 | CONFIG_COMMAND_AUTO_TRANSCRIPT_VOICE_MESSAGES_SUB_COMMAND_GROUP_DESCRIPTION: 146 | "Manage automatically transcribe voice messages.", 147 | CONFIG_COMMAND_AUTO_TRANSCRIPT_VOICE_MESSAGES_SUB_COMMAND_GROUP_ENABLE_SUB_COMMAND_NAME: "enable", 148 | CONFIG_COMMAND_AUTO_TRANSCRIPT_VOICE_MESSAGES_SUB_COMMAND_GROUP_ENABLE_SUB_COMMAND_DESCRIPTION: 149 | "Enable automatically transcribing voice messages.", 150 | CONFIG_COMMAND_AUTO_TRANSCRIPT_VOICE_MESSAGES_SUB_COMMAND_GROUP_DISABLE_SUB_COMMAND_NAME: "disable", 151 | CONFIG_COMMAND_AUTO_TRANSCRIPT_VOICE_MESSAGES_SUB_COMMAND_GROUP_DISABLE_SUB_COMMAND_DESCRIPTION: 152 | "Disable automatically transcribing voice messages.", 153 | 154 | AUTO_TRANSCRIPT_VOICE_MESSAGES_ENABLED_TITLE: "Auto Transcript Voice Messages Enabled", 155 | AUTO_TRANSCRIPT_VOICE_MESSAGES_ENABLED_DESCRIPTION: 156 | "Yapper will now automatically transcribe voice messages in this server.", 157 | 158 | AUTO_TRANSCRIPT_VOICE_MESSAGES_DISABLED_TITLE: "Auto Transcript Voice Messages Disabled", 159 | AUTO_TRANSCRIPT_VOICE_MESSAGES_DISABLED_DESCRIPTION: 160 | "Yapper will no longer automatically transcribe voice messages in this server.", 161 | 162 | NO_VALID_ATTACHMENTS_ERROR: ":man_gesturing_no: There are no valid attachments on this message!", 163 | MESSAGE_STILL_BEING_TRANSCRIBED_ERROR: 164 | "This message is still being transcribed, please wait a moment and then try again!", 165 | TRANSCRIBING: ":writing_hand: Transcribing, this may take a moment...", 166 | READ_MORE_BUTTON_LABEL: "Read More", 167 | TRANSCRIBED_MESSAGE_BUTTON_LABEL: "Transcribed Message", 168 | 169 | NOT_A_PREMIUM_GUILD_ERROR_TITLE: "Not A Premium Guild", 170 | NOT_A_PREMIUM_GUILD_CONTEXT_MENU_ERROR_DESCRIPTION: 171 | "This server is not a premium guild, please upgrade to premium to use context menus.", 172 | NOT_A_PREMIUM_GUILD_FILES_ERROR_DESCRIPTION: 173 | "This server is not a premium guild, please upgrade to premium to transcribe files.", 174 | 175 | IGNORE_COMMAND_NAME: "ignore", 176 | IGNORE_COMMAND_DESCRIPTION: "Configure how Yapper ignores users.", 177 | IGNORE_COMMAND_CONTEXT_MENU_SUB_COMMAND_NAME: "context_menu", 178 | IGNORE_COMMAND_CONTEXT_MENU_SUB_COMMAND_DESCRIPTION: 179 | "Have Yapper ignore when someone else is trying to transcribe your messages with a context menu.", 180 | IGNORE_COMMAND_AUTO_TRANSCRIPTION_SUB_COMMAND_NAME: "auto_transcription", 181 | IGNORE_COMMAND_AUTO_TRANSCRIPTION_SUB_COMMAND_DESCRIPTION: 182 | "Have Yapper ignore your messages when auto transcription is enabled.", 183 | IGNORE_COMMAND_ALL_SUB_COMMAND_NAME: "all", 184 | IGNORE_COMMAND_ALL_SUB_COMMAND_DESCRIPTION: 185 | "Have Yapper ignore your messages completely (unless you use a context menu on your own messages).", 186 | 187 | IGNORED_SUCCESSFULLY_TITLE: "Ignored Successfully", 188 | IGNORED_SUCCESSFULLY_DESCRIPTION: "Yapper will now ignore messages from you.", 189 | 190 | UNIGORED_SUCCESSFULLY_TITLE: "Unignored Successfully", 191 | UNIGORED_SUCCESSFULLY_DESCRIPTION: "Yapper will no longer ignore messages from you.", 192 | 193 | USER_IS_IGNORED_ERROR: "This user has opted out of Yapper, their messages can not be transcribed.", 194 | 195 | NOT_A_GUILD_TITLE: "Not A Server", 196 | NOT_A_GUILD_DESCRIPTION: "You can only use this command in a server!", 197 | 198 | NOT_A_PREMIUM_USER_ERROR_TITLE: "Not A Premium User", 199 | NOT_A_PREMIUM_USER_ERROR_DESCRIPTION: "You are not a premium user, please subscribe to premium to use this command.", 200 | 201 | MAX_GUILD_COUNT_REACHED_ERROR_TITLE: "Max Guild Count Reached", 202 | MAX_GUILD_COUNT_REACHED_ERROR_DESCRIPTION: 203 | "You have reached your maximum guild count, please remove a guild from premium to add another.", 204 | 205 | PREMIUM_APPLIED_TITLE: "Premium Applied", 206 | PREMIUM_APPLIED_DESCRIPTION: "This server is now a premium guild!", 207 | 208 | PREMIUM_REMOVED_TITLE: "Premium Removed", 209 | PREMIUM_REMOVED_DESCRIPTION: "I have removed premium from the guild you've provided!", 210 | }; 211 | -------------------------------------------------------------------------------- /lib/classes/ApplicationCommand.ts: -------------------------------------------------------------------------------- 1 | import { env } from "node:process"; 2 | import type { 3 | APIApplicationCommandInteraction, 4 | APIContextMenuInteraction, 5 | APIEmbed, 6 | Permissions, 7 | RESTPostAPIApplicationCommandsJSONBody, 8 | } from "@discordjs/core"; 9 | import { ApplicationCommandType, RESTJSONErrorCodes } from "@discordjs/core"; 10 | import { DiscordAPIError } from "@discordjs/rest"; 11 | import type { APIInteractionWithArguments } from "../../typings"; 12 | import type ExtendedClient from "../extensions/ExtendedClient.js"; 13 | import PermissionsBitField from "../utilities/permissions.js"; 14 | import type Language from "./Language.js"; 15 | 16 | export default class ApplicationCommand { 17 | /** 18 | * Our extended client. 19 | */ 20 | public readonly client: ExtendedClient; 21 | 22 | /** 23 | * The name for this application command. 24 | */ 25 | public readonly name: string; 26 | 27 | /** 28 | * The type of application command. 29 | */ 30 | public readonly type: ApplicationCommandType; 31 | 32 | /** 33 | * The options for this application command. 34 | */ 35 | public readonly options: RESTPostAPIApplicationCommandsJSONBody; 36 | 37 | /** 38 | * The permissions the user requires to run this application command. 39 | */ 40 | private readonly permissions: Permissions; 41 | 42 | /** 43 | * The permissions the client requires to run this application command. 44 | */ 45 | private readonly clientPermissions: Permissions; 46 | 47 | /** 48 | * Whether or not this application command can only be used by developers. 49 | */ 50 | private readonly devOnly: boolean; 51 | 52 | /** 53 | * Whether or not this application command can only be run by the guild owner. 54 | */ 55 | private readonly ownerOnly: boolean; 56 | 57 | /** 58 | * The cooldown on this application command. 59 | */ 60 | public readonly cooldown: number; 61 | 62 | /** 63 | * The guilds this application command should be loaded into, if this value is defined, this command will only be added to these guilds and not globally. 64 | */ 65 | public readonly guilds: string[]; 66 | 67 | /** 68 | * Create a new application command. 69 | * 70 | * @param client Our extended client. 71 | * @param options The options for this application command. 72 | * @param options.clientPermissions The permissions the client requires to run this application command. 73 | * @param options.cooldown The cooldown on this application command. 74 | * @param options.devOnly Whether or not this application command can only be used by developers. 75 | * @param options.guilds The guilds this application command should be loaded into, if this value is defined, this command will only be added to these guilds and not globally. 76 | * @param options.options The options for this application command. 77 | * @param options.ownerOnly Whether or not this application command can only be run by the guild owner. 78 | */ 79 | public constructor( 80 | client: ExtendedClient, 81 | options: { 82 | clientPermissions?: Permissions; 83 | cooldown?: number; 84 | devOnly?: boolean; 85 | guilds?: string[]; 86 | options: RESTPostAPIApplicationCommandsJSONBody; 87 | ownerOnly?: boolean; 88 | }, 89 | ) { 90 | this.client = client; 91 | 92 | this.type = options.options.type!; 93 | this.options = options.options; 94 | this.name = options.options.name; 95 | 96 | this.permissions = options.options.default_member_permissions ?? "0"; 97 | this.clientPermissions = options.clientPermissions ?? "0"; 98 | 99 | this.devOnly = options.devOnly ?? false; 100 | this.ownerOnly = options.ownerOnly ?? false; 101 | 102 | this.cooldown = options.cooldown ?? 0; 103 | 104 | this.guilds = options.guilds ?? []; 105 | } 106 | 107 | /** 108 | * Apply a cooldown to a user. 109 | * 110 | * @param userId The userID to apply the cooldown on. 111 | * @param cooldown The cooldown to apply, if not provided the default cooldown for this application command will be used. 112 | * @returns True or False if the cooldown was applied. 113 | */ 114 | public async applyCooldown(userId: string, cooldown?: number) { 115 | if (this.cooldown) { 116 | const expiresAt = new Date(Date.now() + (cooldown ?? this.cooldown)); 117 | 118 | return Boolean( 119 | this.client.prisma.cooldown.upsert({ 120 | where: { 121 | commandName_commandType_userId: { 122 | commandName: this.name, 123 | commandType: "APPLICATION_COMMAND", 124 | userId, 125 | }, 126 | }, 127 | update: { 128 | expiresAt, 129 | }, 130 | create: { 131 | commandName: this.name, 132 | commandType: "APPLICATION_COMMAND", 133 | expiresAt, 134 | userId, 135 | }, 136 | }), 137 | ); 138 | } 139 | 140 | return false; 141 | } 142 | 143 | /** 144 | * Validate that the interaction provided is valid. 145 | * 146 | * @param options The options for this function. 147 | * @param options.interaction The interaction to validate. 148 | * @param options.language The language to use when replying to the interaction. 149 | * @param options.shardId The shard ID to use when replying to the interaction. 150 | * @returns An APIEmbed if the interaction is invalid, null if the interaction is valid. 151 | */ 152 | public async validate({ 153 | interaction, 154 | language, 155 | }: { 156 | interaction: APIInteractionWithArguments; 157 | language: Language; 158 | shardId: number; 159 | }): Promise { 160 | const type = this.type === ApplicationCommandType.ChatInput ? "slash command" : "context menu"; 161 | 162 | if (this.ownerOnly && interaction.guild_id) { 163 | if (!this.client.guildOwnersCache.has(interaction.guild_id)) 164 | try { 165 | const guild = await this.client.api.guilds.get(interaction.guild_id); 166 | 167 | this.client.guildOwnersCache.set(interaction.guild_id, guild.owner_id); 168 | } catch (error) { 169 | if (error instanceof DiscordAPIError && error.code === RESTJSONErrorCodes.UnknownGuild) { 170 | const eventId = await this.client.logger.sentry.captureWithInteraction(error, interaction); 171 | 172 | return { 173 | title: language.get("INTERNAL_ERROR_TITLE"), 174 | description: language.get("INTERNAL_ERROR_DESCRIPTION"), 175 | footer: { 176 | text: language.get("SENTRY_EVENT_ID_FOOTER", { 177 | eventId, 178 | }), 179 | }, 180 | }; 181 | } 182 | 183 | await this.client.logger.sentry.captureWithInteraction(error, interaction); 184 | throw error; 185 | } 186 | 187 | const guildOwnerId = this.client.guildOwnersCache.get(interaction.guild_id); 188 | 189 | if (guildOwnerId !== (interaction.member ?? interaction).user!.id) 190 | return { 191 | title: language.get("MISSING_PERMISSIONS_BASE_TITLE"), 192 | description: language.get("MISSING_PERMISSIONS_OWNER_ONLY_DESCRIPTION", { 193 | type, 194 | }), 195 | }; 196 | } else if (this.devOnly && !this.client.config.admins.includes((interaction.member ?? interaction).user!.id || "")) 197 | return { 198 | title: language.get("MISSING_PERMISSIONS_BASE_TITLE"), 199 | description: language.get("MISSING_PERMISSIONS_DEVELOPER_ONLY_DESCRIPTION", { 200 | type, 201 | }), 202 | }; 203 | else if ( 204 | interaction.guild_id && 205 | this.permissions !== "0" && 206 | !PermissionsBitField.has(BigInt(interaction.member?.permissions ?? 0), BigInt(this.permissions)) 207 | ) { 208 | const missingPermissions = PermissionsBitField.toArray( 209 | PermissionsBitField.difference(BigInt(this.permissions), BigInt(interaction.member?.permissions ?? 0)), 210 | ); 211 | 212 | if (missingPermissions) { 213 | if (!this.client.guildOwnersCache.has(interaction.guild_id)) 214 | try { 215 | const guild = await this.client.api.guilds.get(interaction.guild_id); 216 | 217 | this.client.guildOwnersCache.set(interaction.guild_id, guild.owner_id); 218 | } catch (error) { 219 | if (error instanceof DiscordAPIError && error.code === RESTJSONErrorCodes.UnknownGuild) { 220 | const eventId = await this.client.logger.sentry.captureWithInteraction(error, interaction); 221 | 222 | return { 223 | title: language.get("INTERNAL_ERROR_TITLE"), 224 | description: language.get("INTERNAL_ERROR_DESCRIPTION"), 225 | footer: { 226 | text: language.get("SENTRY_EVENT_ID_FOOTER", { 227 | eventId, 228 | }), 229 | }, 230 | }; 231 | } 232 | 233 | await this.client.logger.sentry.captureWithInteraction(error, interaction); 234 | throw error; 235 | } 236 | 237 | if ((interaction.member ?? interaction).user!.id !== this.client.guildOwnersCache.get(interaction.guild_id)) 238 | return { 239 | title: language.get("MISSING_PERMISSIONS_BASE_TITLE"), 240 | description: language.get( 241 | missingPermissions.length === 1 242 | ? "MISSING_PERMISSIONS_USER_PERMISSIONS_ONE_DESCRIPTION" 243 | : "MISSING_PERMISSIONS_USER_PERMISSIONS_OTHER_DESCRIPTION", 244 | { 245 | type, 246 | missingPermissions: missingPermissions 247 | .map((missingPermission) => `**${language.get(missingPermission)}**`) 248 | .join(", "), 249 | }, 250 | ), 251 | }; 252 | } 253 | } else if ( 254 | interaction.guild_id && 255 | this.clientPermissions && 256 | !PermissionsBitField.has(BigInt(interaction.app_permissions ?? 0), BigInt(this.clientPermissions)) 257 | ) { 258 | const missingPermissions = PermissionsBitField.toArray( 259 | PermissionsBitField.difference(BigInt(this.clientPermissions), BigInt(interaction.app_permissions ?? 0)), 260 | ); 261 | 262 | if (missingPermissions) { 263 | try { 264 | if (!this.client.guildOwnersCache.has(interaction.guild_id)) { 265 | const guild = await this.client.api.guilds.get(interaction.guild_id); 266 | 267 | this.client.guildOwnersCache.set(interaction.guild_id, guild.owner_id); 268 | } 269 | } catch (error) { 270 | if ( 271 | error instanceof DiscordAPIError && 272 | ([RESTJSONErrorCodes.UnknownMember, RESTJSONErrorCodes.UnknownGuild] as (number | string)[]).includes( 273 | error.code, 274 | ) 275 | ) { 276 | const eventId = await this.client.logger.sentry.captureWithInteraction(error, interaction); 277 | 278 | return { 279 | title: language.get("INTERNAL_ERROR_TITLE"), 280 | description: language.get("INTERNAL_ERROR_DESCRIPTION"), 281 | footer: { 282 | text: language.get("SENTRY_EVENT_ID_FOOTER", { 283 | eventId, 284 | }), 285 | }, 286 | }; 287 | } 288 | 289 | throw error; 290 | } 291 | 292 | if (this.client.guildOwnersCache.get(interaction.guild_id) !== env.APPLICATION_ID) 293 | return { 294 | title: language.get("MISSING_PERMISSIONS_BASE_TITLE"), 295 | description: language.get( 296 | missingPermissions.length === 1 297 | ? "MISSING_PERMISSIONS_CLIENT_PERMISSIONS_ONE_DESCRIPTION" 298 | : "MISSING_PERMISSIONS_CLIENT_PERMISSIONS_OTHER_DESCRIPTION", 299 | { 300 | type, 301 | missingPermissions: missingPermissions 302 | .map((missingPermission) => `**${language.get(missingPermission)}**`) 303 | .join(", "), 304 | }, 305 | ), 306 | }; 307 | } 308 | } else if (this.cooldown) { 309 | const cooldownItem = await this.client.prisma.cooldown.findUnique({ 310 | where: { 311 | commandName_commandType_userId: { 312 | commandName: this.name, 313 | commandType: "APPLICATION_COMMAND", 314 | userId: (interaction.member ?? interaction).user!.id, 315 | }, 316 | }, 317 | }); 318 | 319 | if (cooldownItem && Date.now() > cooldownItem.expiresAt.valueOf()) 320 | return { 321 | title: language.get("TYPE_ON_COOLDOWN_TITLE"), 322 | description: language.get("TYPE_ON_COOLDOWN_DESCRIPTION", { 323 | type, 324 | formattedTime: this.client.functions.format(cooldownItem.expiresAt.valueOf() - Date.now(), true, language), 325 | }), 326 | }; 327 | } 328 | 329 | return null; 330 | } 331 | 332 | /** 333 | * Pre-check the provided interaction after validating it. 334 | * 335 | * @param _options The options to pre-check. 336 | * @param _options.interaction The interaction to pre-check. 337 | * @param _options.language The language to use when replying to the interaction. 338 | * @param _options.shardId The shard ID to use when replying to the interaction. 339 | * @returns A tuple containing a boolean and an APIEmbed if the interaction is invalid, a boolean if the interaction is valid. 340 | */ 341 | public async preCheck(_options: { 342 | interaction: APIInteractionWithArguments; 343 | language: Language; 344 | shardId: number; 345 | }): Promise<[boolean, APIEmbed?]> { 346 | return [true]; 347 | } 348 | 349 | /** 350 | * Run this application command. 351 | * 352 | * @param _options The options to run this application command. 353 | * @param _options.interaction The interaction that triggered this application command. 354 | * @param _options.language The language to use when replying to the interaction. 355 | * @param _options.shardId The shard ID the interaction belongs to. 356 | */ 357 | public async run(_options: { 358 | interaction: APIInteractionWithArguments; 359 | language: Language; 360 | shardId: number; 361 | }): Promise {} 362 | } 363 | -------------------------------------------------------------------------------- /lib/classes/TextCommand.ts: -------------------------------------------------------------------------------- 1 | import { env } from "node:process"; 2 | import type { APIEmbed, APIRole, GatewayMessageCreateDispatchData, Permissions } from "@discordjs/core"; 3 | import { RESTJSONErrorCodes } from "@discordjs/core"; 4 | import { DiscordAPIError } from "@discordjs/rest"; 5 | import type ExtendedClient from "../extensions/ExtendedClient.js"; 6 | import PermissionsBitField from "../utilities/permissions.js"; 7 | import type Language from "./Language.js"; 8 | 9 | export default class TextCommand { 10 | /** 11 | * Our extended client. 12 | */ 13 | public readonly client: ExtendedClient; 14 | 15 | /** 16 | * The name for this application command. 17 | */ 18 | public readonly name: string; 19 | 20 | /** 21 | * The permissions the user requires to run this application command. 22 | */ 23 | public readonly permissions: Permissions; 24 | 25 | /** 26 | * The permissions the client requires to run this application command. 27 | */ 28 | public readonly clientPermissions: Permissions; 29 | 30 | /** 31 | * Whether or not this application command can only be used by developers. 32 | */ 33 | public readonly devOnly: boolean; 34 | 35 | /** 36 | * Whether or not this application command can only be run by the guild owner. 37 | */ 38 | public readonly ownerOnly: boolean; 39 | 40 | /** 41 | * The cooldown on this application command. 42 | */ 43 | public readonly cooldown: number; 44 | 45 | /** 46 | * Create a new text command. 47 | * 48 | * @param client Our extended client. 49 | * @param options The options for this application command. 50 | * @param options.name The name for this application command. 51 | * @param options.description The description for this application command. 52 | * @param options.clientPermissions The permissions the client requires to run this application command. 53 | * @param options.cooldown The cooldown on this application command. 54 | * @param options.devOnly Whether or not this application command can only be used by developers. 55 | * @param options.ownerOnly Whether or not this application command can only be run by the guild owner. 56 | * @param options.permissions The permissions the user requires to run this application command. 57 | */ 58 | public constructor( 59 | client: ExtendedClient, 60 | options: { 61 | clientPermissions?: Permissions; 62 | cooldown?: number; 63 | description?: string; 64 | devOnly?: boolean; 65 | name: string; 66 | ownerOnly?: boolean; 67 | permissions?: Permissions; 68 | }, 69 | ) { 70 | this.client = client; 71 | 72 | this.name = options.name; 73 | 74 | this.permissions = options.permissions ?? "0"; 75 | this.clientPermissions = options.clientPermissions ?? "0"; 76 | 77 | this.devOnly = options.devOnly ?? false; 78 | this.ownerOnly = options.ownerOnly ?? false; 79 | 80 | this.cooldown = options.cooldown ?? 0; 81 | } 82 | 83 | /** 84 | * Apply a cooldown to a user. 85 | * 86 | * @param userId The userID to apply the cooldown on. 87 | * @param cooldown The cooldown to apply, if not provided the default cooldown for this text command will be used. 88 | * @returns True or False if the cooldown was applied. 89 | */ 90 | public async applyCooldown(userId: string, cooldown?: number) { 91 | if (this.cooldown) { 92 | const expiresAt = new Date(Date.now() + (cooldown ?? this.cooldown)); 93 | 94 | return this.client.prisma.cooldown.upsert({ 95 | where: { 96 | commandName_commandType_userId: { 97 | commandName: this.name, 98 | commandType: "TEXT_COMMAND", 99 | userId, 100 | }, 101 | }, 102 | update: { 103 | expiresAt, 104 | }, 105 | create: { 106 | commandName: this.name, 107 | commandType: "TEXT_COMMAND", 108 | expiresAt, 109 | userId, 110 | }, 111 | }); 112 | } 113 | 114 | return false; 115 | } 116 | 117 | /** 118 | * Validate that the message provided is valid. 119 | * 120 | * @param options The options to validate the message with. 121 | * @param options.args The arguments to validate the message with. 122 | * @param options.message The message to validate. 123 | * @param options.language The language to use when replying to the message. 124 | * @param options.shardId The shard ID to use when replying to the message. 125 | * @returns An APIEmbed if the message is invalid, null if the message is valid. 126 | */ 127 | public async validate({ 128 | message, 129 | language, 130 | }: { 131 | args: string[]; 132 | language: Language; 133 | message: GatewayMessageCreateDispatchData; 134 | shardId: number; 135 | }): Promise { 136 | if (this.ownerOnly && message.guild_id) { 137 | if (!this.client.guildOwnersCache.has(message.guild_id)) 138 | try { 139 | const guild = await this.client.api.guilds.get(message.guild_id); 140 | 141 | this.client.guildOwnersCache.set(message.guild_id, guild.owner_id); 142 | } catch (error) { 143 | if (error instanceof DiscordAPIError && error.code === RESTJSONErrorCodes.UnknownGuild) { 144 | const eventId = await this.client.logger.sentry.captureWithMessage(error, message); 145 | 146 | return { 147 | title: language.get("INTERNAL_ERROR_TITLE"), 148 | description: language.get("INTERNAL_ERROR_DESCRIPTION"), 149 | footer: { 150 | text: language.get("SENTRY_EVENT_ID_FOOTER", { 151 | eventId, 152 | }), 153 | }, 154 | }; 155 | } 156 | 157 | await this.client.logger.sentry.captureWithMessage(error, message); 158 | throw error; 159 | } 160 | 161 | const guildOwnerId = this.client.guildOwnersCache.get(message.guild_id); 162 | 163 | if (guildOwnerId !== message.author.id) 164 | return { 165 | title: language.get("MISSING_PERMISSIONS_BASE_TITLE"), 166 | description: language.get("MISSING_PERMISSIONS_OWNER_ONLY_DESCRIPTION", { 167 | type: "text command", 168 | }), 169 | }; 170 | } else if (this.devOnly && !this.client.config.admins.includes(message.author.id)) 171 | return { 172 | title: language.get("MISSING_PERMISSIONS_BASE_TITLE"), 173 | description: language.get("MISSING_PERMISSIONS_DEVELOPER_ONLY_DESCRIPTION", { 174 | type: "text command", 175 | }), 176 | }; 177 | else if (message.guild_id && this.permissions !== "0") { 178 | if (!this.client.guildRolesCache.get(message.guild_id)) { 179 | const guildRoles = await this.client.api.guilds.getRoles(message.guild_id); 180 | const guildRolesMap = new Map(); 181 | 182 | for (const role of guildRoles) guildRolesMap.set(role.id, role); 183 | 184 | this.client.guildRolesCache.set(message.guild_id, new Map(guildRolesMap)); 185 | } 186 | 187 | const guildRoles = this.client.guildRolesCache.get(message.guild_id)!; 188 | 189 | if (!guildRoles) 190 | return { 191 | title: language.get("INTERNAL_ERROR_TITLE"), 192 | description: language.get("INTERNAL_ERROR_DESCRIPTION"), 193 | }; 194 | 195 | const missingPermissions = PermissionsBitField.toArray( 196 | PermissionsBitField.difference( 197 | BigInt(this.permissions), 198 | PermissionsBitField.resolve( 199 | message 200 | .member!.roles.map((role) => guildRoles.get(role)) 201 | .filter(Boolean) 202 | .map((role) => BigInt(role!.permissions)), 203 | ), 204 | ), 205 | ); 206 | 207 | if (missingPermissions) { 208 | if (!this.client.guildOwnersCache.has(message.guild_id)) 209 | try { 210 | const guild = await this.client.api.guilds.get(message.guild_id); 211 | 212 | this.client.guildOwnersCache.set(message.guild_id, guild.owner_id); 213 | } catch (error) { 214 | if (error instanceof DiscordAPIError && error.code === RESTJSONErrorCodes.UnknownGuild) { 215 | const eventId = await this.client.logger.sentry.captureWithMessage(error, message); 216 | 217 | return { 218 | title: language.get("INTERNAL_ERROR_TITLE"), 219 | description: language.get("INTERNAL_ERROR_DESCRIPTION"), 220 | footer: { 221 | text: language.get("SENTRY_EVENT_ID_FOOTER", { 222 | eventId, 223 | }), 224 | }, 225 | }; 226 | } 227 | 228 | await this.client.logger.sentry.captureWithMessage(error, message); 229 | throw error; 230 | } 231 | 232 | if (message.author.id !== this.client.guildOwnersCache.get(message.guild_id)) 233 | return { 234 | title: language.get("MISSING_PERMISSIONS_BASE_TITLE"), 235 | description: language.get( 236 | missingPermissions.length === 1 237 | ? "MISSING_PERMISSIONS_USER_PERMISSIONS_ONE_DESCRIPTION" 238 | : "MISSING_PERMISSIONS_USER_PERMISSIONS_OTHER_DESCRIPTION", 239 | { 240 | type: "text command", 241 | missingPermissions: missingPermissions 242 | .map((missingPermission) => `**${language.get(missingPermission)}**`) 243 | .join(", "), 244 | }, 245 | ), 246 | }; 247 | } 248 | } else if (message.guild_id && this.clientPermissions !== "0") { 249 | const guildMe = await this.client.api.guilds.getMember(message.guild_id, env.APPLICATION_ID); 250 | 251 | try { 252 | if (!this.client.guildOwnersCache.has(message.guild_id)) { 253 | const guild = await this.client.api.guilds.get(message.guild_id, { with_counts: false }); 254 | 255 | this.client.guildOwnersCache.set(message.guild_id, guild.owner_id); 256 | } 257 | } catch (error) { 258 | if ( 259 | error instanceof DiscordAPIError && 260 | ([RESTJSONErrorCodes.UnknownMember, RESTJSONErrorCodes.UnknownGuild] as (number | string)[]).includes( 261 | error.code, 262 | ) 263 | ) { 264 | const eventId = await this.client.logger.sentry.captureWithMessage(error, message); 265 | 266 | return { 267 | title: language.get("INTERNAL_ERROR_TITLE"), 268 | description: language.get("INTERNAL_ERROR_DESCRIPTION"), 269 | footer: { 270 | text: language.get("SENTRY_EVENT_ID_FOOTER", { 271 | eventId, 272 | }), 273 | }, 274 | }; 275 | } 276 | 277 | throw error; 278 | } 279 | 280 | if (!this.client.guildRolesCache.get(message.guild_id)) { 281 | const guildRoles = await this.client.api.guilds.getRoles(message.guild_id); 282 | const guildRolesMap = new Map(); 283 | 284 | for (const role of guildRoles) guildRolesMap.set(role.id, role); 285 | 286 | this.client.guildRolesCache.set(message.guild_id, new Map(guildRolesMap)); 287 | } 288 | 289 | const guildRoles = this.client.guildRolesCache.get(message.guild_id)!; 290 | 291 | if (!guildRoles) 292 | return { 293 | title: language.get("INTERNAL_ERROR_TITLE"), 294 | description: language.get("INTERNAL_ERROR_DESCRIPTION"), 295 | }; 296 | 297 | const missingPermissions = PermissionsBitField.toArray( 298 | PermissionsBitField.difference( 299 | BigInt(this.permissions), 300 | PermissionsBitField.resolve( 301 | guildMe.roles 302 | .map((role) => guildRoles.get(role)) 303 | .filter(Boolean) 304 | .map((role) => BigInt(role!.permissions)), 305 | ), 306 | ), 307 | ); 308 | 309 | if (missingPermissions && this.client.guildOwnersCache.get(message.guild_id) !== env.APPLICATION_ID) 310 | return { 311 | title: language.get("MISSING_PERMISSIONS_BASE_TITLE"), 312 | description: language.get( 313 | missingPermissions.length === 1 314 | ? "MISSING_PERMISSIONS_CLIENT_PERMISSIONS_ONE_DESCRIPTION" 315 | : "MISSING_PERMISSIONS_CLIENT_PERMISSIONS_OTHER_DESCRIPTION", 316 | { 317 | type: "text command", 318 | missingPermissions: missingPermissions 319 | .map((missingPermission) => `**${language.get(missingPermission)}**`) 320 | .join(", "), 321 | }, 322 | ), 323 | }; 324 | } else if (this.cooldown) { 325 | const cooldownItem = await this.client.prisma.cooldown.findUnique({ 326 | where: { 327 | commandName_commandType_userId: { 328 | commandName: this.name, 329 | commandType: "APPLICATION_COMMAND", 330 | userId: message.author.id, 331 | }, 332 | }, 333 | }); 334 | 335 | if (cooldownItem && Date.now() > cooldownItem.expiresAt.valueOf()) 336 | return { 337 | title: language.get("TYPE_ON_COOLDOWN_TITLE"), 338 | description: language.get("TYPE_ON_COOLDOWN_DESCRIPTION", { 339 | type: "text command", 340 | formattedTime: this.client.functions.format(cooldownItem.expiresAt.valueOf() - Date.now(), true, language), 341 | }), 342 | }; 343 | } 344 | 345 | return null; 346 | } 347 | 348 | /** 349 | * Pre-check the provided message after validating it. 350 | * 351 | * @param _options The options to pre-check. 352 | * @param _options.args The arguments to use when pre-checking the message. 353 | * @param _options.message The message to pre-check. 354 | * @param _options.language The language to use when replying to the message. 355 | * @param _options.shardId The shard ID to use when replying to the message. 356 | * @returns A tuple containing a boolean and an APIEmbed if the message is invalid, a boolean if the message is valid. 357 | */ 358 | public async preCheck(_options: { 359 | args: string[]; 360 | language: Language; 361 | message: GatewayMessageCreateDispatchData; 362 | shardId: number; 363 | }): Promise<[boolean, APIEmbed?]> { 364 | return [true]; 365 | } 366 | 367 | /** 368 | * Run this text command. 369 | * 370 | * @param _options The options to run this text command. 371 | * @param _options.args The arguments to use when running this text command. 372 | * @param _options.message The message that triggered this text command. 373 | * @param _options.language The language to use when replying to the message. 374 | * @param _options.shardId The shard ID the message belongs to. 375 | */ 376 | public async run(_options: { 377 | args: string[]; 378 | language: Language; 379 | message: GatewayMessageCreateDispatchData; 380 | shardId: number; 381 | }): Promise {} 382 | } 383 | --------------------------------------------------------------------------------