From 1e8c732098b60e461f7e9538351f1ba77a429442 Mon Sep 17 00:00:00 2001 From: Promise Date: Thu, 19 Dec 2024 21:34:13 +0000 Subject: [PATCH] feat: v3 Signed-off-by: Promise --- .github/renovate.json | 2 +- .github/settings.yml | 11 - .github/workflows/docker-compose-test.yml | 5 - .github/workflows/docker-image.yml | 26 +-- .vscode/extensions.json | 9 - .vscode/settings.json | 9 - Dockerfile | 3 - LICENSE | 21 ++ README.md | 1 - docker-compose.yml | 4 +- example.env | 5 +- package.json | 11 +- src/commands/applicationCommands.ts | 18 +- src/commands/chatInput/index.ts | 57 +++-- src/commands/chatInput/ping.ts | 15 +- src/commands/chatInput/self_destruct.ts | 6 +- src/commands/chatInput/testarea/index.ts | 16 +- src/commands/chatInput/testarea/list.ts | 217 +++++++++--------- src/commands/chatInput/testarea/new.ts | 144 +++++------- src/commands/chatInput/testarea/remove.ts | 23 +- src/commands/mention/eval.ts | 6 +- src/commands/mention/ping.ts | 12 +- src/commands/menu/index.ts | 9 +- src/commands/menu/toggleAdmin.ts | 31 +++ src/commands/menu/toggleOperator.ts | 34 +++ src/commands/menu/transferOwnership.ts | 29 +++ src/config.ts | 37 ++- src/database/models/TestArea.ts | 84 ++++++- src/handlers/interactions/autocompletes.ts | 6 +- .../interactions/chatInputCommands.ts | 2 +- src/handlers/interactions/components.ts | 16 +- src/handlers/interactions/index.ts | 3 +- src/handlers/interactions/menuCommands.ts | 11 +- src/handlers/interactions/modals.ts | 4 +- src/handlers/workers.ts | 132 ----------- src/handlers/workers/Worker.ts | 112 +++++++++ src/handlers/workers/index.ts | 24 ++ src/index.ts | 52 +---- src/utils/client.ts | 49 ++++ 39 files changed, 699 insertions(+), 557 deletions(-) delete mode 100644 .vscode/extensions.json delete mode 100644 .vscode/settings.json create mode 100644 LICENSE create mode 100644 src/commands/menu/toggleAdmin.ts create mode 100644 src/commands/menu/toggleOperator.ts create mode 100644 src/commands/menu/transferOwnership.ts delete mode 100644 src/handlers/workers.ts create mode 100644 src/handlers/workers/Worker.ts create mode 100644 src/handlers/workers/index.ts create mode 100644 src/utils/client.ts diff --git a/.github/renovate.json b/.github/renovate.json index 543917f7..a627d337 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -2,6 +2,6 @@ "extends": [ "github>promise/renovate-config", "github>promise/renovate-config:force-node-version(22)", - "github>promise/renovate-config:force-mongo-version(4)" + "github>promise/renovate-config:force-mongo-version(8)" ] } diff --git a/.github/settings.yml b/.github/settings.yml index 4d497d7e..43053778 100644 --- a/.github/settings.yml +++ b/.github/settings.yml @@ -5,14 +5,3 @@ repository: description: "A Discord bot to easily create testing servers, without having to write in that 2FA code to delete it later" private: false topics: "discord, discord-bot, testing-tools, utility" - -branches: - - name: main - protection: - required_status_checks: - checks: - - context: Test Build - - context: ESLint - - context: DeepScan - - context: Jest - - context: CodeQL diff --git a/.github/workflows/docker-compose-test.yml b/.github/workflows/docker-compose-test.yml index e51acb57..11f3039a 100644 --- a/.github/workflows/docker-compose-test.yml +++ b/.github/workflows/docker-compose-test.yml @@ -2,11 +2,6 @@ name: Docker Compose on: push: - branches: - - main - pull_request: - branches: - - main jobs: build: diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 2d251828..974d39f6 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -10,18 +10,18 @@ jobs: runs-on: self-hosted steps: - - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - name: Login to ghcr.io - uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + - name: Login to ghcr.io + uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - - name: ${{ github.ref == 'refs/heads/main' && 'Build and Push' || 'Test Build' }} - uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6 - with: - push: ${{ github.ref == 'refs/heads/main' }} - tags: ghcr.io/biaw/test-area:latest + - name: ${{ github.ref == 'refs/heads/main' && 'Build and Push' || 'Test Build' }} + uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6 + with: + push: ${{ github.ref == 'refs/heads/main' }} + tags: ghcr.io/biaw/test-area:latest diff --git a/.vscode/extensions.json b/.vscode/extensions.json deleted file mode 100644 index d243ade9..00000000 --- a/.vscode/extensions.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "recommendations": [ - "aaron-bond.better-comments", - "mikestead.dotenv", - "dbaeumer.vscode-eslint", - "christian-kohler.npm-intellisense", - "meganrogge.template-string-converter", - ] -} diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 2592b787..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "editor.defaultFormatter": "dbaeumer.vscode-eslint", - "editor.formatOnPaste": true, - "editor.formatOnSave": true, - "files.trimFinalNewlines": true, - "files.trimTrailingWhitespace": true, - "eslint.format.enable": true, - "typescript.tsdk": "node_modules/typescript/lib" -} diff --git a/Dockerfile b/Dockerfile index 361d58b0..75150fba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,6 @@ FROM node:22-alpine@sha256:6e80991f69cc7722c561e5d14d5e72ab47c0d6b6cfb3ae50fb9cf9a7b30fdf97 AS base RUN apk --no-cache add g++ gcc make python3 - WORKDIR /app -ENV IS_DOCKER=true - # install prod dependencies diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..dd3ff9d6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Promise Solutions + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 28358581..aa7fadd6 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ [![Docker test](https://img.shields.io/github/actions/workflow/status/biaw/test-area/docker-compose-test.yml?branch=main)](https://github.com/biaw/test-area/actions/workflows/docker-compose-test.yml) [![Linting](https://img.shields.io/github/actions/workflow/status/biaw/test-area/linting.yml?branch=main&label=quality)](https://github.com/biaw/test-area/actions/workflows/linting.yml) [![Testing](https://img.shields.io/github/actions/workflow/status/biaw/test-area/testing.yml?branch=main&label=test)](https://github.com/biaw/test-area/actions/workflows/testing.yml) -[![DeepScan grade](https://deepscan.io/api/teams/16173/projects/19537/branches/707763/badge/grade.svg)](https://deepscan.io/dashboard#view=project&tid=16173&pid=19537&bid=707763) [![discord.js version](https://img.shields.io/github/package-json/dependency-version/biaw/test-area/discord.js)](https://www.npmjs.com/package/discord.js) [![GitHub Issues](https://img.shields.io/github/issues-raw/biaw/test-area.svg)](https://github.com/biaw/test-area/issues) [![GitHub Pull Requests](https://img.shields.io/github/issues-pr-raw/biaw/test-area.svg)](https://github.com/biaw/test-area/pulls) diff --git a/docker-compose.yml b/docker-compose.yml index af4a45c3..45732191 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,18 +5,18 @@ services: build: . restart: always environment: - - CLIENT_ID=$CLIENT_ID - CLIENT_TOKEN=$CLIENT_TOKEN - WORKER_TOKENS=$WORKER_TOKENS - DATABASE_URI=mongodb://db - OWNER_ID=$OWNER_ID - THEME_COLOR=$THEME_COLOR + - AREA_LIMIT_PER_USER=$AREA_LIMIT_PER_USER volumes: - ./logs:/app/logs depends_on: - db db: - image: mongo:4@sha256:52c42cbab240b3c5b1748582cc13ef46d521ddacae002bbbda645cebed270ec0 + image: mongo:8 restart: always volumes: - ./database:/data/db diff --git a/example.env b/example.env index 3c7728b5..7ecc76d1 100644 --- a/example.env +++ b/example.env @@ -1,10 +1,11 @@ -CLIENT_ID= CLIENT_TOKEN= - WORKER_TOKENS= +# not required when using docker-compose.yml DATABASE_URI= OWNER_ID= +# optional below THEME_COLOR= +AREA_LIMIT_PER_USER= diff --git a/package.json b/package.json index dbc01302..84f89d3d 100644 --- a/package.json +++ b/package.json @@ -16,18 +16,11 @@ "main": "build", "scripts": { "build": "tsc", - "build:watch": "tsc -w", - "docker": "npm run docker:build && npm run docker:up", - "docker:build": "docker-compose --project-directory . build", - "docker:down": "docker-compose --project-directory . down", - "docker:logs": "docker-compose --project-directory . logs --tail=500 -f", - "docker:start": "npm run docker:up", - "docker:stop": "npm run docker:down", - "docker:up": "docker-compose --project-directory . up -d", "lint": "eslint .", "lint:fix": "eslint . --fix", "start": "node .", - "test": "jest" + "test": "jest", + "watch": "tsc -w" }, "dependencies": { "@sapphire/type": "2.6.0", diff --git a/src/commands/applicationCommands.ts b/src/commands/applicationCommands.ts index d916c5ea..a700748b 100644 --- a/src/commands/applicationCommands.ts +++ b/src/commands/applicationCommands.ts @@ -1,14 +1,16 @@ -import type{ ApplicationCommandData, ApplicationCommandOptionData, ApplicationCommandSubCommandData, ApplicationCommandSubGroupData } from "discord.js"; -import { ApplicationCommandOptionType, ApplicationCommandType } from "discord.js"; -import type{ ChatInputCommand, ChatInputCommandExecutable, ChatInputCommandOptionData, ChatInputCommandOptionDataAutocomplete } from "./chatInput"; +import type { ApplicationCommandData, ApplicationCommandOptionData, ApplicationCommandSubCommandData, ApplicationCommandSubGroupData } from "discord.js"; +import { ApplicationCommandOptionType, ApplicationCommandType, ApplicationIntegrationType, InteractionContextType } from "discord.js"; + + +import type{ ChatInputCommand, ChatInputCommandExecutable, ChatInputCommandOptionData, ChatInputCommandOptionDataAutocomplete, FirstLevelChatInputCommand } from "./chatInput"; import { allChatInputCommands } from "./chatInput"; import { allMenuCommands } from "./menu"; -export default function getAllApplicationCommands(commandType?: "non-test-areas" | "test-areas"): ApplicationCommandData[] { +export default function getAllApplicationCommands(commandType?: FirstLevelChatInputCommand["applicableTo"][number]): ApplicationCommandData[] { const applicationCommands: ApplicationCommandData[] = []; for (const command of allChatInputCommands) { - if (!commandType || command.worksIn.includes(commandType)) { + if (!commandType || command.applicableTo.includes(commandType)) { applicationCommands.push({ name: command.name, description: command.description, @@ -35,7 +37,8 @@ export default function getAllApplicationCommands(commandType?: "non-test-areas" }, })), }, - ...!command.public && commandType === "non-test-areas" && { defaultMemberPermissions: 0n }, + integrationTypes: [commandType === "main" ? ApplicationIntegrationType.UserInstall : ApplicationIntegrationType.GuildInstall], + ...commandType === "main" && { contexts: [InteractionContextType.BotDM, InteractionContextType.Guild, InteractionContextType.PrivateChannel] }, }); } } @@ -45,7 +48,8 @@ export default function getAllApplicationCommands(commandType?: "non-test-areas" applicationCommands.push({ name: command.name, type: command.type === "message" ? ApplicationCommandType.Message : ApplicationCommandType.User, - ...!command.public && commandType === "non-test-areas" && { defaultMemberPermissions: 0n }, + integrationTypes: [commandType === "main" ? ApplicationIntegrationType.UserInstall : ApplicationIntegrationType.GuildInstall], + ...commandType === "main" && { contexts: [InteractionContextType.BotDM, InteractionContextType.Guild, InteractionContextType.PrivateChannel] }, }); } } diff --git a/src/commands/chatInput/index.ts b/src/commands/chatInput/index.ts index 21f8bf7d..f79a4572 100644 --- a/src/commands/chatInput/index.ts +++ b/src/commands/chatInput/index.ts @@ -1,20 +1,14 @@ -import type{ ApplicationCommandAutocompleteNumericOptionData, ApplicationCommandAutocompleteStringOptionData, ApplicationCommandBooleanOptionData, ApplicationCommandChannelOptionData, ApplicationCommandMentionableOptionData, ApplicationCommandNonOptionsData, ApplicationCommandNumericOptionData, ApplicationCommandRoleOptionData, ApplicationCommandStringOptionData, ApplicationCommandUserOptionData, Awaitable, ChatInputCommandInteraction } from "discord.js"; +import type { ApplicationCommandAutocompleteNumericOptionData, ApplicationCommandAutocompleteStringOptionData, ApplicationCommandBooleanOptionData, ApplicationCommandChannelOptionData, ApplicationCommandMentionableOptionData, ApplicationCommandNonOptionsData, ApplicationCommandNumericOptionData, ApplicationCommandRoleOptionData, ApplicationCommandStringOptionData, ApplicationCommandUserOptionData, Awaitable, ChatInputCommandInteraction } from "discord.js"; import { readdirSync } from "fs"; -import type{ Autocomplete } from "../../handlers/interactions/autocompletes"; +import type { Autocomplete } from "../../handlers/interactions/autocompletes"; -export type FirstLevelChatInputCommand = { - public?: true; - worksIn: Array<"non-test-areas" | "test-areas">; -} & (ChatInputCommandExecutable | ChatInputCommandGroup) & ChatInputCommandMeta; - -export type SecondLevelChatInputCommand = (ChatInputCommandExecutable | ChatInputCommandGroup) & ChatInputCommandMeta; - -export type ThirdLevelChatInputCommand = ChatInputCommandExecutable & ChatInputCommandMeta; - -export type ChatInputCommand = FirstLevelChatInputCommand | SecondLevelChatInputCommand | ThirdLevelChatInputCommand; +export interface ChatInputCommandMeta { + description: string; + name: string; +} export interface ChatInputCommandExecutable { - execute(interaction: ChatInputCommandInteraction<"cached">): Awaitable; + execute(interaction: ChatInputCommandInteraction): Awaitable; options?: [ChatInputCommandOptionData, ...ChatInputCommandOptionData[]]; } @@ -22,24 +16,37 @@ export interface ChatInputCommandGroup } + & (ChatInputCommandExecutable | ChatInputCommandGroup) + & ChatInputCommandMeta; + +export type SecondLevelChatInputCommand = + & (ChatInputCommandExecutable | ChatInputCommandGroup) + & ChatInputCommandMeta; + +export type ThirdLevelChatInputCommand = + & ChatInputCommandExecutable + & ChatInputCommandMeta; + +export type ChatInputCommand = + | FirstLevelChatInputCommand + | SecondLevelChatInputCommand + | ThirdLevelChatInputCommand; export type ChatInputCommandOptionDataAutocomplete = | ({ autocomplete: Autocomplete } & Omit) | ({ autocomplete: Autocomplete } & Omit); export type ChatInputCommandOptionDataNoAutocomplete = -| ApplicationCommandBooleanOptionData -| ApplicationCommandChannelOptionData -| ApplicationCommandMentionableOptionData -| ApplicationCommandNonOptionsData -| ApplicationCommandRoleOptionData -| ApplicationCommandUserOptionData -| Omit -| Omit; + | ApplicationCommandBooleanOptionData + | ApplicationCommandChannelOptionData + | ApplicationCommandMentionableOptionData + | ApplicationCommandNonOptionsData + | ApplicationCommandRoleOptionData + | ApplicationCommandUserOptionData + | Omit + | Omit; export type ChatInputCommandOptionData = ChatInputCommandOptionDataAutocomplete | ChatInputCommandOptionDataNoAutocomplete; diff --git a/src/commands/chatInput/ping.ts b/src/commands/chatInput/ping.ts index da22638d..25c4aad7 100644 --- a/src/commands/chatInput/ping.ts +++ b/src/commands/chatInput/ping.ts @@ -4,11 +4,14 @@ import { msToHumanShortTime } from "../../utils/time"; export default { name: "ping", description: "Ping the bot", - worksIn: ["test-areas", "non-test-areas"], - public: true, + applicableTo: ["main", "workers"], async execute(interaction) { - const now = Date.now(); - await interaction.deferReply(); - return void interaction.editReply(`🏓 Server latency is \`${Date.now() - now}ms\`, shard latency is \`${Math.ceil(interaction.guild.shard.ping)}ms\` and my uptime is \`${msToHumanShortTime(interaction.client.uptime)}\`.`); + const start = Date.now(); + const [interactionLatency, gatewayLatency] = await Promise.all([ + interaction.deferReply().then(() => Date.now() - start), + interaction.client.rest.get("/gateway").then(() => Date.now() - start), + ]); + + return void interaction.editReply(`🏓 Interaction latency is \`${interactionLatency}ms\`, gateway latency is \`${gatewayLatency}ms\` and my uptime is \`${msToHumanShortTime(interaction.client.uptime)}\`.`); }, -} as FirstLevelChatInputCommand; +} satisfies FirstLevelChatInputCommand; diff --git a/src/commands/chatInput/self_destruct.ts b/src/commands/chatInput/self_destruct.ts index aaafba47..27511337 100644 --- a/src/commands/chatInput/self_destruct.ts +++ b/src/commands/chatInput/self_destruct.ts @@ -7,16 +7,16 @@ import { buttonComponents } from "../../handlers/interactions/components"; export default { name: "self_destruct", description: "Delete the server", - worksIn: ["test-areas"], + applicableTo: ["workers"], async execute(interaction) { - const testArea = await TestArea.findOne({ serverId: interaction.guildId }); + const testArea = await TestArea.findOne({ guildId: interaction.guildId }); if (interaction.user.id !== testArea?.ownerId && interaction.user.id !== config.ownerId) return interaction.reply({ content: "❌ You are not the owner of this test area.", ephemeral: true }); buttonComponents.set(`${interaction.id}:confirm`, { allowedUsers: [interaction.user.id], async callback(button) { await button.deferUpdate(); - void button.guild.delete(); + await testArea?.discordGuild?.delete(); }, }); diff --git a/src/commands/chatInput/testarea/index.ts b/src/commands/chatInput/testarea/index.ts index c34194f5..8c5600d2 100644 --- a/src/commands/chatInput/testarea/index.ts +++ b/src/commands/chatInput/testarea/index.ts @@ -1,15 +1,11 @@ import type{ FirstLevelChatInputCommand } from ".."; -import subcommandList from "./list"; -import subcommandNew from "./new"; -import subcommandRemove from "./remove"; +import list from "./list"; +import _new from "./new"; +import remove from "./remove"; export default { name: "testarea", description: "Sub-command for administrating test areas", - worksIn: ["test-areas", "non-test-areas"], - subcommands: [ - subcommandList, - subcommandNew, - subcommandRemove, - ], -} as FirstLevelChatInputCommand; + applicableTo: ["main"], + subcommands: [list, _new, remove], +} satisfies FirstLevelChatInputCommand; diff --git a/src/commands/chatInput/testarea/list.ts b/src/commands/chatInput/testarea/list.ts index 7e563a4d..1808745f 100644 --- a/src/commands/chatInput/testarea/list.ts +++ b/src/commands/chatInput/testarea/list.ts @@ -1,9 +1,9 @@ -import type{ APIEmbed, GuildMember, Interaction, InteractionReplyOptions, InteractionUpdateOptions } from "discord.js"; -import { ButtonStyle, Colors, ComponentType, time, TimestampStyles } from "discord.js"; +import type{ APIEmbed, Interaction, InteractionReplyOptions, InteractionUpdateOptions } from "discord.js"; +import { ButtonStyle, Colors, ComponentType, RouteBases, time, TimestampStyles } from "discord.js"; import type{ SecondLevelChatInputCommand } from ".."; +import config from "../../../config"; import { TestArea } from "../../../database/models/TestArea"; import { buttonComponents, selectMenuComponents } from "../../../handlers/interactions/components"; -import { workers } from "../../../handlers/workers"; export default { name: "list", @@ -11,43 +11,41 @@ export default { async execute(interaction) { return void interaction.reply(await generateMessage(interaction)); }, -} as SecondLevelChatInputCommand; +} satisfies SecondLevelChatInputCommand; -enum Filter { none, active1d, active1w, active1m, active6m, stale1d, stale1w, stale1m, stale6m, ownerIsMe, ownerIsNotMe } -enum Sort { none, creationDateAscending, creationDateDescending, nameAZ, nameZA, ownerAZ, ownerZA } +enum Filter { None, Active1d, Active1w, Active1m, Active6m, Stale1d, Stale1w, Stale1m, Stale6m, OwnerIsMe, OwnerIsNotMe } +enum Sort { None, CreationDateAscending, CreationDateDescending, NameAZ, NameZA, OwnerAZ, OwnerZA } const entriesPerPage = 5; -async function generateMessage(interaction: Interaction, filter: Filter = Filter.none, sort: Sort = Sort.none, page = 0): Promise { +async function generateMessage(interaction: Interaction, filter: Filter = Filter.None, sort: Sort = Sort.None, page = 0): Promise { const testAreas = (await TestArea.find()) .filter(testArea => { switch (filter) { - case Filter.active1d: return testArea.lastActivityAt.getTime() > Date.now() - 86400000; - case Filter.active1w: return testArea.lastActivityAt.getTime() > Date.now() - 604800000; - case Filter.active1m: return testArea.lastActivityAt.getTime() > Date.now() - 2592000000; - case Filter.active6m: return testArea.lastActivityAt.getTime() > Date.now() - 15552000000; - case Filter.stale1d: return testArea.lastActivityAt.getTime() < Date.now() - 86400000; - case Filter.stale1w: return testArea.lastActivityAt.getTime() < Date.now() - 604800000; - case Filter.stale1m: return testArea.lastActivityAt.getTime() < Date.now() - 2592000000; - case Filter.stale6m: return testArea.lastActivityAt.getTime() < Date.now() - 15552000000; - case Filter.ownerIsMe: return testArea.ownerId === interaction.user.id; - case Filter.ownerIsNotMe: return testArea.ownerId !== interaction.user.id; - case Filter.none: + case Filter.Active1d: return testArea.lastActivityAt.getTime() > Date.now() - 86400000; + case Filter.Active1w: return testArea.lastActivityAt.getTime() > Date.now() - 604800000; + case Filter.Active1m: return testArea.lastActivityAt.getTime() > Date.now() - 2592000000; + case Filter.Active6m: return testArea.lastActivityAt.getTime() > Date.now() - 15552000000; + case Filter.Stale1d: return testArea.lastActivityAt.getTime() < Date.now() - 86400000; + case Filter.Stale1w: return testArea.lastActivityAt.getTime() < Date.now() - 604800000; + case Filter.Stale1m: return testArea.lastActivityAt.getTime() < Date.now() - 2592000000; + case Filter.Stale6m: return testArea.lastActivityAt.getTime() < Date.now() - 15552000000; + case Filter.OwnerIsMe: return testArea.ownerId === interaction.user.id; + case Filter.OwnerIsNotMe: return testArea.ownerId !== interaction.user.id; + case Filter.None: default: return true; } }) // eslint-disable-next-line complexity -- lol .sort((a, b) => { - const [aGuild, bGuild] = [workers.get(a.botId)?.guilds.cache.get(a.serverId) ?? null, workers.get(b.botId)?.guilds.cache.get(b.serverId) ?? null]; - const [aOwner, bOwner] = [interaction.client.users.cache.get(a.ownerId) ?? null, interaction.client.users.cache.get(b.ownerId) ?? null]; switch (sort) { - case Sort.creationDateAscending: return (aGuild?.createdTimestamp ?? 0) - (bGuild?.createdTimestamp ?? 0); - case Sort.creationDateDescending: return (bGuild?.createdTimestamp ?? 0) - (aGuild?.createdTimestamp ?? 0); - case Sort.nameAZ: return aGuild?.name.localeCompare(bGuild?.name ?? "") ?? 0; - case Sort.nameZA: return bGuild?.name.localeCompare(aGuild?.name ?? "") ?? 0; - case Sort.ownerAZ: return aOwner?.username.localeCompare(bOwner?.username ?? "") ?? 0; - case Sort.ownerZA: return bOwner?.username.localeCompare(aOwner?.username ?? "") ?? 0; - case Sort.none: + case Sort.CreationDateAscending: return (a.createdAt?.getTime() ?? 0) - (b.createdAt?.getTime() ?? 0); + case Sort.CreationDateDescending: return (b.createdAt?.getTime() ?? 0) - (a.createdAt?.getTime() ?? 0); + case Sort.NameAZ: return a.guild.name.localeCompare(b.guild.name); + case Sort.NameZA: return b.guild.name.localeCompare(a.guild.name); + case Sort.OwnerAZ: return a.ownerUser?.username.localeCompare(b.ownerUser?.username ?? "") ?? 0; + case Sort.OwnerZA: return b.ownerUser?.username.localeCompare(a.ownerUser?.username ?? "") ?? 0; + case Sort.None: default: return 0; } }); @@ -57,7 +55,7 @@ async function generateMessage(interaction: Interaction, filter: Filter = Filter selectType: "string", allowedUsers: [interaction.user.id], async callback(select) { - return void select.update(await generateMessage(interaction, Number(select.values[0] ?? Filter.none) as unknown as Filter, sort, page)); + return void select.update(await generateMessage(interaction, Number(select.values[0] ?? Filter.None) as unknown as Filter, sort, page)); }, }); @@ -65,7 +63,7 @@ async function generateMessage(interaction: Interaction, filter: Filter = Filter selectType: "string", allowedUsers: [interaction.user.id], async callback(select) { - return void select.update(await generateMessage(interaction, filter, Number(select.values[0] ?? Sort.none) as unknown as Sort, page)); + return void select.update(await generateMessage(interaction, filter, Number(select.values[0] ?? Sort.None) as unknown as Sort, page)); }, }); @@ -97,49 +95,50 @@ async function generateMessage(interaction: Interaction, filter: Filter = Filter }, }); + let content = testAreas.length ? + `📋 Showing test areas **${entriesPerPage * page + 1}-${Math.min(testAreas.length, entriesPerPage * (page + 1) + 1)}** out of a total **${testAreas.length}** test areas matching your filter.` : + "❌ No test areas matched your filter. Try to filter something else, or create a new test area."; + if (filter === Filter.None) content = interaction.user.id === config.ownerId ? "❌ There are no test areas created yet." : "❌ You haven't created any test areas yet."; + return { - content: testAreas.length ? `📋 Showing test areas **${entriesPerPage * page + 1}-${Math.min(testAreas.length, entriesPerPage * (page + 1) + 1)}** out of a total **${testAreas.length}** test areas matching your filter.` : "❌ No test areas matched your filter. Try to filter something else, or create a new test area.", + content, embeds: await Promise.all(testAreasPage.map>(async testArea => { - const worker = workers.get(testArea.botId); - const guild = await worker?.guilds.fetch({ guild: testArea.serverId, cache: true, force: false }).catch(() => null) ?? null; - const owner = await interaction.client.users.fetch(testArea.ownerId, { cache: true, force: false }); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- typescript bug idk why - const members = Array.from(await guild?.members.fetch().then(list => list.values()) ?? []) as GuildMember[]; - const bots = members.filter(member => member.user.bot && member.user.id !== testArea.botId); - const humans = members.filter(member => !member.user.bot); + const members = await testArea.discordGuild?.members.fetch({ withPresences: false }); + const bots = members?.filter(member => member.user.bot && member.user.id !== testArea.workerId); + const humans = members?.filter(member => !member.user.bot); return { - color: guild ? Number(testArea.serverId) % 0xFFFFFF : Colors.Red, - author: { - name: owner.tag, - iconUrl: owner.displayAvatarURL({ size: 32 }), + title: testArea.discordGuild?.name ?? `Unknown guild ${testArea.guildId}`, + color: testArea.discordGuild ? Number(testArea.guildId) % 0xFFFFFF : Colors.Red, + description: `created ${time(testArea.discordGuild?.createdAt ?? testArea.createdAt ?? new Date(0), TimestampStyles.RelativeTime)}, last activity was ${time(testArea.lastActivityAt, TimestampStyles.RelativeTime)}`, + ...testArea.discordGuild && { + ...humans && bots && { + fields: [ + { + name: humans.size === 1 ? "1 human" : `${humans.size} humans`, + value: humans + .map(human => human.user.toString()) + .sort((a, b) => a.localeCompare(b)) + .join("\n") || "*No humans.*", + inline: true, + }, { + name: bots.size === 1 ? "1 bot" : `${bots.size} bots`, + value: bots + .map(bot => bot.user.toString()) + .sort((a, b) => a.localeCompare(b)) + .join("\n") || "*No bots.*", + inline: true, + }, + ], + }, }, - title: guild?.name ?? `Unknown guild ${testArea.serverId}`, - url: testArea.invite, - ...guild && { - description: `created ${time(guild.createdAt, TimestampStyles.RelativeTime)} - last activity was ${time(testArea.lastActivityAt, TimestampStyles.RelativeTime)}`, - fields: [ - { - name: humans.length === 1 ? "1 human" : `${humans.length} humans`, - value: humans - .map(human => human.user.toString()) - .sort((a, b) => a.localeCompare(b)) - .join("\n") || "*No humans.*", - inline: true, - }, - { - name: bots.length === 1 ? "1 bot" : `${bots.length} bots`, - value: bots - - .map(bot => bot.user.toString()) - .sort((a, b) => a.localeCompare(b)) - .join("\n") || "*No bots.*", - inline: true, - }, - ], - footer: { - text: `worker: ${worker?.user.username ?? "n/a"} (${testArea.botId})`, + ...testArea.ownerUser && { + author: { + name: `@${testArea.ownerUser.username}`, + iconUrl: testArea.ownerUser.displayAvatarURL({ size: 32 }), }, }, + url: `${RouteBases.invite}/${testArea.guild.inviteCode}`, + footer: { text: `worker: ${testArea.worker.client.user?.username ?? "n/a"} (${testArea.workerId})` }, }; })), components: [ @@ -149,33 +148,37 @@ async function generateMessage(interaction: Interaction, filter: Filter = Filter { type: ComponentType.StringSelect, placeholder: { - [Filter.none]: "Filter: None", - [Filter.active1d]: "Filter: Active (1 day)", - [Filter.active1w]: "Filter: Active (1 week)", - [Filter.active1m]: "Filter: Active (1 month)", - [Filter.active6m]: "Filter: Active (6 months)", - [Filter.stale1d]: "Filter: Stale (1 day)", - [Filter.stale1w]: "Filter: Stale (1 week)", - [Filter.stale1m]: "Filter: Stale (1 month)", - [Filter.stale6m]: "Filter: Stale (6 months)", - [Filter.ownerIsMe]: "Filter: Owned by me", - [Filter.ownerIsNotMe]: "Filter: Not owned by me", + [Filter.None]: "Filter: None", + [Filter.Active1d]: "Filter: Active (1 day)", + [Filter.Active1w]: "Filter: Active (1 week)", + [Filter.Active1m]: "Filter: Active (1 month)", + [Filter.Active6m]: "Filter: Active (6 months)", + [Filter.Stale1d]: "Filter: Stale (1 day)", + [Filter.Stale1w]: "Filter: Stale (1 week)", + [Filter.Stale1m]: "Filter: Stale (1 month)", + [Filter.Stale6m]: "Filter: Stale (6 months)", + [Filter.OwnerIsMe]: "Filter: Owned by me", + [Filter.OwnerIsNotMe]: "Filter: Not owned by me", }[filter], customId: `${interaction.id}:filter`, minValues: 0, maxValues: 1, options: [ - { label: "None", value: String(Filter.none) }, - { label: "Active (1 day)", value: String(Filter.active1d) }, - { label: "Active (1 week)", value: String(Filter.active1w) }, - { label: "Active (1 month)", value: String(Filter.active1m) }, - { label: "Active (6 months)", value: String(Filter.active6m) }, - { label: "Stale (1 day)", value: String(Filter.stale1d) }, - { label: "Stale (1 week)", value: String(Filter.stale1w) }, - { label: "Stale (1 month)", value: String(Filter.stale1m) }, - { label: "Stale (6 months)", value: String(Filter.stale6m) }, - { label: "Owned by me", value: String(Filter.ownerIsMe) }, - { label: "Not owned by me", value: String(Filter.ownerIsNotMe) }, + { label: "None", value: String(Filter.None) }, + { label: "Active (1 day)", value: String(Filter.Active1d) }, + { label: "Active (1 week)", value: String(Filter.Active1w) }, + { label: "Active (1 month)", value: String(Filter.Active1m) }, + { label: "Active (6 months)", value: String(Filter.Active6m) }, + { label: "Stale (1 day)", value: String(Filter.Stale1d) }, + { label: "Stale (1 week)", value: String(Filter.Stale1w) }, + { label: "Stale (1 month)", value: String(Filter.Stale1m) }, + { label: "Stale (6 months)", value: String(Filter.Stale6m) }, + ...interaction.user.id === config.ownerId ? + [ + { label: "Owned by me", value: String(Filter.OwnerIsMe) }, + { label: "Not owned by me", value: String(Filter.OwnerIsNotMe) }, + ] : + [], ], }, ], @@ -186,25 +189,29 @@ async function generateMessage(interaction: Interaction, filter: Filter = Filter { type: ComponentType.StringSelect, placeholder: { - [Sort.none]: "Sorting by: None", - [Sort.creationDateAscending]: "Sorting by: Creation date (ascending)", - [Sort.creationDateDescending]: "Sorting by: Creation date (descending)", - [Sort.nameAZ]: "Sorting by: Name (A-Z)", - [Sort.nameZA]: "Sorting by: Name (Z-A)", - [Sort.ownerAZ]: "Sorting by: Owner (A-Z)", - [Sort.ownerZA]: "Sorting by: Owner (Z-A)", + [Sort.None]: "Sorting by: None", + [Sort.CreationDateAscending]: "Sorting by: Creation date (ascending)", + [Sort.CreationDateDescending]: "Sorting by: Creation date (descending)", + [Sort.NameAZ]: "Sorting by: Name (A-Z)", + [Sort.NameZA]: "Sorting by: Name (Z-A)", + [Sort.OwnerAZ]: "Sorting by: Owner (A-Z)", + [Sort.OwnerZA]: "Sorting by: Owner (Z-A)", }[sort], customId: `${interaction.id}:sort`, minValues: 1, maxValues: 1, options: [ - { label: "None", value: String(Sort.none) }, - { label: "Creation date (ascending)", value: String(Sort.creationDateAscending) }, - { label: "Creation date (descending)", value: String(Sort.creationDateDescending) }, - { label: "Name (A-Z)", value: String(Sort.nameAZ) }, - { label: "Name (Z-A)", value: String(Sort.nameZA) }, - { label: "Owner (A-Z)", value: String(Sort.ownerAZ) }, - { label: "Owner (Z-A)", value: String(Sort.ownerZA) }, + { label: "None", value: String(Sort.None) }, + { label: "Creation date (ascending)", value: String(Sort.CreationDateAscending) }, + { label: "Creation date (descending)", value: String(Sort.CreationDateDescending) }, + { label: "Name (A-Z)", value: String(Sort.NameAZ) }, + { label: "Name (Z-A)", value: String(Sort.NameZA) }, + ...interaction.user.id === config.ownerId ? + [ + { label: "Owner (A-Z)", value: String(Sort.OwnerAZ) }, + { label: "Owner (Z-A)", value: String(Sort.OwnerZA) }, + ] : + [], ], }, ], @@ -228,7 +235,7 @@ async function generateMessage(interaction: Interaction, filter: Filter = Filter }, { type: ComponentType.Button, - label: `${page + 1} / ${Math.ceil(testAreas.length / entriesPerPage)}`, + label: `${testAreas.length ? page + 1 : 0} / ${Math.ceil(testAreas.length / entriesPerPage)}`, customId: "disabled", style: ButtonStyle.Secondary, disabled: true, @@ -238,14 +245,14 @@ async function generateMessage(interaction: Interaction, filter: Filter = Filter label: "\u203A", customId: `${interaction.id}:go-forward`, style: ButtonStyle.Primary, - disabled: page === Math.ceil(testAreas.length / entriesPerPage) - 1, + disabled: testAreas.length === 0 || page === Math.ceil(testAreas.length / entriesPerPage) - 1, }, { type: ComponentType.Button, label: "\u00bb", customId: `${interaction.id}:go-to-end`, style: ButtonStyle.Primary, - disabled: page === Math.ceil(testAreas.length / entriesPerPage) - 1, + disabled: testAreas.length === 0 || page === Math.ceil(testAreas.length / entriesPerPage) - 1, }, ], }, diff --git a/src/commands/chatInput/testarea/new.ts b/src/commands/chatInput/testarea/new.ts index c18e2eb0..97e8aa54 100644 --- a/src/commands/chatInput/testarea/new.ts +++ b/src/commands/chatInput/testarea/new.ts @@ -1,12 +1,12 @@ import type{ BaseGuildTextChannel, PartialChannelData } from "discord.js"; -import { ApplicationCommandOptionType, ButtonStyle, ChannelType, ComponentType, GuildDefaultMessageNotifications, OAuth2Scopes } from "discord.js"; +import { ApplicationCommandOptionType, ChannelType, Colors, GuildDefaultMessageNotifications, PermissionFlagsBits } from "discord.js"; import type{ SecondLevelChatInputCommand } from ".."; +import config from "../../../config"; import { TestArea } from "../../../database/models/TestArea"; -import { buttonComponents } from "../../../handlers/interactions/components"; -import { workers } from "../../../handlers/workers"; +import Worker from "../../../handlers/workers/Worker"; enum Channels { EntryLog, HumansOnly, TestChannelCategory } -enum Roles { Everyone, Owner, Bot, AdminPerms, Manager } +enum Roles { Everyone, Owner, Operator, Bot, AdminPerms, Manager } export default { name: "new", @@ -26,11 +26,16 @@ export default { }, ], async execute(interaction) { - const worker = Array.from(workers.values()).find(({ guilds }) => guilds.cache.size < 10); + const worker = Worker.getRandomNonFullWorker(); if (!worker) return void interaction.reply({ content: "❌ I cannot create more test areas, no worker is available.", ephemeral: true }); + if (interaction.user.id !== config.ownerId) { + const allOwnedTestAreas = await TestArea.countDocuments({ guild: { ownerId: interaction.user.id } }); + if (allOwnedTestAreas >= config.limitAmountOfAreasPerUser) return void interaction.reply({ content: "❌ You've exceeded the amount of testing areas. Consider deleting some before you create a new one.", ephemeral: true }); + } + const [[guild, invite]] = await Promise.all([ - worker.guilds.create({ + worker.client.guilds.create({ name: interaction.options.getString("name", true), channels: [ { @@ -43,7 +48,7 @@ export default { permissionOverwrites: [ { id: Roles.Bot, - deny: ["ViewChannel"], + deny: [PermissionFlagsBits.ViewChannel], }, ], }, @@ -59,52 +64,59 @@ export default { id: Roles.Everyone, name: "everyone", permissions: [ - "AddReactions", - "AttachFiles", - "ChangeNickname", - "Connect", - "CreatePrivateThreads", - "CreatePublicThreads", - "EmbedLinks", - "ReadMessageHistory", - "RequestToSpeak", - "SendMessages", - "SendMessagesInThreads", - "Speak", - "Stream", - "UseApplicationCommands", - "UseEmbeddedActivities", - "UseExternalEmojis", - "UseExternalStickers", - "UseVAD", - "ViewAuditLog", - "ViewChannel", + PermissionFlagsBits.AddReactions, + PermissionFlagsBits.AttachFiles, + PermissionFlagsBits.ChangeNickname, + PermissionFlagsBits.Connect, + PermissionFlagsBits.CreatePrivateThreads, + PermissionFlagsBits.CreatePublicThreads, + PermissionFlagsBits.EmbedLinks, + PermissionFlagsBits.ReadMessageHistory, + PermissionFlagsBits.RequestToSpeak, + PermissionFlagsBits.SendMessages, + PermissionFlagsBits.SendMessagesInThreads, + PermissionFlagsBits.Speak, + PermissionFlagsBits.Stream, + PermissionFlagsBits.UseApplicationCommands, + PermissionFlagsBits.UseEmbeddedActivities, + PermissionFlagsBits.UseExternalEmojis, + PermissionFlagsBits.UseExternalStickers, + PermissionFlagsBits.UseVAD, + PermissionFlagsBits.ViewAuditLog, + PermissionFlagsBits.ViewChannel, ], }, { - id: Roles.Owner, - name: "👑", - color: "Gold", + id: Roles.Bot, + name: "🤖", + color: Colors.Grey, hoist: true, permissions: [], }, { - id: Roles.Bot, - name: "🤖", - color: "Grey", + id: Roles.Operator, + name: "🔰", + color: Colors.Green, + hoist: true, + permissions: [], + }, + { + id: Roles.Owner, + name: "👑", + color: Colors.Gold, hoist: true, permissions: [], }, { id: Roles.AdminPerms, name: "💥", - color: "Red", - permissions: ["Administrator"], + color: Colors.Red, + permissions: [PermissionFlagsBits.Administrator], }, { id: Roles.Manager, name: "🔧", - color: "Blurple", + color: config.themeColor, hoist: true, permissions: [], }, @@ -117,47 +129,26 @@ export default { const entryChannel = newTestArea.channels.cache.find(({ name }) => name === "entry-log") as BaseGuildTextChannel; const newInvite = await entryChannel.createInvite({ maxAge: 0, maxUses: 0 }); - // manager role - const managerRole = newTestArea.roles.cache.find(({ name }) => name === "🔧")!; - void newTestArea.members.fetchMe().then(me => me.roles.add(managerRole)); - // saving to database and then returning await TestArea.create({ - serverId: newTestArea.id, - botId: worker.user.id, + guildId: newTestArea.id, ownerId: interaction.user.id, - roles: { - ownerId: newTestArea.roles.cache.find(({ name }) => name === "👑")!.id, - botId: newTestArea.roles.cache.find(({ name }) => name === "🤖")!.id, - adminId: newTestArea.roles.cache.find(({ name }) => name === "💥")!.id, - }, - invite: newInvite.url, - }); - - // sending invite link for slash commands - void entryChannel.send({ - content: `✨ For my slash commands to work, you need to add them for me ... which is stupid, but welcome to Discord. Please add me with this link:\n<${worker.generateInvite({ - scopes: [OAuth2Scopes.ApplicationsCommands], - guild: newTestArea, - disableGuildSelect: true, - })}>`, - components: [ - { - type: ComponentType.ActionRow, - components: [ - { - type: ComponentType.Button, - customId: "new-test-area:hide-slash-commands-notice", - label: "Hide this message", - style: ButtonStyle.Secondary, - }, - ], + workerId: worker.client.user!.id, + guild: { + name: newTestArea.name, + inviteCode: newInvite.code, + roles: { + adminId: newTestArea.roles.cache.find(({ name }) => name === "💥")!.id, + botId: newTestArea.roles.cache.find(({ name }) => name === "🤖")!.id, + managerId: newTestArea.roles.cache.find(({ name }) => name === "🔧")!.id, + operatorId: newTestArea.roles.cache.find(({ name }) => name === "🔰")!.id, + ownerId: newTestArea.roles.cache.find(({ name }) => name === "👑")!.id, }, - ], + }, }); - // rename bot - void newTestArea.members.fetchMe().then(me => me.setNickname("Test Area Worker")); + // rename bot and add role + void newTestArea.members.fetchMe().then(me => void me.setNickname("Test Area Worker")); return [newTestArea, newInvite] as const; }), @@ -166,13 +157,4 @@ export default { return void interaction.editReply(`✅ Area **${guild.name}** created! Here's an invite: ${invite.url}`); }, -} as SecondLevelChatInputCommand; - -buttonComponents.set("new-test-area:hide-slash-commands-notice", { - allowedUsers: "all", - persistent: true, - async callback(button) { - await button.deferUpdate(); - void button.message.delete(); - }, -}); +} satisfies SecondLevelChatInputCommand; diff --git a/src/commands/chatInput/testarea/remove.ts b/src/commands/chatInput/testarea/remove.ts index 2167ad37..d8c1a3b2 100644 --- a/src/commands/chatInput/testarea/remove.ts +++ b/src/commands/chatInput/testarea/remove.ts @@ -3,7 +3,6 @@ import { matchSorter } from "match-sorter"; import type{ SecondLevelChatInputCommand } from ".."; import config from "../../../config"; import { TestArea } from "../../../database/models/TestArea"; -import { workers } from "../../../handlers/workers"; export default { name: "remove", @@ -16,27 +15,23 @@ export default { required: true, autocomplete: async (query, interaction) => { const testAreas = await TestArea.find({ ...config.ownerId !== interaction.user.id && { ownerId: interaction.user.id } }); - return matchSorter(testAreas.map(testArea => { - const guild = workers.get(testArea.botId)?.guilds.cache.get(testArea.serverId); - return { - name: guild ? `${guild.name} (${testArea.serverId})` : `unknown server ${testArea.serverId}`, - value: testArea.serverId, - }; - }), query, { keys: ["value", "name"] }); + return matchSorter(testAreas.map(testArea => ({ + name: testArea.discordGuild ? `${testArea.guild.name} (${testArea.guildId})` : `unknown server ${testArea.guildId}`, + value: testArea.guildId, + })), query, { keys: ["value", "name"] }); }, }, ], async execute(interaction) { - const serverId = interaction.options.getString("server_id", true); - const testArea = await TestArea.findOne({ serverId }); + const guildId = interaction.options.getString("server_id", true); + const testArea = await TestArea.findOne({ guildId }); if (!testArea) return void interaction.reply({ content: "❌ This server is not a test area", ephemeral: true }); if (testArea.ownerId !== interaction.user.id && config.ownerId !== interaction.user.id) return void interaction.reply({ content: "❌ You do not own this test area.", ephemeral: true }); - await workers.get(testArea.botId)?.guilds.cache.get(testArea.serverId)?.delete(); + await testArea.discordGuild?.delete(); await testArea.deleteOne(); - if (interaction.guildId !== serverId) void interaction.reply({ content: "✅ Test area removed.", ephemeral: true }); - return void 0; + return void (interaction.guildId !== guildId && interaction.reply({ content: "✅ Test area removed.", ephemeral: true })); }, -} as SecondLevelChatInputCommand; +} satisfies SecondLevelChatInputCommand; diff --git a/src/commands/mention/eval.ts b/src/commands/mention/eval.ts index cd721db9..ac244561 100644 --- a/src/commands/mention/eval.ts +++ b/src/commands/mention/eval.ts @@ -36,12 +36,12 @@ export default { void (await message).edit(generateFinalResponse(err, ms, false)); }); } - return reply(generateFinalResponse(evaluated)); + return void reply(generateFinalResponse(evaluated)); } catch (err) { - return reply(generateFinalResponse(err, -1, false)); + return void reply(generateFinalResponse(err, -1, false)); } }, -} as MentionCommand; +} satisfies MentionCommand; function generateFinalResponse(result: unknown, ms = -1, success = true, fileUpload = false): MessageEditOptions & MessageReplyOptions { const identifier = randomBytes(16).toString("hex"); diff --git a/src/commands/mention/ping.ts b/src/commands/mention/ping.ts index bd7a92ce..fdc73202 100644 --- a/src/commands/mention/ping.ts +++ b/src/commands/mention/ping.ts @@ -6,8 +6,12 @@ export default { worksIn: ["test-areas", "non-test-areas"], testArgs(args) { return args.length === 0; }, async execute(message, reply) { - const now = Date.now(); - const botMessage = await reply("〽️ Pinging..."); - return void botMessage.edit(`🏓 Server latency is \`${Date.now() - now}ms\`, shard latency is \`${Math.ceil(message.guild.shard.ping)}ms\` and my uptime is \`${msToHumanShortTime(message.client.uptime)}\`.`); + const start = Date.now(); + const [[botMessage, messageLatency], gatewayLatency] = await Promise.all([ + reply("〽️ Pinging...").then(newMessage => [newMessage, Date.now() - start] as const), + message.client.rest.get("/gateway").then(() => Date.now() - start), + ]); + + return void botMessage.edit(`🏓 Message latency is \`${messageLatency}ms\`, gateway latency is \`${gatewayLatency}ms\` and my uptime is \`${msToHumanShortTime(message.client.uptime)}\`.`); }, -} as MentionCommand; +} satisfies MentionCommand; diff --git a/src/commands/menu/index.ts b/src/commands/menu/index.ts index 377aaa1b..11b5ce6f 100644 --- a/src/commands/menu/index.ts +++ b/src/commands/menu/index.ts @@ -1,19 +1,18 @@ -import type{ Awaitable, GuildMember, Message, MessageContextMenuCommandInteraction, UserContextMenuCommandInteraction } from "discord.js"; +import type{ APIGuildMember, Awaitable, GuildMember, Message, MessageContextMenuCommandInteraction, UserContextMenuCommandInteraction } from "discord.js"; import { readdirSync } from "fs"; interface BaseMenuCommand { name: string; - public?: true; - worksIn: Array<"non-test-areas" | "test-areas">; + worksIn: Array<"main" | "workers">; } export interface UserMenuCommand extends BaseMenuCommand { - execute(interaction: UserContextMenuCommandInteraction<"cached">, target: GuildMember): Awaitable; + execute(interaction: UserContextMenuCommandInteraction, target: APIGuildMember | GuildMember | null): Awaitable; type: "user"; } export interface MessageMenuCommand extends BaseMenuCommand { - execute(interaction: MessageContextMenuCommandInteraction<"cached">, target: Message): Awaitable; + execute(interaction: MessageContextMenuCommandInteraction, target: Message): Awaitable; type: "message"; } diff --git a/src/commands/menu/toggleAdmin.ts b/src/commands/menu/toggleAdmin.ts new file mode 100644 index 00000000..965cc6fc --- /dev/null +++ b/src/commands/menu/toggleAdmin.ts @@ -0,0 +1,31 @@ +import type { MenuCommand } from "."; +import config from "../../config"; +import { TestArea } from "../../database/models/TestArea"; + +export default { + name: "Toggle administrator permission", + type: "user", + worksIn: ["workers"], + async execute(interaction, target) { + const testArea = await TestArea.findOne({ guildId: interaction.guildId }); + if ( + !testArea || + !( + interaction.user.id === testArea.ownerId || + testArea.guild.operatorIds.includes(interaction.user.id) || + interaction.user.id === config.ownerId + ) + ) return void interaction.reply({ content: "❌ You're not an operator of this test area.", ephemeral: true }); + + const member = "client" in target! ? target : await interaction.guild!.members.fetch(target!.user.id); + if (member.id === interaction.client.user.id) return void interaction.reply({ content: "❌ The test area worker cannot become an admin.", ephemeral: true }); + + if (member.roles.cache.has(testArea.guild.roles.adminId)) { + await member.roles.remove(testArea.guild.roles.adminId, `Toggled by ${interaction.user.displayName}`); + return void interaction.reply({ content: `✅ ${member.toString()} no longer has administrator in this test area.`, ephemeral: true }); + } + + await member.roles.add(testArea.guild.roles.adminId, `Toggled by ${interaction.user.displayName}`); + return void interaction.reply({ content: `✅ ${member.toString()} now has administrator in this test area.`, ephemeral: true }); + }, +} satisfies MenuCommand; diff --git a/src/commands/menu/toggleOperator.ts b/src/commands/menu/toggleOperator.ts new file mode 100644 index 00000000..acbcb8be --- /dev/null +++ b/src/commands/menu/toggleOperator.ts @@ -0,0 +1,34 @@ +import type { MenuCommand } from "."; +import config from "../../config"; +import { TestArea } from "../../database/models/TestArea"; + +export default { + name: "Toggle operator", + type: "user", + worksIn: ["workers"], + async execute(interaction, target) { + const testArea = await TestArea.findOne({ guildId: interaction.guildId }); + if ( + !testArea || + !( + interaction.user.id === testArea.ownerId || + interaction.user.id === config.ownerId + ) + ) return void interaction.reply({ content: "❌ You're not the owner of this test area.", ephemeral: true }); + + const member = "client" in target! ? target : await interaction.guild!.members.fetch(target!.user.id); + if (member.id === interaction.user.id) return void interaction.reply({ content: "❌ The owner cannot become an operator.", ephemeral: true }); + if (member.id === interaction.client.user.id) return void interaction.reply({ content: "❌ The test area worker cannot become an operator.", ephemeral: true }); + if (member.user.bot) return void interaction.reply({ content: "❌ A bot cannot become an operator.", ephemeral: true }); + + if (testArea.guild.operatorIds.includes(member.id)) { + testArea.guild.operatorIds.splice(testArea.guild.operatorIds.indexOf(member.id), 1); + void interaction.reply({ content: `✅ ${member.toString()} is no longer an operator.`, ephemeral: true }); + } else { + testArea.guild.operatorIds.push(member.id); + void interaction.reply({ content: `✅ ${member.toString()} is now an operator.`, ephemeral: true }); + } + + return void await testArea.save(); + }, +} satisfies MenuCommand; diff --git a/src/commands/menu/transferOwnership.ts b/src/commands/menu/transferOwnership.ts new file mode 100644 index 00000000..3cff68c8 --- /dev/null +++ b/src/commands/menu/transferOwnership.ts @@ -0,0 +1,29 @@ +import type { MenuCommand } from "."; +import config from "../../config"; +import { TestArea } from "../../database/models/TestArea"; + +export default { + name: "Transfer ownership", + type: "user", + worksIn: ["workers"], + async execute(interaction, target) { + const testArea = await TestArea.findOne({ guildId: interaction.guildId }); + if ( + !testArea || + !( + interaction.user.id === testArea.ownerId || + interaction.user.id === config.ownerId + ) + ) return void interaction.reply({ content: "❌ You're not the owner of this test area.", ephemeral: true }); + + if (interaction.targetId === interaction.user.id) return void interaction.reply({ content: "❌ You doofus.", ephemeral: true }); + const member = "client" in target! ? target : await interaction.guild!.members.fetch(target!.user.id); + if (member.id === interaction.client.user.id) return void interaction.reply({ content: "❌ The test area worker cannot become the owner.", ephemeral: true }); + if (member.user.bot) return void interaction.reply({ content: "❌ A bot cannot become the owner of a test area.", ephemeral: true }); + + testArea.ownerId = member.id; + await testArea.save(); + + return void interaction.reply({ content: `✅ The new owner of this test area is now ${member.toString()}!`, ephemeral: true }); + }, +} satisfies MenuCommand; diff --git a/src/config.ts b/src/config.ts index 26c3da77..8d091f74 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,17 +1,30 @@ -import { config } from "dotenv"; +import { Colors } from "discord.js"; +import "dotenv/config"; -config(); +// test environment +if ( + !process.env["CLIENT_TOKEN"] || + !process.env["WORKER_TOKENS"] || + !process.env["DATABASE_URI"] || + !process.env["OWNER_ID"] +) throw new Error("Missing environment variables, please check the README for more information on how you can set your environment variables.."); -export default { - client: { - id: String(process.env["CLIENT_ID"]), - token: String(process.env["CLIENT_TOKEN"]), - }, - workerTokens: String(process.env["WORKER_TOKENS"]).split(","), +// export config +const config = { + clientToken: process.env["CLIENT_TOKEN"], + workerTokens: process.env["WORKER_TOKENS"] + .split(",") + .map(token => token.trim()) + .filter(Boolean), + ownerId: process.env["OWNER_ID"], - databaseUri: String(process.env["DATABASE_URI"]), + databaseUri: process.env["DATABASE_URI"], - ownerId: String(process.env["OWNER_ID"]), - - themeColor: parseInt(process.env["THEME_COLOR"] ?? "0", 16) || 0x5865F2, + themeColor: parseInt(process.env["THEME_COLOR"] ?? "0", 16) || Colors.Blurple, + limitAmountOfAreasPerUser: parseInt(process.env["AREA_LIMIT_PER_USER"] ?? "10", 10), } as const; + +if (config.workerTokens.includes(config.clientToken)) throw new Error("You need to supply a separate bot token to be a worker, they cannot work alongside each other."); +if (!config.workerTokens.length) throw new Error("You need to supply at least one worker token. If you want more than one worker then you can separate the tokens with a comma."); + +export default config; diff --git a/src/database/models/TestArea.ts b/src/database/models/TestArea.ts index 12cc7b18..91d91dc3 100644 --- a/src/database/models/TestArea.ts +++ b/src/database/models/TestArea.ts @@ -1,23 +1,83 @@ /* eslint-disable max-classes-per-file */ -import type{ DocumentType } from "@typegoose/typegoose"; -import type{ Snowflake } from "discord.js"; -import { getModelForClass, prop } from "@typegoose/typegoose"; +import type { DocumentType } from "@typegoose/typegoose"; +import type { Guild, GuildMember, Snowflake, User } from "discord.js"; +import { getModelForClass, post, prop, PropType } from "@typegoose/typegoose"; +import { TimeStamps } from "@typegoose/typegoose/lib/defaultClasses"; +import Worker from "../../handlers/workers/Worker"; +import client from "../../utils/client"; export class TestAreaRoles { - @prop({ type: String, required: true }) adminId!: Snowflake; - @prop({ type: String, required: true }) botId!: Snowflake; - @prop({ type: String, required: true }) ownerId!: Snowflake; + @prop({ type: String, required: true }) readonly adminId!: Snowflake; + @prop({ type: String, required: true }) readonly botId!: Snowflake; + @prop({ type: String, required: true }) readonly managerId!: Snowflake; + @prop({ type: String, required: true }) readonly operatorId!: Snowflake; + @prop({ type: String, required: true }) readonly ownerId!: Snowflake; +} + +export class TestAreaGuild { + @prop({ type: String, required: true }) inviteCode!: string; + @prop({ type: String, required: true }) name!: string; + @prop({ type: [String], default: [] }, PropType.ARRAY) operatorIds!: Snowflake[]; + @prop({ type: TestAreaRoles, required: true }) readonly roles!: TestAreaRoles; } -export class TestAreaSchema { - @prop({ type: String, required: true }) botId!: Snowflake; - @prop({ type: String, required: true }) invite!: string; +/* eslint-disable @typescript-eslint/no-invalid-this */ +@post("save", function () { + void this.updateRoles(); +}) +/* eslint-enable @typescript-eslint/no-invalid-this */ + +export class TestAreaSchema extends TimeStamps { + @prop({ type: TestAreaGuild, required: true }) guild!: TestAreaGuild; + @prop({ type: String, required: true, unique: true }) readonly guildId!: Snowflake; @prop({ type: Date, default: Date.now }) lastActivityAt!: Date; @prop({ type: String, required: true }) ownerId!: Snowflake; - @prop({ type: TestAreaRoles, required: true }) roles!: TestAreaRoles; - @prop({ type: String, required: true }) serverId!: Snowflake; + @prop({ type: String, required: true }) readonly workerId!: Snowflake; + + get discordGuild(): Guild | null { + return this.worker.client.guilds.cache.get(this.guildId) ?? null; + } + + get ownerMember(): GuildMember | null { + return this.discordGuild?.members.cache.get(this.ownerId) ?? null; + } + + get ownerUser(): null | User { + return ( + this.ownerMember?.user ?? + client.users.cache.get(this.ownerId) ?? + Array.from(Worker.workers.values()).find(worker => worker.client.users.cache.has(this.ownerId))?.client.users.cache.get(this.ownerId) ?? + null + ); + } + + get worker(): Worker { + return Worker.workers.get(this.workerId)!; + } + + async updateRoles(): Promise { + if (!this.discordGuild) return; + + const members = await this.discordGuild.members.fetch(); + for (const [, member] of members) { + let roleToHave: null | Snowflake = null; + if (member.user.bot && member.user.id !== this.workerId) roleToHave = this.guild.roles.botId; + if (this.guild.operatorIds.includes(member.id)) roleToHave = this.guild.roles.operatorId; + if (member.id === this.ownerId) roleToHave = this.guild.roles.ownerId; + if (member.user.id === this.workerId) roleToHave = this.guild.roles.managerId; + + const rolesToRemove = [ + this.guild.roles.botId, + this.guild.roles.operatorId, + this.guild.roles.ownerId, + this.guild.roles.managerId, + ].filter(roleToRemove => member.roles.cache.has(roleToRemove) && roleToRemove !== roleToHave); + if (rolesToRemove.length) await member.roles.remove(rolesToRemove); + + if (roleToHave && !member.roles.cache.has(roleToHave)) await member.roles.add(roleToHave); + } + } } export type TestAreaDocument = DocumentType; - export const TestArea = getModelForClass(TestAreaSchema); diff --git a/src/handlers/interactions/autocompletes.ts b/src/handlers/interactions/autocompletes.ts index 5aed1271..8fe2b8df 100644 --- a/src/handlers/interactions/autocompletes.ts +++ b/src/handlers/interactions/autocompletes.ts @@ -1,10 +1,10 @@ -import type{ ApplicationCommandOptionChoiceData, AutocompleteInteraction, Awaitable } from "discord.js"; +import type { ApplicationCommandOptionChoiceData, AutocompleteInteraction, Awaitable } from "discord.js"; import type{ ChatInputCommand } from "../../commands/chatInput"; import { allChatInputCommands } from "../../commands/chatInput"; -export type Autocomplete = (query: QueryType, interaction: AutocompleteInteraction<"cached">) => Awaitable>>; +export type Autocomplete = (query: QueryType, interaction: AutocompleteInteraction) => Awaitable>>; -export default async function autocompleteHandler(interaction: AutocompleteInteraction<"cached">): Promise { +export default async function autocompleteHandler(interaction: AutocompleteInteraction): Promise { const hierarchy = [interaction.commandName, interaction.options.getSubcommandGroup(false), interaction.options.getSubcommand(false)] as const; const firstLevelCommand = allChatInputCommands.find(({ name }) => name === hierarchy[0]) ?? null; diff --git a/src/handlers/interactions/chatInputCommands.ts b/src/handlers/interactions/chatInputCommands.ts index 25e2f480..3ba0bcab 100644 --- a/src/handlers/interactions/chatInputCommands.ts +++ b/src/handlers/interactions/chatInputCommands.ts @@ -2,7 +2,7 @@ import type{ ChatInputCommandInteraction } from "discord.js"; import type{ ChatInputCommand } from "../../commands/chatInput"; import { allChatInputCommands } from "../../commands/chatInput"; -export default function chatInputCommandHandler(interaction: ChatInputCommandInteraction<"cached">): void { +export default function chatInputCommandHandler(interaction: ChatInputCommandInteraction): void { const hierarchy = [interaction.commandName, interaction.options.getSubcommandGroup(false), interaction.options.getSubcommand(false)] as const; const firstLevelCommand = allChatInputCommands.find(({ name }) => name === hierarchy[0]) ?? null; diff --git a/src/handlers/interactions/components.ts b/src/handlers/interactions/components.ts index 4409d74e..b42739dd 100644 --- a/src/handlers/interactions/components.ts +++ b/src/handlers/interactions/components.ts @@ -7,38 +7,38 @@ interface BaseComponent { } interface ButtonComponent extends BaseComponent { - callback(interaction: ButtonInteraction<"cached">): Awaitable; + callback(interaction: ButtonInteraction): Awaitable; } interface ChannelSelectMenuComponent extends BaseComponent { - callback(interaction: ChannelSelectMenuInteraction<"cached">): Awaitable; + callback(interaction: ChannelSelectMenuInteraction): Awaitable; selectType: "channel"; } interface MentionableSelectMenuComponent extends BaseComponent { - callback(interaction: MentionableSelectMenuInteraction<"cached">): Awaitable; + callback(interaction: MentionableSelectMenuInteraction): Awaitable; selectType: "mentionable"; } interface RoleSelectMenuComponent extends BaseComponent { - callback(interaction: RoleSelectMenuInteraction<"cached">): Awaitable; + callback(interaction: RoleSelectMenuInteraction): Awaitable; selectType: "role"; } interface StringSelectMenuComponent extends BaseComponent { - callback(interaction: StringSelectMenuInteraction<"cached">): Awaitable; + callback(interaction: StringSelectMenuInteraction): Awaitable; selectType: "string"; } interface UserSelectMenuComponent extends BaseComponent { - callback(interaction: UserSelectMenuInteraction<"cached">): Awaitable; + callback(interaction: UserSelectMenuInteraction): Awaitable; selectType: "user"; } export const buttonComponents = new Map(); export const selectMenuComponents = new Map(); -export default function componentHandler(interaction: AnySelectMenuInteraction<"cached"> | ButtonInteraction<"cached">): void { +export default function componentHandler(interaction: AnySelectMenuInteraction | ButtonInteraction): void { if (interaction.isButton()) { const component = buttonComponents.get(interaction.customId); if (component && (component.allowedUsers === "all" || component.allowedUsers.includes(interaction.user.id))) void component.callback(interaction); @@ -58,6 +58,6 @@ const selectTypes: Record<(ChannelSelectMenuComponent | MentionableSelectMenuCom user: ComponentType.UserSelect, }; -function selectComponentMatchesInteractionType(interaction: AnySelectMenuInteraction<"cached">, component: ChannelSelectMenuComponent | MentionableSelectMenuComponent | RoleSelectMenuComponent | StringSelectMenuComponent | UserSelectMenuComponent): boolean { +function selectComponentMatchesInteractionType(interaction: AnySelectMenuInteraction, component: ChannelSelectMenuComponent | MentionableSelectMenuComponent | RoleSelectMenuComponent | StringSelectMenuComponent | UserSelectMenuComponent): boolean { return selectTypes[component.selectType] === interaction.componentType; } diff --git a/src/handlers/interactions/index.ts b/src/handlers/interactions/index.ts index 293b3125..8922dbd0 100644 --- a/src/handlers/interactions/index.ts +++ b/src/handlers/interactions/index.ts @@ -9,7 +9,6 @@ import modalHandler from "./modals"; export default function handleInteractions(client: Client, workerName?: string): void { client.on("interactionCreate", interaction => { - if (!interaction.inCachedGuild()) return void mainLogger.warn(`Received interaction ${interaction.id} (guild ${interaction.guildId ?? "n/a"}, channel ${interaction.channelId ?? "n/a"}, user ${interaction.user.id}) from uncached guild.${workerName ? ` (${workerName})` : ""}`); if (interaction.isModalSubmit()) return modalHandler(interaction); if (interaction.isMessageComponent()) return componentHandler(interaction); if (interaction.isChatInputCommand()) return chatInputCommandHandler(interaction); @@ -19,5 +18,5 @@ export default function handleInteractions(client: Client, workerName?: st mainLogger.info(`Interaction command listener registered.${workerName ? ` (${workerName})` : ""}`); - void client.application.commands.set(getAllApplicationCommands(workerName ? "test-areas" : "non-test-areas")).then(() => mainLogger.info(`Application commands registered.${workerName ? ` (${workerName})` : ""}`)); + void client.application.commands.set(getAllApplicationCommands(workerName ? "workers" : "main")).then(() => mainLogger.info(`Application commands registered.${workerName ? ` (${workerName})` : ""}`)); } diff --git a/src/handlers/interactions/menuCommands.ts b/src/handlers/interactions/menuCommands.ts index 3287d89b..e666674a 100644 --- a/src/handlers/interactions/menuCommands.ts +++ b/src/handlers/interactions/menuCommands.ts @@ -1,13 +1,8 @@ import type{ ContextMenuCommandInteraction } from "discord.js"; import { allMenuCommands } from "../../commands/menu"; -export default async function menuCommandHandler(interaction: ContextMenuCommandInteraction<"cached">): Promise { +export default async function menuCommandHandler(interaction: ContextMenuCommandInteraction): Promise { const command = allMenuCommands.find(({ name }) => name === interaction.commandName); - if (interaction.isMessageContextMenuCommand() && command?.type === "message") { - const target = await interaction.channel?.messages.fetch(interaction.targetId).catch(() => null); - if (target) return command.execute(interaction, target); - } else if (interaction.isUserContextMenuCommand() && command?.type === "user") { - const target = await interaction.guild.members.fetch(interaction.targetId).catch(() => null); - if (target) return command.execute(interaction, target); - } + if (interaction.isMessageContextMenuCommand() && command?.type === "message") return command.execute(interaction, interaction.targetMessage); + if (interaction.isUserContextMenuCommand() && command?.type === "user") return command.execute(interaction, interaction.targetMember); } diff --git a/src/handlers/interactions/modals.ts b/src/handlers/interactions/modals.ts index b6be05a8..0e068e70 100644 --- a/src/handlers/interactions/modals.ts +++ b/src/handlers/interactions/modals.ts @@ -1,10 +1,10 @@ import type{ Awaitable, ModalSubmitInteraction } from "discord.js"; -export type Modal = (interaction: ModalSubmitInteraction<"cached">) => Awaitable; +export type Modal = (interaction: ModalSubmitInteraction) => Awaitable; export const modals = new Map(); -export default function modalHandler(interaction: ModalSubmitInteraction<"cached">): void { +export default function modalHandler(interaction: ModalSubmitInteraction): void { const modal = modals.get(interaction.customId); if (modal) void modal(interaction); modals.delete(interaction.customId); diff --git a/src/handlers/workers.ts b/src/handlers/workers.ts deleted file mode 100644 index 1d0a3df9..00000000 --- a/src/handlers/workers.ts +++ /dev/null @@ -1,132 +0,0 @@ -import type{ Caches, Guild, GuildMember, Snowflake } from "discord.js"; -import { Client, IntentsBitField, Options, Partials } from "discord.js"; -import { objects, predicates } from "friendly-words"; -import { inspect } from "util"; -import config from "../config"; -import { TestArea } from "../database/models/TestArea"; -import { workerLogger } from "../utils/logger/discord"; -import mainLogger from "../utils/logger/main"; -import handleInteractions from "./interactions"; -import handleMentionCommands from "./mentionCommands"; - -export const workers = new Map>(); - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -- // todo use client -export default function handleWorkers(_client: Client): void { - void initWorkers().then(() => mainLogger.info("Workers initialized.")); -} - -async function initWorkers(): Promise { - for (const token of config.workerTokens) { - const worker = new Client({ - allowedMentions: { parse: [], users: [], roles: [], repliedUser: true }, - intents: [ - IntentsBitField.Flags.GuildMessages, - IntentsBitField.Flags.Guilds, - IntentsBitField.Flags.GuildMembers, - IntentsBitField.Flags.MessageContent, - ], - makeCache: Options.cacheWithLimits({ - ApplicationCommandManager: 0, - AutoModerationRuleManager: 0, - BaseGuildEmojiManager: 0, - DMMessageManager: 0, - GuildBanManager: 0, - GuildEmojiManager: 0, - GuildForumThreadManager: 0, - GuildInviteManager: 0, - GuildMemberManager: 0, - GuildMessageManager: 0, - GuildScheduledEventManager: 0, - GuildStickerManager: 0, - GuildStickerPackManager: 0, - GuildTextThreadManager: 0, - MessageManager: 0, - PresenceManager: 0, - ReactionManager: 0, - ReactionUserManager: 0, - StageInstanceManager: 0, - ThreadManager: 0, - ThreadMemberManager: 0, - UserManager: 0, - VoiceStateManager: 0, - } as Record), - partials: [ - Partials.Channel, - Partials.GuildMember, - Partials.GuildScheduledEvent, - Partials.Message, - Partials.Reaction, - Partials.ThreadMember, - Partials.User, - ], - presence: { status: "online" }, - rest: { userAgentAppendix: "Test Area Worker (https://github.com/biaw/test-area)" }, - }); - - const workerId = Buffer.from(token.split(".")[0]!, "base64").toString("ascii"); - - const workerName = getWorkerUniqueName(workerId); - const discordLogger = workerLogger(workerName); - - // init client - worker.once("ready", trueWorker => { - mainLogger.info(`Worker "${workerName}" logged in as ${trueWorker.user.tag}!`); - - handleInteractions(trueWorker, workerName); - handleMentionCommands(trueWorker, workerName); - workers.set(workerId, trueWorker); - }); - - // discord debug logging - worker - .on("cacheSweep", message => void discordLogger.debug(message)) - .on("debug", info => void discordLogger.debug(info)) - .on("error", error => void discordLogger.error(`Cluster errored. ${inspect(error)}`)) - .on("rateLimit", rateLimitData => void discordLogger.warn(`Rate limit ${JSON.stringify(rateLimitData)}`)) - .on("ready", () => void discordLogger.info("All shards have been connected.")) - .on("shardDisconnect", (_, id) => void discordLogger.warn(`Shard ${id} disconnected.`)) - .on("shardError", (error, id) => void discordLogger.error(`Shard ${id} errored. ${inspect(error)}`)) - .on("shardReady", id => void discordLogger.info(`Shard ${id} is ready.`)) - .on("shardReconnecting", id => void discordLogger.warn(`Shard ${id} is reconnecting.`)) - .on("shardResume", (id, replayed) => void discordLogger.info(`Shard ${id} resumed. ${replayed} events replayed.`)) - .on("warn", info => void discordLogger.warn(info)); - - // trigger guild activity update -- we really only need the message create event for this - worker.on("messageCreate", message => updateGuildActivity(message.guild!)); - - // give roles upon joining the server - worker.on("guildMemberAdd", member => updateRoles(member)); - - // log in - await worker.login(token); - } -} - -function getWorkerUniqueName(workerId: Snowflake): string { - const workerNumber = Number(workerId); - const workerIndent = workerNumber % (objects.length * predicates.length); - return `${predicates[workerIndent % predicates.length]!} ${objects[Math.floor(workerIndent / predicates.length)]!}`; -} - -function updateGuildActivity(guild: Guild): void { - void TestArea.findOne({ serverId: guild.id }) - .then(testArea => { - if (testArea) { - testArea.lastActivityAt = new Date(); - void testArea.save(); - } - }); -} - -function updateRoles(member: GuildMember): void { - void TestArea.findOne({ serverId: member.guild.id }) - .then(testArea => { - if (testArea) { - const roles = []; - if (testArea.ownerId === member.id) roles.push(testArea.roles.ownerId, testArea.roles.adminId); - if (member.user.bot) roles.push(testArea.roles.botId); - if (roles.length) void member.roles.add(roles); - } - }); -} diff --git a/src/handlers/workers/Worker.ts b/src/handlers/workers/Worker.ts new file mode 100644 index 00000000..4541ef06 --- /dev/null +++ b/src/handlers/workers/Worker.ts @@ -0,0 +1,112 @@ +import type { Caches, Snowflake } from "discord.js"; +import type { Logger } from "winston"; +import { Client, IntentsBitField, Options, Partials } from "discord.js"; +import { objects, predicates } from "friendly-words"; +import { inspect } from "util"; +import { workerLogger } from "../../utils/logger/discord"; +import mainLogger from "../../utils/logger/main"; +import handleInteractions from "../interactions"; +import handleMentionCommands from "../mentionCommands"; + +export default class Worker { + public static workers = new Map(); + public static get nonFullWorkers(): Worker[] { + return Array.from(this.workers.values()).filter(worker => worker.client.guilds.cache.size < 10); + } + + public readonly client: Client; + public readonly logger: Logger; + + public get workerName(): string { + const workerNumber = Number(this.workerId); + const workerIndent = workerNumber % (objects.length * predicates.length); + return `${predicates[workerIndent % predicates.length]!} ${objects[Math.floor(workerIndent / predicates.length)]!}`; + } + + private readonly token: string; + private readonly workerId: Snowflake; + + public constructor(token: string) { + this.client = new Client({ + allowedMentions: { parse: [], users: [], roles: [], repliedUser: true }, + intents: [ + IntentsBitField.Flags.GuildMessages, + IntentsBitField.Flags.Guilds, + IntentsBitField.Flags.GuildMembers, + IntentsBitField.Flags.MessageContent, + ], + makeCache: Options.cacheWithLimits({ + ApplicationCommandManager: 0, + AutoModerationRuleManager: 0, + BaseGuildEmojiManager: 0, + DMMessageManager: 0, + GuildBanManager: 0, + GuildEmojiManager: 0, + GuildForumThreadManager: 0, + GuildInviteManager: 0, + GuildMemberManager: Infinity, + GuildMessageManager: 0, + GuildScheduledEventManager: 0, + GuildStickerManager: 0, + GuildStickerPackManager: 0, + GuildTextThreadManager: 0, + MessageManager: 0, + PresenceManager: 0, + ReactionManager: 0, + ReactionUserManager: 0, + StageInstanceManager: 0, + ThreadManager: 0, + ThreadMemberManager: 0, + UserManager: Infinity, + VoiceStateManager: 0, + } as Record), + partials: [ + Partials.Channel, + Partials.GuildMember, + Partials.GuildScheduledEvent, + Partials.Message, + Partials.Reaction, + Partials.ThreadMember, + Partials.User, + ], + presence: { status: "online" }, + rest: { userAgentAppendix: "Test Area Worker (https://github.com/biaw/test-area)" }, + }); + + this.workerId = Buffer.from(token.split(".")[0]!, "base64").toString("ascii"); + this.logger = workerLogger(this.workerName); + this.token = token; + + Worker.workers.set(this.workerId, this); + + // init client + this.client.once("ready", trueClient => { + mainLogger.info(`Worker "${this.workerName}" logged in as ${this.workerName}#${trueClient.user.discriminator}!`); + + handleInteractions(trueClient, this.workerName); + handleMentionCommands(trueClient, this.workerName); + + if (trueClient.user.username !== this.workerName) { + void trueClient.user.edit({ username: this.workerName }); + this.logger.info(`Renamed worker to match worker name. (${this.workerName})`); + } + }); + + // discord debug logging + this.client + .on("cacheSweep", message => void this.logger.debug(message)) + .on("debug", info => void this.logger.debug(info)) + .on("error", error => void this.logger.error(`Client errored. ${inspect(error)}`)) + .on("rateLimit", rateLimitData => void this.logger.warn(`Rate limit: ${JSON.stringify(rateLimitData)}`)) + .on("ready", () => void this.logger.info("All shards have been connected.")) + .on("warn", info => void this.logger.warn(info)); + } + + public static getRandomNonFullWorker(): null | Worker { + return this.nonFullWorkers[Math.floor(Math.random() * this.nonFullWorkers.length)] ?? null; + } + + public async login(): Promise { + return void await this.client.login(this.token); + } +} diff --git a/src/handlers/workers/index.ts b/src/handlers/workers/index.ts new file mode 100644 index 00000000..329343b9 --- /dev/null +++ b/src/handlers/workers/index.ts @@ -0,0 +1,24 @@ +import config from "../../config"; +import { TestArea } from "../../database/models/TestArea"; +import mainLogger from "../../utils/logger/main"; +import Worker from "./Worker"; + +export default function handleWorkers(): void { + void initWorkers().then(() => mainLogger.info("Workers initialized.")); +} + +async function initWorkers(): Promise { + for (const token of config.workerTokens) await new Worker(token).login(); + for (const [, worker] of Worker.workers) { + + // since Worker.ts is importing TestArea.ts and vice versa, we need to do these things in a separate file unfortunately. + worker.client.on("messageCreate", message => message.guild && void TestArea.findOne({ guildId: message.guild.id }).then(testArea => { + if (testArea) { + testArea.lastActivityAt = new Date(); + void testArea.save(); + } + })); + + worker.client.on("guildMemberAdd", member => void TestArea.findOne({ guildId: member.guild.id }).then(testArea => void testArea?.updateRoles())); + } +} diff --git a/src/index.ts b/src/index.ts index c10db3cd..cdde81a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,66 +1,20 @@ -import type{ Caches } from "discord.js"; -import { Client, IntentsBitField, Options, Partials } from "discord.js"; import { inspect } from "util"; import config from "./config"; import connection from "./database"; import handleInteractions from "./handlers/interactions"; import handleMentionCommands from "./handlers/mentionCommands"; import handleWorkers from "./handlers/workers"; +import client from "./utils/client"; import discordLogger from "./utils/logger/discord"; import mainLogger from "./utils/logger/main"; -const client = new Client({ - allowedMentions: { parse: [], users: [], roles: [], repliedUser: true }, - intents: [ - IntentsBitField.Flags.GuildMessages, - IntentsBitField.Flags.Guilds, - IntentsBitField.Flags.MessageContent, - ], - makeCache: Options.cacheWithLimits({ - ApplicationCommandManager: 0, - AutoModerationRuleManager: 0, - BaseGuildEmojiManager: 0, - DMMessageManager: 0, - GuildBanManager: 0, - GuildEmojiManager: 0, - GuildForumThreadManager: 0, - GuildInviteManager: 0, - GuildMemberManager: 0, - GuildMessageManager: 0, - GuildScheduledEventManager: 0, - GuildStickerManager: 0, - GuildStickerPackManager: 0, - GuildTextThreadManager: 0, - MessageManager: 0, - PresenceManager: 0, - ReactionManager: 0, - ReactionUserManager: 0, - StageInstanceManager: 0, - ThreadManager: 0, - ThreadMemberManager: 0, - UserManager: 0, - VoiceStateManager: 0, - } as Record), - partials: [ - Partials.Channel, - Partials.GuildMember, - Partials.GuildScheduledEvent, - Partials.Message, - Partials.Reaction, - Partials.ThreadMember, - Partials.User, - ], - presence: { status: "online" }, - rest: { userAgentAppendix: "Test Area (https://github.com/biaw/test-area)" }, -}); - // init client client.once("ready", trueClient => { mainLogger.info(`Logged in as ${trueClient.user.tag}!`); handleInteractions(trueClient); handleMentionCommands(trueClient); - handleWorkers(trueClient); + handleWorkers(); }); // discord debug logging @@ -83,4 +37,4 @@ process .on("unhandledRejection", error => mainLogger.warn(`Unhandled rejection: ${inspect(error)}`)); // log in -void connection.then(() => void client.login(config.client.token)); +void connection.then(() => void client.login(config.clientToken)); diff --git a/src/utils/client.ts b/src/utils/client.ts new file mode 100644 index 00000000..ba831e27 --- /dev/null +++ b/src/utils/client.ts @@ -0,0 +1,49 @@ +import type { Caches } from "discord.js"; +import { Client, IntentsBitField, Options, Partials } from "discord.js"; + +const client = new Client({ + allowedMentions: { parse: [], users: [], roles: [], repliedUser: true }, + intents: [ + IntentsBitField.Flags.GuildMessages, + IntentsBitField.Flags.Guilds, + IntentsBitField.Flags.MessageContent, + ], + makeCache: Options.cacheWithLimits({ + ApplicationCommandManager: 0, + AutoModerationRuleManager: 0, + BaseGuildEmojiManager: 0, + DMMessageManager: 0, + GuildBanManager: 0, + GuildEmojiManager: 0, + GuildForumThreadManager: 0, + GuildInviteManager: 0, + GuildMemberManager: 0, + GuildMessageManager: 0, + GuildScheduledEventManager: 0, + GuildStickerManager: 0, + GuildStickerPackManager: 0, + GuildTextThreadManager: 0, + MessageManager: 0, + PresenceManager: 0, + ReactionManager: 0, + ReactionUserManager: 0, + StageInstanceManager: 0, + ThreadManager: 0, + ThreadMemberManager: 0, + UserManager: 0, + VoiceStateManager: 0, + } as Record), + partials: [ + Partials.Channel, + Partials.GuildMember, + Partials.GuildScheduledEvent, + Partials.Message, + Partials.Reaction, + Partials.ThreadMember, + Partials.User, + ], + presence: { status: "online" }, + rest: { userAgentAppendix: "Test Area (https://github.com/biaw/test-area)" }, +}); + +export default client;