diff --git a/.eslintrc.js b/.eslintrc.js index 2737352f..833cbb4a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -27,6 +27,7 @@ module.exports = { 'sort-imports': 'error', 'unicorn/prefer-node-protocol': 'error', // '@typescript-eslint/consistent-type-imports': 'error', - '@typescript-eslint/explicit-member-accessibility': 'error' + '@typescript-eslint/explicit-member-accessibility': 'error', + '@typescript-eslint/method-signature-style': 'off' } } diff --git a/.github/workflows/continuous-delivery.yml b/.github/workflows/continuous-delivery.yml index 94786393..6e31adb2 100644 --- a/.github/workflows/continuous-delivery.yml +++ b/.github/workflows/continuous-delivery.yml @@ -153,6 +153,7 @@ jobs: cd /opt/docker/$PROJECT_NAME/$STAGE docker-compose pull docker-compose run --rm app yarn run typeorm migration:run + docker-compose run --rm app node bin/update-commands.js docker-compose up -d - name: Finalize Sentry release diff --git a/bin/update-commands.js b/bin/update-commands.js new file mode 100644 index 00000000..442ebca2 --- /dev/null +++ b/bin/update-commands.js @@ -0,0 +1,16 @@ +#!/usr/bin/env node +'use strict' + +require('dotenv').config() + +const { REST } = require('@discordjs/rest') +const { Routes } = require('discord-api-types/v10') +const applicationCommands = require('../dist/interactions/data/application-commands') + +async function updateCommands () { + const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_TOKEN) + const application = await rest.get(Routes.oauth2CurrentApplication()) + await rest.put(Routes.applicationCommands(application.id), { body: Object.values(applicationCommands) }) +} + +updateCommands() diff --git a/docker-compose.yml b/docker-compose.yml index be09c74c..4d61976c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: environment: - POSTGRES_HOST=db volumes: - - ./config/application.js:/opt/app/config/application.js + - ./application.js:/opt/app/dist/configs/application.js command: /bin/bash ./bin/wait-for-it.sh db:5432 -- yarn start db: diff --git a/ormconfig.js b/ormconfig.js index ae3a09ee..5220463f 100644 --- a/ormconfig.js +++ b/ormconfig.js @@ -7,15 +7,9 @@ const baseConfig = { port: 5432, username: process.env.POSTGRES_USER, password: process.env.POSTGRES_PASSWORD, - entities: [ - 'dist/entities/**/*.js' - ], - migrations: [ - 'dist/migrations/**/*.js' - ], - subscribers: [ - 'dist/subscribers/**/*.js' - ], + entities: ['dist/entities/**/*.js'], + migrations: ['dist/migrations/**/*.js'], + subscribers: ['dist/subscribers/**/*.js'], cli: { entitiesDir: 'src/entities', migrationsDir: 'src/migrations', diff --git a/package.json b/package.json index 8ac7d42f..78e3b410 100644 --- a/package.json +++ b/package.json @@ -22,11 +22,11 @@ "axios": "^0.26.0", "class-validator": "^0.13.2", "common-tags": "^1.8.2", - "discord.js": "^12.5.3", - "discord.js-commando": "^0.12.3", + "discord-api-types": "^0.33.0", + "discord.js": "^13.7.0", "dotenv": "^16.0.0", + "emoji-regex": "^10.0.0", "inversify": "^6.0.1", - "inversify-inject-decorators": "^3.1.0", "lodash": "^4.17.21", "node-cron": "^3.0.0", "pg": "^8.7.1", @@ -36,6 +36,7 @@ "ws": "^8.3.0" }, "devDependencies": { + "@discordjs/rest": "^0.3.0", "@guidojw/bloxy": "^5.7.6", "@types/common-tags": "^1.8.1", "@types/lodash": "^4.14.178", @@ -54,7 +55,7 @@ "typescript": "^4.5.3" }, "engines": { - "node": ">=14" + "node": ">=16.6.0" }, "packageManager": "yarn@3.2.0" } diff --git a/src/argument-types/always.ts b/src/argument-types/always.ts new file mode 100644 index 00000000..5abe9491 --- /dev/null +++ b/src/argument-types/always.ts @@ -0,0 +1,13 @@ +import BaseArgumentType from './base' +import { injectable } from 'inversify' + +@injectable() +export default class AlwaysArgumentType extends BaseArgumentType { + public validate (): boolean { + return true + } + + public parse (value: string): string { + return value + } +} diff --git a/src/argument-types/base.ts b/src/argument-types/base.ts new file mode 100644 index 00000000..778393ce --- /dev/null +++ b/src/argument-types/base.ts @@ -0,0 +1,111 @@ +import type { Collection, CommandInteraction } from 'discord.js' +import { type Constructor, constants } from '../utils' +import type { DataManager, GuildContextManager } from '../managers' +import type { GuildContext, IdentifiableStructure } from '../structures' +import { inject, injectable, named, unmanaged } from 'inversify' +import type { Argument } from '../interactions/application-commands' +import type { IdentifiableEntity } from '../entities' +import lodash from 'lodash' +import pluralize from 'pluralize' + +const { TYPES } = constants + +@injectable() +export default abstract class BaseArgumentType { + public abstract validate ( + value: string, + interaction: CommandInteraction, + arg: Argument + ): boolean | string | Promise + + public abstract parse ( + value: string, + interaction: CommandInteraction, + arg: Argument + ): T | null | Promise +} + +export class BaseStructureArgumentType< + T extends IdentifiableStructure, + U extends IdentifiableEntity +> extends BaseArgumentType { + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + protected readonly holds: Constructor + + private readonly managerName: string + + public constructor (@unmanaged() holds: Constructor, @unmanaged() managerName?: string) { + super() + + this.holds = holds + this.managerName = typeof managerName === 'undefined' + ? pluralize(lodash.camelCase(holds.name)) + : managerName + } + + public validate ( + value: string, + interaction: CommandInteraction, + _arg: Argument + ): boolean | string | Promise { + if (!interaction.inGuild()) { + return false + } + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + + const manager = context[this.managerName as keyof typeof context] as unknown as DataManager + const id = parseInt(value) + if (!isNaN(id)) { + const structure = manager.cache.get(id) + return typeof structure !== 'undefined' && structure instanceof this.holds + } + const search = value.toLowerCase() + const structures: Collection = manager.cache.filter(this.filterInexact(search)) + if (structures.size === 1) { + return true + } + const exactStructures = structures.filter(this.filterExact(search)) + return exactStructures.size === 1 + } + + public parse ( + value: string, + interaction: CommandInteraction, + _arg: Argument + ): T | null | Promise { + if (!interaction.inGuild()) { + return null + } + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + + const manager = context[this.managerName as keyof typeof context] as unknown as DataManager + const id = parseInt(value) + if (!isNaN(id)) { + return manager.cache.get(id) ?? null + } + const search = value.toLowerCase() + const structures = manager.cache.filter(this.filterInexact(search)) + if (structures.size === 0) { + return null + } + if (structures.size === 1) { + return structures.first() as T + } + const exactStructures = structures.filter(this.filterExact(search)) + if (exactStructures.size === 1) { + return exactStructures.first() as T + } + return null + } + + protected filterExact (search: string): (structure: T) => boolean { + return structure => structure instanceof this.holds && structure.toString().toLowerCase() === search + } + + protected filterInexact (search: string): (structure: T) => boolean { + return structure => structure instanceof this.holds && structure.toString().toLowerCase().includes(search) + } +} diff --git a/src/argument-types/boolean.ts b/src/argument-types/boolean.ts new file mode 100644 index 00000000..a578160d --- /dev/null +++ b/src/argument-types/boolean.ts @@ -0,0 +1,21 @@ +import BaseArgumentType from './base' +import { injectable } from 'inversify' + +@injectable() +export default class BooleanArgumentType extends BaseArgumentType { + private readonly truthy = ['true', 't', 'yes', 'y', 'on', 'enable', 'enabled', '1', '+'] + private readonly falsy = ['false', 'f', 'no', 'n', 'off', 'disable', 'disabled', '0', '-'] + + public validate (value: string): boolean { + const search = value.toLowerCase() + return this.truthy.includes(search) || this.falsy.includes(search) + } + + public parse (value: string): boolean | null { + if (!this.validate(value)) { + return null + } + const search = value.toLowerCase() + return this.truthy.includes(search) + } +} diff --git a/src/argument-types/category-channel.ts b/src/argument-types/category-channel.ts new file mode 100644 index 00000000..28702fe7 --- /dev/null +++ b/src/argument-types/category-channel.ts @@ -0,0 +1,32 @@ +import { CategoryChannel, type CommandInteraction } from 'discord.js' +import BaseArgumentType from './base' +import { injectable } from 'inversify' + +@injectable() +export default class CategoryChannelArgumentType extends BaseArgumentType { + public validate (value: string, interaction: CommandInteraction): boolean { + if (!interaction.inCachedGuild()) { + return false + } + + const match = value.match(/(\d+)/) + if (match === null) { + return false + } + const channel = interaction.guild.channels.resolve(match[0]) + return channel !== null && channel instanceof CategoryChannel + } + + public parse (value: string, interaction: CommandInteraction): CategoryChannel | null { + if (!interaction.inCachedGuild()) { + return null + } + + const match = value.match(/(\d+)/) + if (match === null) { + return null + } + const channel = interaction.guild.channels.resolve(match[0]) + return channel instanceof CategoryChannel ? channel : null + } +} diff --git a/src/argument-types/channel-group.ts b/src/argument-types/channel-group.ts new file mode 100644 index 00000000..39416c14 --- /dev/null +++ b/src/argument-types/channel-group.ts @@ -0,0 +1,11 @@ +import { BaseStructureArgumentType } from './base' +import { ChannelGroup } from '../structures' +import type { Group as GroupEntity } from '../entities' +import { injectable } from 'inversify' + +@injectable() +export default class ChannelGroupArgumentType extends BaseStructureArgumentType { + public constructor () { + super(ChannelGroup, 'groups') + } +} diff --git a/src/argument-types/custom-emoji.ts b/src/argument-types/custom-emoji.ts new file mode 100644 index 00000000..a29db9c2 --- /dev/null +++ b/src/argument-types/custom-emoji.ts @@ -0,0 +1,54 @@ +import type { CommandInteraction, GuildEmoji } from 'discord.js' +import BaseArgumentType from './base' +import { injectable } from 'inversify' + +@injectable() +export default class CustomEmojiArgumentType extends BaseArgumentType { + public validate (value: string, interaction: CommandInteraction): boolean { + const match = value.match(/^(?:?$/) + if (match !== null && interaction.client.emojis.cache.has(match[2])) { + return true + } + if (!interaction.inCachedGuild()) { + return false + } + const search = value.toLowerCase() + const emojis = interaction.guild.emojis.cache.filter(this.filterInexact(search)) + if (emojis.size === 1) { + return true + } + const exactEmojis = interaction.guild.emojis.cache.filter(this.filterExact(search)) + return exactEmojis.size === 1 + } + + public parse (value: string, interaction: CommandInteraction): GuildEmoji | null { + const match = value.match(/^(?:?$/) + if (match !== null) { + return interaction.client.emojis.cache.get(match[2]) ?? null + } + if (!interaction.inCachedGuild()) { + return null + } + const search = value.toLowerCase() + const emojis = interaction.guild.emojis.cache.filter(this.filterInexact(search)) + if (emojis.size === 0) { + return null + } + if (emojis.size === 1) { + return emojis.first() as GuildEmoji + } + const exactEmojis = interaction.guild.emojis.cache.filter(this.filterExact(search)) + if (exactEmojis.size === 1) { + return exactEmojis.first() as GuildEmoji + } + return null + } + + private filterExact (search: string): (structure: GuildEmoji) => boolean { + return structure => structure.name?.toLowerCase() === search + } + + private filterInexact (search: string): (structure: GuildEmoji) => boolean { + return structure => structure.name?.toLowerCase().includes(search) ?? false + } +} diff --git a/src/argument-types/date.ts b/src/argument-types/date.ts new file mode 100644 index 00000000..629488d4 --- /dev/null +++ b/src/argument-types/date.ts @@ -0,0 +1,19 @@ +import BaseArgumentType from './base' +import { argumentUtil } from '../utils' +import { injectable } from 'inversify' + +const { validDate } = argumentUtil + +@injectable() +export default class DateArgumentType extends BaseArgumentType { + public validate (value: string): boolean { + return validDate(value) + } + + public parse (value: string): string | null { + if (!this.validate(value)) { + return null + } + return value + } +} diff --git a/src/argument-types/default-emoji.ts b/src/argument-types/default-emoji.ts new file mode 100644 index 00000000..36247a40 --- /dev/null +++ b/src/argument-types/default-emoji.ts @@ -0,0 +1,17 @@ +import BaseArgumentType from './base' +import emojiRegex from 'emoji-regex' +import { injectable } from 'inversify' + +@injectable() +export default class DefaultEmojiArgumentType extends BaseArgumentType { + public validate (value: string): boolean { + return new RegExp(`^(?:${emojiRegex().source})$`).test(value) + } + + public parse (value: string): string | null { + if (!this.validate(value)) { + return null + } + return value + } +} diff --git a/src/argument-types/group.ts b/src/argument-types/group.ts new file mode 100644 index 00000000..020075ed --- /dev/null +++ b/src/argument-types/group.ts @@ -0,0 +1,11 @@ +import { BaseStructureArgumentType } from './base' +import { Group } from '../structures' +import type { Group as GroupEntity } from '../entities' +import { injectable } from 'inversify' + +@injectable() +export default class GroupArgumentType extends BaseStructureArgumentType { + public constructor () { + super(Group) + } +} diff --git a/src/argument-types/index.ts b/src/argument-types/index.ts new file mode 100644 index 00000000..05272019 --- /dev/null +++ b/src/argument-types/index.ts @@ -0,0 +1,23 @@ +export * from './base' +export * from './roblox-user' +export { default as AlwaysArgumentType } from './always' +export { default as BaseArgumentType } from './base' +export { default as BooleanArgumentType } from './boolean' +export { default as CategoryChannelArgumentType } from './category-channel' +export { default as ChannelGroupArgumentType } from './channel-group' +export { default as CustomEmojiArgumentType } from './custom-emoji' +export { default as DateArgumentType } from './date' +export { default as DefaultEmojiArgumentType } from './default-emoji' +export { default as GroupArgumentType } from './group' +export { default as IntegerArgumentType } from './integer' +export { default as JsonObjectArgumentType } from './json-object' +export { default as MessageArgumentType } from './message' +export { default as PanelArgumentType } from './panel' +export { default as RobloxUserArgumentType } from './roblox-user' +export { default as RoleBindingArgumentType } from './role-binding' +export { default as RoleGroupArgumentType } from './role-group' +export { default as RoleMessageArgumentType } from './role-message' +export { default as TagArgumentType } from './tag' +export { default as TextChannelArgumentType } from './text-channel' +export { default as TicketTypeArgumentType } from './ticket-type' +export { default as TimeArgumentType } from './time' diff --git a/src/argument-types/integer.ts b/src/argument-types/integer.ts new file mode 100644 index 00000000..3318361c --- /dev/null +++ b/src/argument-types/integer.ts @@ -0,0 +1,16 @@ +import BaseArgumentType from './base' +import { injectable } from 'inversify' + +@injectable() +export default class IntegerArgumentType extends BaseArgumentType { + public validate (value: string): boolean { + return /^[0-9]+$/.test(value) + } + + public parse (value: string): number | null { + if (!this.validate(value)) { + return null + } + return parseInt(value) + } +} diff --git a/src/argument-types/json-object.ts b/src/argument-types/json-object.ts new file mode 100644 index 00000000..bb2373dc --- /dev/null +++ b/src/argument-types/json-object.ts @@ -0,0 +1,21 @@ +import BaseArgumentType from './base' +import { injectable } from 'inversify' + +@injectable() +export default class JsonObjectArgumentType extends BaseArgumentType { + public validate (value: string): boolean { + try { + JSON.parse(value) + } catch { + return false + } + return true + } + + public parse (value: string): object | null { + if (!this.validate(value)) { + return null + } + return JSON.parse(value) + } +} diff --git a/src/argument-types/message.ts b/src/argument-types/message.ts new file mode 100644 index 00000000..074835b0 --- /dev/null +++ b/src/argument-types/message.ts @@ -0,0 +1,51 @@ +import type { CommandInteraction, Message } from 'discord.js' +import BaseArgumentType from './base' +import { injectable } from 'inversify' + +const messageUrlRegex = /^https:\/\/discord.com\/channels\/([0-9]+|@me)\/[0-9]+\/[0-9]+$/ +const endpointUrl = 'https://discord.com/channels/' + +@injectable() +export default class MessageArgumentType extends BaseArgumentType { + public async validate (value: string, interaction: CommandInteraction): Promise { + const match = value.match(messageUrlRegex) + if (match === null) { + return false + } + const [guildId, channelId, messageId] = match[0] + .replace(endpointUrl, '') + .split('/') + const channel = guildId === interaction.guildId && interaction.inCachedGuild() + ? interaction.guild.channels.resolve(channelId) + : guildId === '@me' + ? interaction.channel + : null + if (channel === null || !channel.isText()) { + return false + } + try { + return typeof await channel.messages.fetch(messageId) !== 'undefined' + } catch { + return false + } + } + + public parse (value: string, interaction: CommandInteraction): Message | null { + const match = value.match(messageUrlRegex) + if (match === null) { + return null + } + const [guildId, channelId, messageId] = match[0] + .replace(endpointUrl, '') + .split('/') + const channel = guildId === interaction.guildId && interaction.inCachedGuild() + ? interaction.guild.channels.resolve(channelId) + : guildId === '@me' + ? interaction.channel + : null + if (channel === null || !channel.isText()) { + return null + } + return channel.messages.resolve(messageId) + } +} diff --git a/src/argument-types/panel.ts b/src/argument-types/panel.ts new file mode 100644 index 00000000..15afae03 --- /dev/null +++ b/src/argument-types/panel.ts @@ -0,0 +1,11 @@ +import { BaseStructureArgumentType } from './base' +import { Panel } from '../structures' +import type { Panel as PanelEntity } from '../entities' +import { injectable } from 'inversify' + +@injectable() +export default class PanelArgumentType extends BaseStructureArgumentType { + public constructor () { + super(Panel) + } +} diff --git a/src/argument-types/roblox-user.ts b/src/argument-types/roblox-user.ts new file mode 100644 index 00000000..09b9cc44 --- /dev/null +++ b/src/argument-types/roblox-user.ts @@ -0,0 +1,96 @@ +import type { CommandInteraction, GuildMember } from 'discord.js' +import { userService, verificationService } from '../services' +import BaseArgumentType from './base' +import { injectable } from 'inversify' + +export interface RobloxUser { id: number, username: string | null } + +@injectable() +export default class RobloxUserArgumentType extends BaseArgumentType { + private readonly cache: Map = new Map() + + public async validate ( + val: string, + interaction: CommandInteraction + ): Promise { + if (val === 'self') { + const verificationData = await verificationService.fetchVerificationData( + interaction.user.id, + interaction.guildId ?? undefined + ) + if (verificationData !== null) { + this.setCache(interaction.id, verificationData.robloxId, verificationData.robloxUsername) + return true + } + return false + } + + const match = val.match(/^(?:<@!?)?([0-9]+)>?$/) + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions + if (match !== null) { + if (interaction.inCachedGuild()) { + try { + const member = await interaction.guild.members.fetch(await interaction.client.users.fetch(match[1])) + if (!member.user.bot) { + const verificationData = await verificationService.fetchVerificationData(member.id, interaction.guildId) + if (verificationData !== null) { + this.setCache(interaction.id, verificationData.robloxId, verificationData.robloxUsername) + return true + } + } + } catch {} + } + + const id = parseInt(match[0].match(/^(\d+)$/)?.[1] ?? '') + if (!isNaN(id)) { + try { + const username = (await userService.getUser(id)).name + this.setCache(interaction.id, id, username) + return true + } catch {} + } else { + return false + } + } + + const search = val.toLowerCase() + if (interaction.inCachedGuild()) { + const members = interaction.guild.members.cache.filter(memberFilterExact(search)) + if (members.size === 1) { + const member = members.first() + if (typeof member !== 'undefined' && !member.user.bot) { + const verificationData = await verificationService.fetchVerificationData(member.id, interaction.guildId) + if (verificationData !== null) { + this.setCache(interaction.id, verificationData.robloxId, verificationData.robloxUsername) + return true + } + } + } + } + + if (!/\s/.test(val)) { + try { + const id = await userService.getIdFromUsername(search) + this.setCache(interaction.id, id, search) + return true + } catch {} + } + return false + } + + public parse (_val: string, interaction: CommandInteraction): RobloxUser | null { + const result = this.cache.get(interaction.id) + this.cache.delete(interaction.id) + return result ?? null + } + + private setCache (key: string, id: number, username: string | null): void { + this.cache.set(key, { id, username }) + } +} + +function memberFilterExact (search: string): (member: GuildMember) => boolean { + return (member: GuildMember) => member.user.username.toLowerCase() === search || + (member.nickname !== null && member.nickname.toLowerCase() === search) || + `${member.user.username.toLowerCase()}#${member.user.discriminator}` === search +} diff --git a/src/argument-types/role-binding.ts b/src/argument-types/role-binding.ts new file mode 100644 index 00000000..a9c77f6e --- /dev/null +++ b/src/argument-types/role-binding.ts @@ -0,0 +1,11 @@ +import { BaseStructureArgumentType } from './base' +import { RoleBinding } from '../structures' +import type { RoleBinding as RoleBindingEntity } from '../entities' +import { injectable } from 'inversify' + +@injectable() +export default class RoleBindingArgumentType extends BaseStructureArgumentType { + public constructor () { + super(RoleBinding) + } +} diff --git a/src/argument-types/role-group.ts b/src/argument-types/role-group.ts new file mode 100644 index 00000000..03f8b704 --- /dev/null +++ b/src/argument-types/role-group.ts @@ -0,0 +1,11 @@ +import { BaseStructureArgumentType } from './base' +import type { Group as GroupEntity } from '../entities' +import { RoleGroup } from '../structures' +import { injectable } from 'inversify' + +@injectable() +export default class RoleGroupArgumentType extends BaseStructureArgumentType { + public constructor () { + super(RoleGroup, 'groups') + } +} diff --git a/src/argument-types/role-message.ts b/src/argument-types/role-message.ts new file mode 100644 index 00000000..430f7f15 --- /dev/null +++ b/src/argument-types/role-message.ts @@ -0,0 +1,11 @@ +import { BaseStructureArgumentType } from './base' +import { RoleMessage } from '../structures' +import type { RoleMessage as RoleMessageEntity } from '../entities' +import { injectable } from 'inversify' + +@injectable() +export default class RoleMessageArgumentType extends BaseStructureArgumentType { + public constructor () { + super(RoleMessage) + } +} diff --git a/src/argument-types/tag.ts b/src/argument-types/tag.ts new file mode 100644 index 00000000..4e256812 --- /dev/null +++ b/src/argument-types/tag.ts @@ -0,0 +1,21 @@ +import { type BaseStructure, Tag } from '../structures' +import { BaseStructureArgumentType } from './base' +import type { Tag as TagEntity } from '../entities' +import { injectable } from 'inversify' + +@injectable() +export default class TagArgumentType extends BaseStructureArgumentType { + public constructor () { + super(Tag) + } + + protected override filterExact (search: string): (structure: BaseStructure) => boolean { + return structure => structure instanceof this.holds && structure.names.resolve(search) !== null + } + + protected override filterInexact (search: string): (structure: BaseStructure) => boolean { + return structure => structure instanceof this.holds && structure.names.cache.some(tagName => ( + tagName.name.toLowerCase().includes(search) + )) + } +} diff --git a/src/argument-types/text-channel.ts b/src/argument-types/text-channel.ts new file mode 100644 index 00000000..395d501b --- /dev/null +++ b/src/argument-types/text-channel.ts @@ -0,0 +1,32 @@ +import { type CommandInteraction, TextChannel } from 'discord.js' +import BaseArgumentType from './base' +import { injectable } from 'inversify' + +@injectable() +export default class TextChannelArgumentType extends BaseArgumentType { + public validate (value: string, interaction: CommandInteraction): boolean { + if (!interaction.inCachedGuild()) { + return false + } + + const match = value.match(/(\d+)/) + if (match === null) { + return false + } + const channel = interaction.guild.channels.resolve(match[0]) + return channel !== null && channel instanceof TextChannel + } + + public parse (value: string, interaction: CommandInteraction): TextChannel | null { + if (!interaction.inCachedGuild()) { + return null + } + + const match = value.match(/(\d+)/) + if (match === null) { + return null + } + const channel = interaction.guild.channels.resolve(match[0]) + return channel instanceof TextChannel ? channel : null + } +} diff --git a/src/argument-types/ticket-type.ts b/src/argument-types/ticket-type.ts new file mode 100644 index 00000000..eb30e685 --- /dev/null +++ b/src/argument-types/ticket-type.ts @@ -0,0 +1,11 @@ +import { BaseStructureArgumentType } from './base' +import { TicketType } from '../structures' +import type { TicketType as TicketTypeEntity } from '../entities' +import { injectable } from 'inversify' + +@injectable() +export default class TicketTypeArgumentType extends BaseStructureArgumentType { + public constructor () { + super(TicketType) + } +} diff --git a/src/argument-types/time.ts b/src/argument-types/time.ts new file mode 100644 index 00000000..8c0eef8c --- /dev/null +++ b/src/argument-types/time.ts @@ -0,0 +1,19 @@ +import BaseArgumentType from './base' +import { argumentUtil } from '../utils' +import { injectable } from 'inversify' + +const { validTime } = argumentUtil + +@injectable() +export default class TimeArgumentType extends BaseArgumentType { + public validate (value: string): boolean { + return validTime(value) + } + + public parse (value: string): string | null { + if (!this.validate(value)) { + return null + } + return value + } +} diff --git a/src/client/base.ts b/src/client/base.ts index 84c1f5f9..dab51b8e 100644 --- a/src/client/base.ts +++ b/src/client/base.ts @@ -1,5 +1,3 @@ -import type Client from './client' - export default interface BaseHandler { - handle: (client: Client, ...args: any[]) => void | Promise + handle (...args: any[]): void | Promise } diff --git a/src/client/client.ts b/src/client/client.ts index 5af44f73..0d6b1af8 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -1,159 +1,67 @@ -import '../extensions' // Extend Discord.js structures before the client collections get instantiated. +import { type AnyFunction, constants } from '../utils' +import type { BaseHandler, SettingProvider, WebSocketManager } from '.' import { - type APIMessage, + Client, + type ClientEvents, Constants, DiscordAPIError, - type GuildMember, + type Guild, Intents, type Message, - type MessageOptions, - type PartialGuildMember, - type Presence, - type User + type PartialTextBasedChannelFields, + type PartialTypes as PartialType, + type Presence } from 'discord.js' -import { - CommandoClient, - // Commando doesn't export these. PR a fix and uncomment this + fix - // Client.bindEvent when merged. - // type CommandoClientEvents, - type CommandoClientOptions, - type CommandoMessage, - type Inhibition -} from 'discord.js-commando' -import AroraProvider from './setting-provider' -import type BaseHandler from './base' -import { WebSocketManager } from './websocket' +import { decorate, inject, injectable, type interfaces, optional } from 'inversify' import applicationConfig from '../configs/application' -import { constants } from '../util' -import container from '../configs/container' -import getDecorators from 'inversify-inject-decorators' -import path from 'node:path' const { PartialTypes } = Constants const { TYPES } = constants -const { lazyInject } = getDecorators(container) - -const registerFilter = /^(?!base\.js|.*\.d\.|.*\.map).*/ - -const ACTIVITY_CAROUSEL_INTERVAL = 60 * 1000 - -declare module 'discord.js' { - interface Client { - mainGuild: Guild | null - - startActivityCarousel: () => Promise - stopActivityCarousel: () => void - nextActivity: (activity?: number) => Promise - send: ( - user: GuildMember | PartialGuildMember | User, - content: string | APIMessage | MessageOptions - ) => Promise - } -} - -export default class AroraClient extends CommandoClient { - @lazyInject(TYPES.PacketHandlerFactory) - public readonly packetHandlerFactory!: (eventName: string) => BaseHandler - - @lazyInject(TYPES.EventHandlerFactory) - private readonly eventHandlerFactory!: (eventName: string) => BaseHandler - - private readonly aroraWs: WebSocketManager | null - private currentActivity: number - private activityCarouselInterval: NodeJS.Timeout | null - - public constructor (options: CommandoClientOptions = {}) { - if (typeof options.commandPrefix === 'undefined') { - options.commandPrefix = applicationConfig.defaultPrefix - } - if (typeof options.owner === 'undefined') { - options.owner = applicationConfig.owner - } - if (typeof options.invite === 'undefined') { - options.invite = applicationConfig.invite - } - if (typeof options.ws === 'undefined') { - options.ws = {} - } - if (typeof options.ws.intents === 'undefined') { - options.ws.intents = [] - } - const intentsArray = options.ws.intents as number[] - if (!intentsArray.includes(Intents.FLAGS.GUILDS)) { - intentsArray.push(Intents.FLAGS.GUILDS) - } - if (!intentsArray.includes(Intents.FLAGS.GUILD_MEMBERS)) { - intentsArray.push(Intents.FLAGS.GUILD_MEMBERS) - } - if (!intentsArray.includes(Intents.FLAGS.GUILD_VOICE_STATES)) { - intentsArray.push(Intents.FLAGS.GUILD_VOICE_STATES) - } - if (!intentsArray.includes(Intents.FLAGS.GUILD_MESSAGES)) { - intentsArray.push(Intents.FLAGS.GUILD_MESSAGES) - } - if (!intentsArray.includes(Intents.FLAGS.GUILD_MESSAGE_REACTIONS)) { - intentsArray.push(Intents.FLAGS.GUILD_MESSAGE_REACTIONS) - } - if (!intentsArray.includes(Intents.FLAGS.DIRECT_MESSAGES)) { - intentsArray.push(Intents.FLAGS.DIRECT_MESSAGES) - } - if (!intentsArray.includes(Intents.FLAGS.DIRECT_MESSAGE_REACTIONS)) { - intentsArray.push(Intents.FLAGS.DIRECT_MESSAGE_REACTIONS) - } - if (typeof options.partials === 'undefined') { - options.partials = [] - } - if (!options.partials.includes(PartialTypes.GUILD_MEMBER)) { - options.partials.push(PartialTypes.GUILD_MEMBER) - } - if (!options.partials.includes(PartialTypes.REACTION)) { - options.partials.push(PartialTypes.REACTION) - } - if (!options.partials.includes(PartialTypes.MESSAGE)) { - options.partials.push(PartialTypes.MESSAGE) - } - if (!options.partials.includes(PartialTypes.USER)) { - options.partials.push(PartialTypes.USER) - } - super(options) - this.mainGuild = null +const ACTIVITY_CAROUSEL_INTERVAL = 60_000 +const REQUIRED_INTENTS: number[] = [ + Intents.FLAGS.GUILDS, + Intents.FLAGS.GUILD_MEMBERS, + Intents.FLAGS.GUILD_VOICE_STATES, + Intents.FLAGS.GUILD_MESSAGES, + Intents.FLAGS.GUILD_MESSAGE_REACTIONS, + Intents.FLAGS.DIRECT_MESSAGES, + Intents.FLAGS.DIRECT_MESSAGE_REACTIONS +] +const REQUIRED_PARTIALS: PartialType[] = [ + PartialTypes.GUILD_MEMBER, + PartialTypes.REACTION, + PartialTypes.MESSAGE, + PartialTypes.USER +] + +decorate(injectable(), Client) + +@injectable() +export default class AroraClient extends Client { + @inject(TYPES.EventHandlerFactory) + private readonly eventHandlerFactory!: interfaces.AutoNamedFactory + + @inject(TYPES.SettingProvider) + private readonly settingProvider!: SettingProvider + + @inject(TYPES.WebSocketManager) + @optional() + private readonly aroraWs?: WebSocketManager + + public mainGuild: Guild | null = null + + private currentActivity: number = 0 + private activityCarouselInterval: NodeJS.Timeout | null = null + + public constructor () { + super({ intents: REQUIRED_INTENTS, partials: REQUIRED_PARTIALS }) - this.currentActivity = 0 - this.activityCarouselInterval = null - - this.registry - .registerDefaultGroups() - .registerDefaultTypes({ message: false }) - .registerDefaultCommands({ - help: true, - prefix: true, - eval: true, - ping: true, - unknownCommand: false, - commandState: true - }) - .unregisterCommand(this.registry.resolveCommand('groups')) // returns void.. - this.registry - .registerGroup('admin', 'Admin') - .registerGroup('bot', 'Bot') - .registerGroup('main', 'Main') - .registerGroup('settings', 'Settings') - .registerTypesIn({ dirname: path.join(__dirname, '../types'), filter: registerFilter }) - .registerCommandsIn({ dirname: path.join(__dirname, '../commands'), filter: registerFilter }) - - this.dispatcher.addInhibitor(requiresApiInhibitor) - this.dispatcher.addInhibitor(requiresRobloxGroupInhibitor) - this.dispatcher.addInhibitor(requiresSingleGuildInhibitor) - - this.aroraWs = applicationConfig.apiEnabled === true ? new WebSocketManager(this) : null - - // eslint-disable-next-line @typescript-eslint/no-misused-promises this.once('ready', this.ready.bind(this)) } private async ready (): Promise { - await this.setProvider(new AroraProvider()) + await this.settingProvider.init(this) const mainGuildId = process.env.NODE_ENV === 'production' ? applicationConfig.productionMainGuildId @@ -161,17 +69,12 @@ export default class AroraClient extends CommandoClient { this.mainGuild = this.guilds.cache.get(mainGuildId) ?? null this.bindEvent('channelDelete') - this.bindEvent('commandCancel') - this.bindEvent('commandError') - this.bindEvent('commandPrefixChange') - this.bindEvent('commandRun') - this.bindEvent('commandStatusChange') this.bindEvent('emojiDelete') - this.bindEvent('groupStatusChange') this.bindEvent('guildCreate') this.bindEvent('guildMemberAdd') this.bindEvent('guildMemberUpdate') - this.bindEvent('message') + this.bindEvent('interactionCreate') + this.bindEvent('messageCreate') this.bindEvent('messageDelete') this.bindEvent('messageDeleteBulk') this.bindEvent('messageReactionAdd') @@ -179,108 +82,64 @@ export default class AroraClient extends CommandoClient { this.bindEvent('roleDelete') this.bindEvent('voiceStateUpdate') - await this.startActivityCarousel() + this.startActivityCarousel() console.log(`Ready to serve on ${this.guilds.cache.size} servers, for ${this.users.cache.size} users.`) } - // @ts-expect-error - public override async startActivityCarousel (): Promise { + public startActivityCarousel (): Presence | null { if (this.activityCarouselInterval === null) { - // eslint-disable-next-line @typescript-eslint/no-misused-promises - this.activityCarouselInterval = this.setInterval(this.nextActivity.bind(this), ACTIVITY_CAROUSEL_INTERVAL) - return await this.nextActivity(0) + this.activityCarouselInterval = setInterval(this.nextActivity.bind(this), ACTIVITY_CAROUSEL_INTERVAL).unref() + return this.nextActivity(0) } return null } - // @ts-expect-error - public override stopActivityCarousel (): void { + public stopActivityCarousel (): void { if (this.activityCarouselInterval !== null) { - this.clearInterval(this.activityCarouselInterval) + clearInterval(this.activityCarouselInterval) this.activityCarouselInterval = null } } - // @ts-expect-error - public override async nextActivity (activity?: number): Promise { + public nextActivity (activity?: number): Presence { if (this.user === null) { throw new Error('Can\'t set activity when the client is not logged in.') } - this.currentActivity = (activity ?? this.currentActivity + 1) % 2 + this.currentActivity = (activity ?? this.currentActivity + 1) % 1 switch (this.currentActivity) { - case 0: { + default: { let totalMemberCount = 0 for (const guild of this.guilds.cache.values()) { totalMemberCount += guild.memberCount } - return await this.user.setActivity(`${totalMemberCount} users`, { type: 'WATCHING' }) + return this.user.setActivity(`${totalMemberCount} users`, { type: 'WATCHING' }) } - default: - return await this.user.setActivity(`${this.commandPrefix}help`, { type: 'LISTENING' }) } } - // @ts-expect-error - public override async send ( - user: GuildMember | PartialGuildMember | User, - content: string | APIMessage | MessageOptions - ): Promise { - return await failSilently(user.send.bind(user, content), [50007]) + public async send ( + user: PartialTextBasedChannelFields, + ...args: Parameters + ): Promise { + return await failSilently(user.send.bind(user, ...args), [50007]) // 50007: Cannot send messages to this user, user probably has DMs closed. } - public override async login (token = this.token): Promise { - const usedToken = await super.login(token ?? undefined) + public override async login (token?: string): Promise { + const usedToken = await super.login(token) this.aroraWs?.connect() return usedToken } - // See comment in discord.js-commando imports. - // private bindEvent (eventName: CommandoClientEvents): void { - private bindEvent (eventName: string): void { + private bindEvent (eventName: keyof ClientEvents): void { const handler = this.eventHandlerFactory(eventName) - // @ts-expect-error FIXME: eventName keyof ClientEvents & async listener - // eslint-disable-next-line @typescript-eslint/no-misused-promises - this.on(eventName, handler.handle.bind(handler, this)) - } -} - -function requiresApiInhibitor (msg: CommandoMessage): false | Inhibition { - if (msg.command?.requiresApi === true && applicationConfig.apiEnabled !== true) { - return { - reason: 'apiRequired', - response: msg.reply('This command requires that the bot has an API connected.') - } - } - return false -} - -function requiresRobloxGroupInhibitor (msg: CommandoMessage): false | Inhibition { - if (msg.command?.requiresRobloxGroup === true && (msg.guild === null || msg.guild.robloxGroupId === null)) { - return { - reason: 'robloxGroupRequired', - response: msg.reply('This command requires that the server has its robloxGroup setting set.') - } - } - return false -} - -function requiresSingleGuildInhibitor (msg: CommandoMessage): false | Inhibition { - if (msg.command?.requiresSingleGuild === true && msg.client.guilds.cache.size !== 1) { - return { - reason: 'singleGuildRequired', - response: msg.reply('This command requires the bot to be in only one guild.') - } + this.on(eventName, handler.handle.bind(handler)) } - return false } -async function failSilently ( - fn: ((...args: any[]) => any | Promise), - codes: number[] -): Promise { +async function failSilently (fn: T, codes: number[]): Promise | null> { try { return await Promise.resolve(fn()) } catch (err) { diff --git a/src/client/dispatcher.ts b/src/client/dispatcher.ts new file mode 100644 index 00000000..6256e63a --- /dev/null +++ b/src/client/dispatcher.ts @@ -0,0 +1,158 @@ +import { + type Argument, + type BaseCommand, + Command, + SubCommandCommand +} from '../interactions/application-commands' +import type { CommandInteraction, CommandInteractionOption, Interaction } from 'discord.js' +import { inject, injectable, type interfaces, named } from 'inversify' +import type { GuildContextManager } from '../managers' +import applicationConfig from '../configs/application' +import { constants } from '../utils' + +const { TYPES } = constants + +@injectable() +export default class Dispatcher { + @inject(TYPES.CommandFactory) + private readonly commandFactory!: interfaces.AutoNamedFactory + + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + public async handleInteraction (interaction: Interaction): Promise { + if (interaction.isCommand()) { + return await this.handleCommandInteraction(interaction) + } + } + + private async handleCommandInteraction (interaction: CommandInteraction): Promise { + const command = this.commandFactory(interaction.commandName) + if (typeof command === 'undefined') { + throw new Error(`Unknown command "${interaction.commandName}".`) + } + + let error + if (command.options.requiresApi === true && applicationConfig.apiEnabled !== true) { + error = 'This command requires that the bot has an API connected.' + } + if ( + command.options.requiresRobloxGroup === true && (interaction.guildId === null || + this.guildContexts.resolve(interaction.guildId)?.robloxGroupId === null) + ) { + error = 'This command requires that the server has its robloxGroup setting set.' + } + if (command.options.requiresSingleGuild === true && interaction.client.guilds.cache.size !== 1) { + error = 'This command requires the bot to be in only one guild.' + } + if (command.options.ownerOwnly === true) { + if (interaction.client.application?.owner === null) { + await interaction.client.application?.fetch() + } + if (interaction.user.id !== interaction.client.application?.owner?.id) { + error = 'This command can only be run by the application owner.' + } + } + if (typeof error !== 'undefined') { + return await interaction.reply({ content: error, ephemeral: true }) + } + + let subCommandGroupName, subCommandName, subCommandArgs + if (command instanceof SubCommandCommand) { + subCommandGroupName = interaction.options.getSubcommandGroup(false) + subCommandName = interaction.options.getSubcommand(false) + if (subCommandName !== null) { + subCommandArgs = subCommandGroupName !== null + ? command.args[subCommandGroupName]?.[subCommandName] + : command.args[subCommandName] + } else { + throw new Error(`Unknown subcommand "${subCommandName ?? 'unknown'}".`) + } + } else if (command instanceof Command) { + subCommandName = interaction.commandName + subCommandArgs = command.args + } else { + throw new Error('Invalid command.') + } + + const args = typeof subCommandArgs !== 'undefined' + ? await this.parseArgs(interaction, subCommandArgs as Record>) + : {} + + return command instanceof Command + ? await command.execute(interaction, args) + : subCommandGroupName == null + ? await command.execute(interaction, subCommandName, args) + : await command.execute(interaction, subCommandGroupName, subCommandName, args) + } + + private async parseArgs ( + interaction: CommandInteraction, + args: Record> + ): Promise> { + const result: Record = {} + for (const [key, arg] of Object.entries(args)) { + const option = interaction.options.get(arg.key, arg.required ?? true) + if (option === null && typeof arg.default === 'undefined') { + result[key] = null + continue + } + + let val = option !== null + ? Dispatcher.getCommandInteractionOptionValue(option) + : typeof arg.default === 'string' ? arg.default : (arg.default as Function)(interaction, this.guildContexts) + if (typeof val !== 'string') { + result[key] = val + continue + } + + if (arg.validate !== null) { + const valid = await arg.validate(val, interaction, arg) + if (valid === false) { + throw new Error(`Invalid ${arg.key}`) + } else if (typeof valid === 'string') { + throw new Error(valid) + } + } + + if (arg.parse !== null) { + val = await arg.parse(val, interaction, arg) + } + + result[key] = val + } + return result + } + + private static getCommandInteractionOptionValue ( + option: CommandInteractionOption + ): Exclude< + | CommandInteractionOption['value'] + | CommandInteractionOption['user'] + | CommandInteractionOption['member'] + | CommandInteractionOption['channel'] + | CommandInteractionOption['role'] + | CommandInteractionOption['attachment'], + undefined + > { + switch (option.type) { + case 'SUB_COMMAND': + case 'SUB_COMMAND_GROUP': + case 'STRING': + case 'INTEGER': + case 'BOOLEAN': return option.value ?? null + // Discord.js resolves the user to a member too. The dispatcher will + // always send the member as option value to the commands since most + // commands require the member anyway, and it's easier to get the user + // from a member than vice versa. + case 'USER': return option.member ?? null + case 'CHANNEL': return option.channel ?? null + case 'ROLE': return option.role ?? null + case 'MENTIONABLE': return option.member ?? option.user ?? option.role ?? null + case 'ATTACHMENT': return option.attachment ?? null + case 'NUMBER': return option.value ?? null + default: return null + } + } +} diff --git a/src/client/events/channel-delete.ts b/src/client/events/channel-delete.ts index 2c3a556a..66f92cf5 100644 --- a/src/client/events/channel-delete.ts +++ b/src/client/events/channel-delete.ts @@ -1,10 +1,9 @@ import { inject, injectable } from 'inversify' -import type BaseHandler from '../base' +import type { BaseHandler } from '..' import type { Channel } from '../../entities' -import type Client from '../client' import type { Channel as DiscordChannel } from 'discord.js' import { Repository } from 'typeorm' -import { constants } from '../../util' +import { constants } from '../../utils' const { TYPES } = constants @@ -13,7 +12,7 @@ export default class ChannelDeleteEventHandler implements BaseHandler { @inject(TYPES.ChannelRepository) private readonly channelRepository!: Repository - public async handle (_client: Client, channel: DiscordChannel): Promise { + public async handle (channel: DiscordChannel): Promise { await this.channelRepository.delete(channel.id) } } diff --git a/src/client/events/command-cancel.ts b/src/client/events/command-cancel.ts deleted file mode 100644 index 803b3679..00000000 --- a/src/client/events/command-cancel.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { ArgumentCollectorResult, Command, CommandoMessage } from 'discord.js-commando' -import type BaseHandler from '../base' -import type Client from '../client' -import { DMChannel } from 'discord.js' -import { injectable } from 'inversify' - -@injectable() -export default class CommandCancelEventHandler implements BaseHandler { - public async handle ( - _client: Client, - _command: Command, - _reason: string, - message: CommandoMessage, - result: ArgumentCollectorResult | null - ): Promise { - if (!(message.channel instanceof DMChannel)) { - try { - await message.channel.bulkDelete([...(result?.prompts ?? []), ...(result?.answers ?? [])]) - } catch {} - } - } -} diff --git a/src/client/events/command-error.ts b/src/client/events/command-error.ts deleted file mode 100644 index 47c9cdce..00000000 --- a/src/client/events/command-error.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { ArgumentCollectorResult, Command, CommandoMessage } from 'discord.js-commando' -import type BaseHandler from '../base' -import type Client from '../client' -import { DMChannel } from 'discord.js' -import axios from 'axios' -import { injectable } from 'inversify' - -@injectable() -export default class CommandErrorEventHandler implements BaseHandler { - public async handle ( - _client: Client, - _command: Command, - err: Error, - message: CommandoMessage, - _args: Object | string | string[], - _fromPattern: boolean, - result: ArgumentCollectorResult | null - ): Promise { - if (axios.isAxiosError(err) && err.response?.data.errors?.length > 0) { - await message.reply(err.response?.data.errors[0].message ?? err.response?.data.errors[0].msg) - } else { - await message.reply(err.message) - } - - if (!(message.channel instanceof DMChannel)) { - try { - await message.channel.bulkDelete([...(result?.prompts ?? []), ...(result?.answers ?? [])]) - } catch {} - } - } -} diff --git a/src/client/events/command-prefix-change.ts b/src/client/events/command-prefix-change.ts deleted file mode 100644 index 0ac4d094..00000000 --- a/src/client/events/command-prefix-change.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type BaseHandler from '../base' -import type Client from '../client' -import type { CommandoGuild } from 'discord.js-commando' -import { injectable } from 'inversify' - -@injectable() -export default class CommandPrefixChangeEventHandler implements BaseHandler { - public async handle (client: Client, guild: CommandoGuild, prefix: string): Promise { - await client.provider.onCommandPrefixChange(guild, prefix) - } -} diff --git a/src/client/events/command-run.ts b/src/client/events/command-run.ts deleted file mode 100644 index c22be477..00000000 --- a/src/client/events/command-run.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { ArgumentCollectorResult, Command, CommandoMessage } from 'discord.js-commando' -import type BaseHandler from '../base' -import type Client from '../client' -import { DMChannel } from 'discord.js' -import { injectable } from 'inversify' -import { stripIndents } from 'common-tags' - -@injectable() -export default class CommandRunEventHandler implements BaseHandler { - public async handle ( - client: Client, - command: Command, - promise: Promise, - message: CommandoMessage, - _args: Object | string | string[], - _fromPattern: boolean, - result: ArgumentCollectorResult | null - ): Promise { - try { - await promise - } catch { - // Command execution errors are handled by the commandError event handler. - return - } - - if (!(message.channel instanceof DMChannel)) { - try { - await message.channel.bulkDelete([...(result?.prompts ?? []), ...(result?.answers ?? [])]) - } catch {} - } - - const guild = message.guild ?? client.mainGuild - await guild.log( - message.author, - stripIndents` - ${message.author} **used** \`${command.name}\` **command in** ${message.channel} ${message.channel.type !== 'dm' ? `[Jump to Message](${message.url})` : ''} - ${message.content} - ` - ) - } -} diff --git a/src/client/events/command-status-change.ts b/src/client/events/command-status-change.ts deleted file mode 100644 index d8a5fc70..00000000 --- a/src/client/events/command-status-change.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Command, CommandoGuild } from 'discord.js-commando' -import type BaseHandler from '../base' -import type Client from '../client' -import { injectable } from 'inversify' - -@injectable() -export default class CommandStatusChangeEventHandler implements BaseHandler { - public async handle ( - client: Client, - guild: CommandoGuild, - command: Command, - enabled: boolean - ): Promise { - await client.provider.onCommandStatusChange(guild, command, enabled) - } -} diff --git a/src/client/events/emoji-delete.ts b/src/client/events/emoji-delete.ts index 897dcc3a..f68b7187 100644 --- a/src/client/events/emoji-delete.ts +++ b/src/client/events/emoji-delete.ts @@ -1,10 +1,9 @@ import { inject, injectable } from 'inversify' -import type BaseHandler from '../base' -import type Client from '../client' +import type { BaseHandler } from '..' import type { Emoji } from '../../entities' import type { GuildEmoji } from 'discord.js' import { Repository } from 'typeorm' -import { constants } from '../../util' +import { constants } from '../../utils' const { TYPES } = constants @@ -13,7 +12,7 @@ export default class EmojiDeleteEventHandler implements BaseHandler { @inject(TYPES.EmojiRepository) private readonly emojiRepository!: Repository - public async handle (_client: Client, emoji: GuildEmoji): Promise { + public async handle (emoji: GuildEmoji): Promise { await this.emojiRepository.delete(emoji.id) } } diff --git a/src/client/events/group-status-change.ts b/src/client/events/group-status-change.ts deleted file mode 100644 index c288debb..00000000 --- a/src/client/events/group-status-change.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { CommandGroup, CommandoGuild } from 'discord.js-commando' -import type BaseHandler from '../base' -import type Client from '../client' -import { injectable } from 'inversify' - -@injectable() -export default class GroupStatusChangeEventHandler implements BaseHandler { - public async handle ( - client: Client, - guild: CommandoGuild, - group: CommandGroup, - enabled: boolean - ): Promise { - await client.provider.onCommandStatusChange(guild, group, enabled) - } -} diff --git a/src/client/events/guild-create.ts b/src/client/events/guild-create.ts index 1c2bcdec..c878c60e 100644 --- a/src/client/events/guild-create.ts +++ b/src/client/events/guild-create.ts @@ -1,11 +1,17 @@ -import type BaseHandler from '../base' -import type Client from '../client' +import { inject, injectable } from 'inversify' +import type { BaseHandler } from '..' import type { Guild } from 'discord.js' -import { injectable } from 'inversify' +import type SettingProvider from '../setting-provider' +import { constants } from '../../utils' + +const { TYPES } = constants @injectable() export default class GuildCreateEventHandler implements BaseHandler { - public async handle (client: Client, guild: Guild): Promise { - await client.provider.setupGuild(guild.id) + @inject(TYPES.SettingProvider) + private readonly settingProvider!: SettingProvider + + public async handle (guild: Guild): Promise { + await this.settingProvider.setupGuild(guild) } } diff --git a/src/client/events/guild-member-add.ts b/src/client/events/guild-member-add.ts index db54ba23..dc498c4a 100644 --- a/src/client/events/guild-member-add.ts +++ b/src/client/events/guild-member-add.ts @@ -1,21 +1,32 @@ import { type GuildMember, MessageEmbed } from 'discord.js' -import type BaseHandler from '../base' -import type Client from '../client' +import { constants, util } from '../../utils' +import { inject, injectable, named } from 'inversify' +import type { BaseHandler } from '..' +import type { GuildContext } from '../../structures' +import type { GuildContextManager } from '../../managers' +import type { PersistentRoleService } from '../../services' import applicationConfig from '../../configs/application' -import { injectable } from 'inversify' -import { util } from '../../util' +const { TYPES } = constants const { getOrdinalNum } = util @injectable() export default class GuildMemberAddEventHandler implements BaseHandler { - public async handle (_client: Client, member: GuildMember): Promise { + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + @inject(TYPES.PersistentRoleService) + private readonly persistentRoleService!: PersistentRoleService + + public async handle (member: GuildMember): Promise { if (member.user.bot) { return } + const context = this.guildContexts.resolve(member.guild) as GuildContext const guild = member.guild - const welcomeChannelsGroup = guild.groups.resolve('welcomeChannels') + const welcomeChannelsGroup = context.groups.resolve('welcomeChannels') // eslint-disable-next-line @typescript-eslint/prefer-optional-chain if (welcomeChannelsGroup !== null && welcomeChannelsGroup.isChannelGroup() && welcomeChannelsGroup.channels.cache.size > 0) { @@ -23,11 +34,13 @@ export default class GuildMemberAddEventHandler implements BaseHandler { .setTitle(`Hey ${member.user.tag},`) .setDescription(`You're the **${getOrdinalNum(guild.memberCount)}** member on **${guild.name}**!`) .setThumbnail(member.user.displayAvatarURL()) - .setColor(guild.primaryColor ?? applicationConfig.defaultColor) - await Promise.all(welcomeChannelsGroup.channels.cache.map(async channel => await channel.send(embed))) + .setColor(context.primaryColor ?? applicationConfig.defaultColor) + await Promise.all(welcomeChannelsGroup.channels.cache.map(async channel => ( + await channel.send({ embeds: [embed] })) + )) } - const persistentRoles = await member.fetchPersistentRoles() + const persistentRoles = await this.persistentRoleService.fetchPersistentRoles(member) if (persistentRoles.size > 0) { await member.roles.add(persistentRoles) } diff --git a/src/client/events/guild-member-update.ts b/src/client/events/guild-member-update.ts index 9deacbec..f7b0a0cb 100644 --- a/src/client/events/guild-member-update.ts +++ b/src/client/events/guild-member-update.ts @@ -1,20 +1,26 @@ import type { GuildMember, Role } from 'discord.js' -import type BaseHandler from '../base' -import type Client from '../client' -import { injectable } from 'inversify' +import { inject, injectable } from 'inversify' +import type { BaseHandler } from '..' +import type { PersistentRoleService } from '../../services' +import { constants } from '../../utils' + +const { TYPES } = constants @injectable() export default class GuildMemberUpdateEventHandler implements BaseHandler { - public async handle (_client: Client, oldMember: GuildMember, newMember: GuildMember): Promise { + @inject(TYPES.PersistentRoleService) + private readonly persistentRoleService!: PersistentRoleService + + public async handle (oldMember: GuildMember, newMember: GuildMember): Promise { if (newMember.user.bot) { return } if (oldMember.roles.cache.size > newMember.roles.cache.size) { const removedRole = oldMember.roles.cache.find(role => !newMember.roles.cache.has(role.id)) as Role - const persistentRoles = await newMember.fetchPersistentRoles() + const persistentRoles = await this.persistentRoleService.fetchPersistentRoles(newMember) if (persistentRoles.has(removedRole.id)) { - await newMember.unpersistRole(removedRole) + await this.persistentRoleService.unpersistRole(newMember, removedRole) } } } diff --git a/src/client/events/index.ts b/src/client/events/index.ts index cde94e17..cbd58358 100644 --- a/src/client/events/index.ts +++ b/src/client/events/index.ts @@ -1,15 +1,10 @@ export { default as ChannelDeleteEventHandler } from './channel-delete' -export { default as CommandCancelEventHandler } from './command-cancel' -export { default as CommandErrorEventHandler } from './command-error' -export { default as CommandPrefixChangeEventHandler } from './command-prefix-change' -export { default as CommandRunEventHandler } from './command-run' -export { default as CommandStatusChangeEventHandler } from './command-status-change' export { default as EmojiDeleteEventHandler } from './emoji-delete' -export { default as GroupStatusChangeEventHandler } from './group-status-change' export { default as GuildCreateEventHandler } from './guild-create' export { default as GuildMemberAddEventHandler } from './guild-member-add' export { default as GuildMemberUpdateEventHandler } from './guild-member-update' -export { default as MessageEventHandler } from './message' +export { default as InteractionCreateEventHandler } from './interaction-create' +export { default as MessageCreateEventHandler } from './message-create' export { default as MessageDeleteEventHandler } from './message-delete' export { default as MessageDeleteBulkEventHandler } from './message-delete-bulk' export { default as MessageReactionAddEventHandler } from './message-reaction-add' diff --git a/src/client/events/interaction-create.ts b/src/client/events/interaction-create.ts new file mode 100644 index 00000000..9df446bc --- /dev/null +++ b/src/client/events/interaction-create.ts @@ -0,0 +1,42 @@ +import type { BaseHandler, Dispatcher } from '..' +import type { Interaction, TextChannel } from 'discord.js' +import { inject, injectable, named } from 'inversify' +import type { GuildContext } from '../../structures' +import type { GuildContextManager } from '../../managers' +import { constants } from '../../utils' + +const { TYPES } = constants + +@injectable() +export default class InteractionCreateEventHandler implements BaseHandler { + @inject(TYPES.Dispatcher) + private readonly dispatcher!: Dispatcher + + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + public async handle (interaction: Interaction): Promise { + try { + await this.dispatcher.handleInteraction(interaction) + + if (interaction.isCommand() && interaction.inGuild()) { + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + const subCommandName = interaction.options.getSubcommand(false) + await context.log( + interaction.user, + // eslint-disable-next-line @typescript-eslint/no-base-to-string + `${interaction.user.toString()} **used command** \`/${interaction.commandName}${subCommandName !== null ? ` ${subCommandName}` : ''}\` **in** ${(interaction.channel as TextChannel).toString()}` + ) + } + } catch (err: any) { + if (interaction.isRepliable() && !interaction.replied) { + return await interaction.reply({ + content: err.toString(), + ephemeral: true + }) + } + throw err + } + } +} diff --git a/src/client/events/message-create.ts b/src/client/events/message-create.ts new file mode 100644 index 00000000..6f6752f3 --- /dev/null +++ b/src/client/events/message-create.ts @@ -0,0 +1,82 @@ +import { ApplicationCommandData, type Message, TextChannel } from 'discord.js' +import type { AroraClient, BaseHandler } from '..' +import { inject, injectable, named } from 'inversify' +import type { GuildContext } from '../../structures' +import type { GuildContextManager } from '../../managers' +import { constants } from '../../utils' +import { stripIndents } from 'common-tags' + +const { TYPES } = constants + +@injectable() +export default class MessageEventHandler implements BaseHandler { + @inject(TYPES.Client) + private readonly client!: AroraClient + + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + public async handle (message: Message): Promise { + if (message.author.bot) { + return + } + const guild = message.guild + if (guild === null) { + return + } + const context = this.guildContexts.resolve(guild) as GuildContext + + if ((process.env.NODE_ENV ?? 'development') === 'development') { + if (this.client.application?.owner === null) { + await this.client.application?.fetch() + } + if (message.content.toLowerCase() === '!deploy' && message.author.id === this.client.application?.owner?.id) { + const applicationCommands = await import('../../interactions/data/application-commands') + await guild.commands.set(Object.values(applicationCommands) as ApplicationCommandData[]) + await this.client.send(message.channel, 'Successfully deployed commands.') + } + } + + const photoContestChannelsGroup = context.groups.resolve('photoContestChannels') + if ( + // eslint-disable-next-line @typescript-eslint/prefer-optional-chain + photoContestChannelsGroup !== null && photoContestChannelsGroup.isChannelGroup() && + photoContestChannelsGroup.channels.cache.has(message.channel.id) + ) { + if (message.attachments.size > 0 || message.embeds.length > 0) { + await message.react('👍') + } + } + + const noTextChannelsGroup = context.groups.resolve('noTextChannels') + if ( + // eslint-disable-next-line @typescript-eslint/prefer-optional-chain + noTextChannelsGroup !== null && noTextChannelsGroup.isChannelGroup() && + noTextChannelsGroup.channels.cache.has(message.channel.id) + ) { + if (message.attachments.size === 0 && message.embeds.length === 0) { + const canTalkInNoTextChannelsGroup = context.groups.resolve('canTalkInNoTextChannels') + if ( + canTalkInNoTextChannelsGroup === null || (canTalkInNoTextChannelsGroup.isRoleGroup() && + message.member?.roles.cache.some(role => canTalkInNoTextChannelsGroup.roles.cache.has(role.id)) === false) + ) { + try { + await message.delete() + await context.log( + message.author, + stripIndents` + **Message sent by ${message.author} deleted in ${message.channel}** + ${message.content} + ` + ) + } catch {} + } + } + } + + if (message.channel instanceof TextChannel) { + await context.tickets.resolve(message.channel)?.onMessage(message) + } + } +} diff --git a/src/client/events/message-delete-bulk.ts b/src/client/events/message-delete-bulk.ts index 2d255f65..f36331a3 100644 --- a/src/client/events/message-delete-bulk.ts +++ b/src/client/events/message-delete-bulk.ts @@ -1,10 +1,9 @@ import { inject, injectable } from 'inversify' -import type BaseHandler from '../base' -import type Client from '../client' -import type { CommandoMessage } from 'discord.js-commando' +import type { BaseHandler } from '..' +import type { Message as DiscordMessage } from 'discord.js' import type { Message } from '../../entities' import { Repository } from 'typeorm' -import { constants } from '../../util' +import { constants } from '../../utils' const { TYPES } = constants @@ -13,7 +12,7 @@ export default class MessageDeleteBulkEventHandler implements BaseHandler { @inject(TYPES.MessageRepository) private readonly messageRepository!: Repository - public async handle (_client: Client, messages: CommandoMessage[]): Promise { + public async handle (messages: DiscordMessage[]): Promise { await this.messageRepository.delete(messages.map(message => message.id)) } } diff --git a/src/client/events/message-delete.ts b/src/client/events/message-delete.ts index ffd40446..1a3efbf5 100644 --- a/src/client/events/message-delete.ts +++ b/src/client/events/message-delete.ts @@ -1,10 +1,9 @@ import { inject, injectable } from 'inversify' -import type BaseHandler from '../base' -import type Client from '../client' -import type { CommandoMessage } from 'discord.js-commando' +import type { BaseHandler } from '..' +import type { Message as DiscordMessage } from 'discord.js' import type { Message } from '../../entities' import { Repository } from 'typeorm' -import { constants } from '../../util' +import { constants } from '../../utils' const { TYPES } = constants @@ -13,7 +12,7 @@ export default class MessageDeleteEventHandler implements BaseHandler { @inject(TYPES.MessageRepository) private readonly messageRepository!: Repository - public async handle (_client: Client, message: CommandoMessage): Promise { + public async handle (message: DiscordMessage): Promise { await this.messageRepository.delete(message.id) } } diff --git a/src/client/events/message-reaction-add.ts b/src/client/events/message-reaction-add.ts index 611381f2..9681ab5a 100644 --- a/src/client/events/message-reaction-add.ts +++ b/src/client/events/message-reaction-add.ts @@ -1,11 +1,19 @@ import type { MessageReaction, User } from 'discord.js' -import type BaseHandler from '../base' -import type Client from '../client' -import { injectable } from 'inversify' +import { inject, injectable, named } from 'inversify' +import type { BaseHandler } from '..' +import type { GuildContext } from '../../structures' +import type { GuildContextManager } from '../../managers' +import { constants } from '../../utils' + +const { TYPES } = constants @injectable() export default class MessageReactionAddEventHandler implements BaseHandler { - public async handle (_client: Client, reaction: MessageReaction, user: User): Promise { + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + public async handle (reaction: MessageReaction, user: User): Promise { if (user.bot) { return } @@ -15,10 +23,11 @@ export default class MessageReactionAddEventHandler implements BaseHandler { if (reaction.message.guild === null) { return } + const context = this.guildContexts.resolve(reaction.message.guild) as GuildContext await Promise.all([ - reaction.message.guild.handleRoleMessage('add', reaction, user), - reaction.message.guild.tickets.onMessageReactionAdd(reaction, user) + context.handleRoleMessage('add', reaction, user), + context.tickets.onMessageReactionAdd(reaction, user) ]) } } diff --git a/src/client/events/message-reaction-remove.ts b/src/client/events/message-reaction-remove.ts index 45ef665f..b00edb18 100644 --- a/src/client/events/message-reaction-remove.ts +++ b/src/client/events/message-reaction-remove.ts @@ -1,11 +1,19 @@ import type { MessageReaction, User } from 'discord.js' -import type BaseHandler from '../base' -import type Client from '../client' -import { injectable } from 'inversify' +import { inject, injectable, named } from 'inversify' +import type { BaseHandler } from '..' +import type { GuildContext } from '../../structures' +import type { GuildContextManager } from '../../managers' +import { constants } from '../../utils' + +const { TYPES } = constants @injectable() export default class MessageReactionRemoveEventHandler implements BaseHandler { - public async handle (_client: Client, reaction: MessageReaction, user: User): Promise { + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + public async handle (reaction: MessageReaction, user: User): Promise { if (user.bot) { return } @@ -15,7 +23,8 @@ export default class MessageReactionRemoveEventHandler implements BaseHandler { if (reaction.message.guild === null) { return } + const context = this.guildContexts.resolve(reaction.message.guild) as GuildContext - await reaction.message.guild.handleRoleMessage('remove', reaction, user) + await context.handleRoleMessage('remove', reaction, user) } } diff --git a/src/client/events/message.ts b/src/client/events/message.ts deleted file mode 100644 index 373a7723..00000000 --- a/src/client/events/message.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { MessageEmbed, TextChannel } from 'discord.js' -import type BaseHandler from '../base' -import type Client from '../client' -import type { CommandoMessage } from 'discord.js-commando' -import { injectable } from 'inversify' -import { stripIndents } from 'common-tags' - -@injectable() -export default class MessageEventHandler implements BaseHandler { - public async handle (client: Client, message: CommandoMessage): Promise { - if (message.author.bot) { - return - } - const guild = message.guild - if (guild === null) { - return - } - - if (message.content.startsWith(guild.commandPrefix)) { - const tagsCommand = client.registry.resolveCommand('tags') - if (tagsCommand.isEnabledIn(guild) && tagsCommand.hasPermission(message) === true) { - const name = message.content.slice(guild.commandPrefix.length) - const tag = guild.tags.resolve(name) - - if (tag !== null) { - await message.reply(tag.content, tag.content instanceof MessageEmbed - ? {} - : { allowedMentions: { users: [message.author.id] } }) - } - } - } - - const photoContestChannelsGroup = guild.groups.resolve('photoContestChannels') - // eslint-disable-next-line @typescript-eslint/prefer-optional-chain - if (photoContestChannelsGroup !== null && photoContestChannelsGroup.isChannelGroup() && - photoContestChannelsGroup.channels.cache.has(message.channel.id)) { - if (message.attachments.size > 0 || message.embeds.length > 0) { - await message.react('👍') - } - } - - const noTextChannelsGroup = guild.groups.resolve('noTextChannels') - // eslint-disable-next-line @typescript-eslint/prefer-optional-chain - if (noTextChannelsGroup !== null && noTextChannelsGroup.isChannelGroup() && - noTextChannelsGroup.channels.cache.has(message.channel.id)) { - if (message.attachments.size === 0 && message.embeds.length === 0) { - const canTalkInNoTextChannelsGroup = guild.groups.resolve('canTalkInNoTextChannels') - // eslint-disable-next-line @typescript-eslint/prefer-optional-chain - if (canTalkInNoTextChannelsGroup === null || (canTalkInNoTextChannelsGroup.isRoleGroup() && - message.member?.roles.cache.some(role => canTalkInNoTextChannelsGroup.roles.cache.has(role.id)) === false)) { - try { - await message.delete() - } catch {} - if (message.deleted) { - await message.guild.log( - message.author, - stripIndents` - **Message sent by ${message.author} deleted in ${message.channel}** - ${message.content} - ` - ) - } - } - } - } - - if (message.channel instanceof TextChannel) { - await guild.tickets.resolve(message.channel)?.onMessage(message) - } - } -} diff --git a/src/client/events/role-delete.ts b/src/client/events/role-delete.ts index 878fe141..d4b7e91b 100644 --- a/src/client/events/role-delete.ts +++ b/src/client/events/role-delete.ts @@ -1,10 +1,9 @@ import { inject, injectable } from 'inversify' -import type BaseHandler from '../base' -import type Client from '../client' +import type { BaseHandler } from '..' import type { Role as DiscordRole } from 'discord.js' import { Repository } from 'typeorm' import type { Role } from '../../entities' -import { constants } from '../../util' +import { constants } from '../../utils' const { TYPES } = constants @@ -13,7 +12,7 @@ export default class RoleDeleteEventHandler implements BaseHandler { @inject(TYPES.RoleRepository) private readonly roleRepository!: Repository - public async handle (_client: Client, role: DiscordRole): Promise { + public async handle (role: DiscordRole): Promise { await this.roleRepository.delete(role.id) } } diff --git a/src/client/events/voice-state-update.ts b/src/client/events/voice-state-update.ts index 580183cc..852a2988 100644 --- a/src/client/events/voice-state-update.ts +++ b/src/client/events/voice-state-update.ts @@ -1,31 +1,37 @@ -import type BaseHandler from '../base' -import type Client from '../client' +import { inject, injectable } from 'inversify' +import type { BaseHandler } from '..' +import type { ChannelLinkService } from '../../services' import type { VoiceState } from 'discord.js' -import { injectable } from 'inversify' +import { constants } from '../../utils' + +const { TYPES } = constants @injectable() export default class VoiceStateUpdateEventHandler implements BaseHandler { - public async handle (_client: Client, oldState: VoiceState, newState: VoiceState): Promise { - if (oldState.channelID !== newState.channelID) { + @inject(TYPES.ChannelLinkService) + private readonly channelLinkService!: ChannelLinkService + + public async handle (oldState: VoiceState, newState: VoiceState): Promise { + if (oldState.channelId !== newState.channelId) { const member = newState.member if (member === null) { return } if (oldState.channel !== null) { - const toLinks = await oldState.channel.fetchToLinks() + const toLinks = await this.channelLinkService.fetchToLinks(oldState.channel) await Promise.all(toLinks.map(async channel => { try { - await channel.permissionOverwrites.get(member.id)?.delete() + await channel.permissionOverwrites.cache.get(member.id)?.delete() } catch {} })) } if (newState.channel !== null) { - const toLinks = await newState.channel.fetchToLinks() + const toLinks = await this.channelLinkService.fetchToLinks(newState.channel) await Promise.all(toLinks.map(async channel => { try { - await channel.updateOverwrite(member, { + await channel.permissionOverwrites.edit(member, { VIEW_CHANNEL: true, SEND_MESSAGES: true }) diff --git a/src/client/index.ts b/src/client/index.ts index afaedbaa..9a44f08a 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -2,3 +2,5 @@ export * from './websocket' export * as eventHandlers from './events' export type { default as BaseHandler } from './base' export { default as AroraClient } from './client' +export { default as Dispatcher } from './dispatcher' +export { default as SettingProvider } from './setting-provider' diff --git a/src/client/setting-provider.ts b/src/client/setting-provider.ts index cac3455a..00a653c8 100644 --- a/src/client/setting-provider.ts +++ b/src/client/setting-provider.ts @@ -1,96 +1,52 @@ -import { - type Command, - type CommandGroup, - type CommandoClient, - type CommandoGuild, - SettingProvider -} from 'discord.js-commando' import type { - Command as CommandEntity, - GuildCommand as GuildCommandEntity, Guild as GuildEntity, Role as RoleEntity, RoleMessage as RoleMessageEntity, Tag as TagEntity } from '../entities' -import { Repository } from 'typeorm' -import type { Snowflake } from 'discord.js' -import { constants } from '../util' -import container from '../configs/container' -import getDecorators from 'inversify-inject-decorators' - -const { CommandType, TYPES } = constants -const { lazyInject } = getDecorators(container) - -declare module 'discord.js-commando' { - interface SettingProvider { - setupGuild: (guildId: Snowflake) => Promise - onCommandPrefixChange: (guild: CommandoGuild | null, prefix: string | null) => Promise - onCommandStatusChange: ( - guild: CommandoGuild | null, - commandOrGroup: Command | CommandGroup, - enabled: boolean - ) => Promise - } -} - -// @ts-expect-error -export default class AroraProvider extends SettingProvider { - @lazyInject(TYPES.CommandRepository) - private readonly commandRepository!: Repository - - @lazyInject(TYPES.GuildCommandRepository) - private readonly guildCommandRepository!: Repository - - @lazyInject(TYPES.GuildRepository) +import { inject, injectable, named } from 'inversify' +import type { AroraClient } from '.' +import type { Guild } from 'discord.js' +import type { GuildContextManager } from '../managers' +import type { Repository } from 'typeorm' +import { constants } from '../utils' + +const { TYPES } = constants + +@injectable() +export default class SettingProvider { + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + @inject(TYPES.GuildRepository) private readonly guildRepository!: Repository - @lazyInject(TYPES.RoleRepository) + @inject(TYPES.RoleRepository) private readonly roleRepository!: Repository - @lazyInject(TYPES.RoleMessageRepository) + @inject(TYPES.RoleMessageRepository) private readonly roleMessageRepository!: Repository - @lazyInject(TYPES.TagRepository) + @inject(TYPES.TagRepository) private readonly tagRepository!: Repository - private client!: CommandoClient - - public async init (client: CommandoClient): Promise { - this.client = client - - const settings = await this.commandRepository.find() - for (const command of client.registry.commands.values()) { - await this.setupCommand(command, settings) - } - for (const group of client.registry.groups.values()) { - await this.setupGroup(group, settings) - } - - for (const guildId of client.guilds.cache.keys()) { - await this.setupGuild(guildId) - } - await this.setupGuild('0') // global settings + public async init (client: AroraClient): Promise { + await Promise.all(client.guilds.cache.map(async guild => await this.setupGuild(guild))) } - // @ts-expect-error - public override async setupGuild (guildId: Snowflake): Promise { - const guild = this.client.guilds.resolve(guildId) as CommandoGuild | null + public async setupGuild (guild: Guild): Promise { const data = await this.guildRepository.findOne( - guildId, + guild.id, { relations: [ 'groups', 'groups.channels', - 'groups.permissions', 'groups.roles', - 'guildCommands', - 'guildCommands.command', 'panels', 'panels.message', // See "Band-aid fix" below. // 'roles', - // 'roles.permissions', // 'roleBindings', // moved to RoleBindingManager.fetch // 'roleMessages', // 'roleMessages.message', @@ -103,133 +59,19 @@ export default class AroraProvider extends SettingProvider { 'ticketTypes.message' ] } - ) ?? await this.guildRepository.save(this.guildRepository.create({ id: guildId })) - if (typeof data.guildCommands === 'undefined') { - data.guildCommands = [] - } + ) ?? await this.guildRepository.save(this.guildRepository.create({ id: guild.id })) // Band-aid fix. idk why, but somehow after merging - // https://github.com/guidojw/nsadmin-discord/pull/164 the bot's memory + // https://github.com/guidojw/arora-discord/pull/164 the bot's memory // usage raised rapidly on start up and kept causing numerous out of memory - // errors. I tried several things and it seems to be pg related. + // errors. I tried several things, and it seems to be pg related. // Removing includes from the relations somehow fixed the issue. - if (guild !== null) { - data.roles = await this.roleRepository.find({ where: { guildId: guild.id }, relations: ['permissions'] }) - data.roleMessages = await this.roleMessageRepository.find({ - where: { guildId: guild.id }, - relations: ['message'] - }) - data.tags = await this.tagRepository.find({ where: { guildId: guild.id }, relations: ['names'] }) - // Remove more from the relations and put it here if above error returns.. - } - - if (guild !== null) { - guild.setup(data) - } - - if (data.commandPrefix !== null) { - if (guild !== null) { - // @ts-expect-error - guild._commandPrefix = data.commandPrefix - } else { - // @ts-expect-error - this.client._commandPrefix = data.commandPrefix - } - } - - for (const command of this.client.registry.commands.values()) { - this.setupGuildCommand(guild, command, data.guildCommands) - } - for (const group of this.client.registry.groups.values()) { - this.setupGuildGroup(guild, group, data.guildCommands) - } - - if (guild !== null) { - await guild.init() - } - } - - private async setupCommand (command: Command, settings: CommandEntity[]): Promise { - const commandSettings = settings.find(cmd => cmd.type === CommandType.Command && cmd.name === command.name) ?? - await this.commandRepository.save(this.commandRepository.create({ - name: command.name, - type: CommandType.Command - })) - command.aroraId = commandSettings.id - } - - private async setupGroup (group: CommandGroup, settings: CommandEntity[]): Promise { - const groupSettings = settings.find(grp => grp.type === CommandType.Group && grp.name === group.id) ?? - await this.commandRepository.save(this.commandRepository.create({ - name: group.id, - type: CommandType.Group - })) - group.aroraId = groupSettings.id - } - - private setupGuildCommand (guild: CommandoGuild | null, command: Command, settings: GuildCommandEntity[]): void { - if (!command.guarded) { - const commandSettings = settings.find(cmd => ( - cmd.command?.type === CommandType.Command && cmd.command?.name === command.name - )) - if (typeof commandSettings !== 'undefined') { - if (guild !== null) { - // @ts-expect-error - if (typeof guild._commandsEnabled === 'undefined') { - // @ts-expect-error - guild._commandsEnabled = {} - } - // @ts-expect-error - guild._commandsEnabled[command.name] = commandSettings.enabled - } else { - // @ts-expect-error - command._globalEnabled = commandSettings.enabled - } - } - } - } - - private setupGuildGroup (guild: CommandoGuild | null, group: CommandGroup, settings: GuildCommandEntity[]): void { - if (!group.guarded) { - const groupSettings = settings.find(grp => ( - grp.command?.type === CommandType.Group && grp.command?.name === group.id - )) - if (guild !== null) { - // @ts-expect-error - if (typeof guild._groupsEnabled === 'undefined') { - // @ts-expect-error - guild._groupsEnabled = {} - } - // @ts-expect-error - guild._groupsEnabled[group.id] = groupSettings?.enabled ?? false - } else { - // @ts-expect-error - group._globalEnabled = groupSettings?.enabled ?? false - } - } - } - - // @ts-expect-error - public override async onCommandPrefixChange (guild: CommandoGuild | null, prefix: string | null): Promise { - if (guild === null) { - await this.guildRepository.save(this.guildRepository.create({ id: '0', commandPrefix: prefix })) - return - } - await guild.update({ commandPrefix: prefix }) - } + data.roles = await this.roleRepository.find({ where: { guildId: guild.id } }) + data.roleMessages = await this.roleMessageRepository.find({ where: { guildId: guild.id }, relations: ['message'] }) + data.tags = await this.tagRepository.find({ where: { guildId: guild.id }, relations: ['names'] }) + // Remove more from the relations and put it here if above error returns.. - // @ts-expect-error - public override async onCommandStatusChange ( - guild: CommandoGuild | null, - commandOrGroup: Command | CommandGroup, - enabled: boolean - ): Promise { - // @ts-expect-error - const guildId = AroraProvider.getGuildID(guild) - await this.guildCommandRepository.save(this.guildCommandRepository.create({ - commandId: commandOrGroup.aroraId, - guildId: guildId !== 'global' ? guildId : '0', - enabled - })) + const context = this.guildContexts.add(data, { id: data.id, extras: [guild] }) + context.init() } } diff --git a/src/client/websocket/handlers/index.ts b/src/client/websocket/handlers/index.ts index 80e80e75..bd9ed825 100644 --- a/src/client/websocket/handlers/index.ts +++ b/src/client/websocket/handlers/index.ts @@ -1,2 +1 @@ export { default as RankChangePacketHandler } from './rank-change' -export { default as TrainDeveloperPayoutReportPacketHandler } from './train-developer-payout-report' diff --git a/src/client/websocket/handlers/rank-change.ts b/src/client/websocket/handlers/rank-change.ts index 87336941..de7c1f85 100644 --- a/src/client/websocket/handlers/rank-change.ts +++ b/src/client/websocket/handlers/rank-change.ts @@ -1,9 +1,11 @@ -import type { Collection, GuildMember } from 'discord.js' -import type BaseHandler from '../../base' -import type Client from '../../client' -import { injectable } from 'inversify' +import { inject, injectable, named } from 'inversify' +import type { BaseHandler } from '../..' +import type { GuildContextManager } from '../../../managers' +import { constants } from '../../../utils' import { userService } from '../../../services' +const { TYPES } = constants + interface RankChangePacket { groupId: number userId: number @@ -12,18 +14,24 @@ interface RankChangePacket { @injectable() export default class RankChangePacketHandler implements BaseHandler { - public async handle (client: Client, { data }: { data: RankChangePacket }): Promise { + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + public async handle ({ data }: { data: RankChangePacket }): Promise { const { groupId, userId, rank } = data const username = (await userService.getUser(userId)).name - for (const guild of client.guilds.cache.values()) { - if (guild.robloxGroupId === groupId) { - const roleBindings = await guild.roleBindings.fetch() + for (const context of this.guildContexts.cache.values()) { + if (context.robloxGroupId === groupId) { + const roleBindings = await context.roleBindings.fetch() if (roleBindings.size > 0) { - const members = await guild.members.fetch(username) as unknown as Collection + const members = await context.fetchMembersByRobloxUsername(username) if (members.size > 0) { for (const roleBinding of roleBindings.values()) { - if (rank === roleBinding.min || - (roleBinding.max !== null && rank >= roleBinding.min && rank <= roleBinding.max)) { + if ( + rank === roleBinding.min || + (roleBinding.max !== null && rank >= roleBinding.min && rank <= roleBinding.max) + ) { await Promise.all(members.map(async member => await member.roles.add(roleBinding.roleId))) } else { await Promise.all(members.map(async member => await member.roles.remove(roleBinding.roleId))) diff --git a/src/client/websocket/handlers/train-developer-payout-report.ts b/src/client/websocket/handlers/train-developer-payout-report.ts deleted file mode 100644 index 320df41b..00000000 --- a/src/client/websocket/handlers/train-developer-payout-report.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { MessageEmbed, type Snowflake } from 'discord.js' -import type BaseHandler from '../../base' -import type Client from '../../client' -import { injectable } from 'inversify' -import pluralize from 'pluralize' -import { userService } from '../../../services' - -interface TrainDeveloperPayoutReportPacket { - developersSales: Record -} - -@injectable() -export default class TrainDeveloperPayoutReportPacketHandler implements BaseHandler { - public async handle (client: Client, { data }: { data: TrainDeveloperPayoutReportPacket }): Promise { - const { developersSales } = data - const developerIds = Object.keys(developersSales).map(id => parseInt(id)) - const developers = await userService.getUsers(developerIds) - const emoji = client.mainGuild?.emojis.cache.find(emoji => emoji.name.toLowerCase() === 'robux') ?? null - const emojiString = (amount: number): string => `${emoji?.toString() ?? ''}${emoji !== null ? ' ' : ''}**${amount}**${emoji === null ? ' Robux' : ''}` - - const embed = new MessageEmbed() - .setTitle('Train Developers Payout Report') - .setColor(0xffffff) - for (const [id, developerSales] of Object.entries(developersSales)) { - const username = developers.find(developer => developer.id === parseInt(id))?.name ?? id - const total = Math.round(developerSales.total.robux) - embed.addField(username, `Has sold **${developerSales.total.amount}** ${pluralize('train', developerSales.total.amount)} and earned ${emojiString(total)}.`) - - try { - const user = client.users.resolve(developerSales.discordId) ?? - await client.users.fetch(developerSales.discordId) - const userEmbed = new MessageEmbed() - .setTitle('Weekly Train Payout Report') - .setColor(0xffffff) - for (const productSales of Object.values(developerSales.sales)) { - userEmbed.addField(productSales.name, `Sold **${productSales.amount}** ${pluralize('time', productSales.amount)} and earned ${emojiString(Math.round(productSales.robux * 100) / 100)}.`) - } - userEmbed.addField('Total', `**${developerSales.total.amount}** trains and ${emojiString(Math.round(total))}.`) - - await user.send(userEmbed) - } catch (err) { - console.error(`Couldn't DM ${developerSales.discordId}!`) - } - } - - await Promise.all(client.owners.map(async owner => await owner.send(embed))) - } -} diff --git a/src/client/websocket/websocket.ts b/src/client/websocket/websocket.ts index 1bd37aee..ef1ad36e 100644 --- a/src/client/websocket/websocket.ts +++ b/src/client/websocket/websocket.ts @@ -1,23 +1,29 @@ -import type Client from '../client' +import { inject, injectable, type interfaces } from 'inversify' +import type { BaseHandler } from '..' import WebSocket from 'ws' +import { constants } from '../../utils' + +const { TYPES } = constants + +const RECONNECT_TIMEOUT = 30_000 +const PING_TIMEOUT = 30_000 + 1000 export interface Packet { event: string data?: any } -const RECONNECT_TIMEOUT = 30 * 1000 -const PING_TIMEOUT = 30 * 1000 + 1000 - +@injectable() export default class WebSocketManager { - public readonly client: Client + @inject(TYPES.PacketHandlerFactory) + private readonly packetHandlerFactory!: interfaces.AutoNamedFactory + private readonly host: string private connection: WebSocket | null private pingTimeout: NodeJS.Timeout | null - public constructor (client: Client, host = process.env.WS_HOST) { - this.client = client - this.host = host ?? 'ws://127.0.0.1' + public constructor () { + this.host = 'ws://127.0.0.1' this.connection = null this.pingTimeout = null } @@ -51,9 +57,9 @@ export default class WebSocketManager { private onClose (): void { console.log('Disconnected!') if (this.pingTimeout !== null) { - this.client.clearTimeout(this.pingTimeout) + clearTimeout(this.pingTimeout) } - this.client.setTimeout(this.connect.bind(this), RECONNECT_TIMEOUT) + setTimeout(this.connect.bind(this), RECONNECT_TIMEOUT).unref() } private onPing (): void { @@ -62,13 +68,12 @@ export default class WebSocketManager { private heartbeat (): void { if (this.pingTimeout !== null) { - this.client.clearTimeout(this.pingTimeout) + clearTimeout(this.pingTimeout) } - this.pingTimeout = this.client.setTimeout(() => this.connection?.terminate(), PING_TIMEOUT) + this.pingTimeout = setTimeout(() => this.connection?.terminate(), PING_TIMEOUT).unref() } private handlePacket (packet: Packet): void { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.client.packetHandlerFactory(packet.event).handle(this.client, packet) + Promise.resolve(this.packetHandlerFactory(packet.event).handle(packet)).catch(console.error) } } diff --git a/src/commands/admin/ban.ts b/src/commands/admin/ban.ts deleted file mode 100644 index 3bc94e7e..00000000 --- a/src/commands/admin/ban.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import type { Guild, GuildMember, Message } from 'discord.js' -import BaseCommand from '../base' -import type { RobloxUser } from '../../types/roblox-user' -import { applicationAdapter } from '../../adapters' -import { argumentUtil } from '../../util' - -const { - validators, - noChannels, - noTags, - noUrls, - parseNoneOrType, - validateNoneOrType -} = argumentUtil - -export default class BanCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'admin', - name: 'ban', - description: 'Bans given user.', - details: 'A ban can be max 7 days long or permanent.', - examples: ['ban Happywalker Doing stuff.'], - clientPermissions: ['SEND_MESSAGES'], - requiresApi: true, - requiresRobloxGroup: true, - args: [{ - key: 'user', - type: 'roblox-user', - prompt: 'Who would you like to ban?' - }, { - key: 'days', - type: 'integer', - prompt: 'For how long would you like this ban this person? Reply with "none" if you want this ban to be ' + - 'permanent.', - min: 1, - max: 7, - validate: validateNoneOrType, - parse: parseNoneOrType - }, { - key: 'reason', - type: 'string', - prompt: 'With what reason would you like to ban this person?', - validate: validators([noChannels, noTags, noUrls]) - }] - }) - } - - public async run ( - message: CommandoMessage & { member: GuildMember, guild: Guild & { robloxGroupId: number } }, - { user, days, reason }: { - user: RobloxUser - days: number - reason: string - } - ): Promise { - const authorId = message.member.robloxId ?? (await message.member.fetchVerificationData())?.robloxId - if (typeof authorId === 'undefined') { - return await message.reply('This command requires you to be verified with a verification provider.') - } - - await applicationAdapter('POST', `v1/groups/${message.guild.robloxGroupId}/bans`, { - userId: user.id, - authorId, - duration: typeof days === 'undefined' ? undefined : days * 24 * 60 * 60 * 1000, - reason - }) - - return await message.reply(`Successfully banned **${user.username ?? user.id}**.`) - } -} diff --git a/src/commands/admin/bans.ts b/src/commands/admin/bans.ts deleted file mode 100644 index 162cc2c7..00000000 --- a/src/commands/admin/bans.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import { type Guild, type Message, MessageEmbed } from 'discord.js' -import BaseCommand from '../base' -import type { RobloxUser } from '../../types/roblox-user' -import { applicationAdapter } from '../../adapters' -import applicationConfig from '../../configs/application' -import { groupService } from '../../services' -import pluralize from 'pluralize' -import { timeUtil } from '../../util' - -const { getDate, getTime } = timeUtil - -export default class BansCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'admin', - name: 'bans', - aliases: ['banlist', 'baninfo'], - description: 'Lists info of current bans/given user\'s ban.', - clientPermissions: ['SEND_MESSAGES'], - requiresApi: true, - requiresRobloxGroup: true, - args: [{ - key: 'user', - type: 'roblox-user', - prompt: 'Of whose ban would you like to know the information?', - default: '' - }] - }) - } - - public async run ( - message: CommandoMessage & { guild: Guild & { robloxGroupId: number } }, - { user }: { user: RobloxUser | '' } - ): Promise { - if (user !== '') { - const ban = (await applicationAdapter('GET', `v1/groups/${message.guild.robloxGroupId}/bans/${user.id}`)).data - - const days = ban.duration / (24 * 60 * 60 * 1000) - const date = new Date(ban.date) - let extensionDays = 0 - for (const extension of ban.extensions) { - extensionDays += extension.duration / (24 * 60 * 60 * 1000) - } - const extensionString = extensionDays !== 0 - ? ` (${Math.sign(extensionDays) === 1 ? '+' : ''}${extensionDays})` - : '' - const embed = new MessageEmbed() - .setTitle(`${user.username ?? user.id}'s ban`) - .setColor(message.guild.primaryColor ?? applicationConfig.defaultColor) - .addField('Start date', getDate(date), true) - .addField('Start time', getTime(date), true) - .addField('Duration', `${days}${extensionString} ${pluralize('day', days + extensionDays)}`, true) - .addField('Reason', ban.reason) - - return await message.replyEmbed(embed) - } else { - const bans = (await applicationAdapter('GET', `v1/groups/${message.guild.robloxGroupId}/bans?sort=date`)).data - if (bans.length === 0) { - return await message.reply('There are currently no bans.') - } - - const embeds = await groupService.getBanEmbeds(message.guild.robloxGroupId, bans) - for (const embed of embeds) { - await message.author.send(embed) - } - - return await message.reply('Sent you a DM with the banlist.') - } - } -} diff --git a/src/commands/admin/cancel-training.ts b/src/commands/admin/cancel-training.ts deleted file mode 100644 index 094f1a2c..00000000 --- a/src/commands/admin/cancel-training.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import type { Guild, GuildMember, Message } from 'discord.js' -import BaseCommand from '../base' -import { applicationAdapter } from '../../adapters' -import { argumentUtil } from '../../util' - -const { validators, noChannels, noTags, noUrls } = argumentUtil - -export default class CancelTrainingCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'admin', - name: 'canceltraining', - details: 'TrainingId must be the ID of a currently scheduled training.', - description: 'Cancels given training.', - examples: ['canceltraining 1 Weird circumstances.'], - clientPermissions: ['SEND_MESSAGES'], - requiresApi: true, - requiresRobloxGroup: true, - args: [{ - key: 'trainingId', - type: 'integer', - prompt: 'Which training would you like to cancel?' - }, { - key: 'reason', - type: 'string', - prompt: 'With what reason would you like to cancel this training?', - validate: validators([noChannels, noTags, noUrls]) - }] - }) - } - - public async run ( - message: CommandoMessage & { member: GuildMember, guild: Guild & { robloxGroupId: number } }, - { trainingId, reason }: { - trainingId: number - reason: string - } - ): Promise { - const authorId = message.member.robloxId ?? (await message.member.fetchVerificationData())?.robloxId - if (typeof authorId === 'undefined') { - return await message.reply('This command requires you to be verified with a verification provider.') - } - - await applicationAdapter('POST', `v1/groups/${message.guild.robloxGroupId}/trainings/${trainingId}/cancel`, { - authorId, - reason - }) - - return await message.reply(`Successfully cancelled training with ID **${trainingId}**.`) - } -} diff --git a/src/commands/admin/demote.ts b/src/commands/admin/demote.ts deleted file mode 100644 index be27a56f..00000000 --- a/src/commands/admin/demote.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import type { Guild, GuildMember, Message } from 'discord.js' -import BaseCommand from '../base' -import type { ChangeMemberRole } from '../../services/group' -import type { RobloxUser } from '../../types/roblox-user' -import { applicationAdapter } from '../../adapters' - -export default class DemoteCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'admin', - name: 'demote', - description: 'Demotes given user in the group.', - examples: ['demote Happywalker'], - clientPermissions: ['SEND_MESSAGES'], - requiresApi: true, - requiresRobloxGroup: true, - args: [{ - key: 'user', - prompt: 'Who would you like to demote?', - type: 'roblox-user' - }] - }) - } - - public async run ( - message: CommandoMessage & { member: GuildMember, guild: Guild & { robloxGroupId: number } }, - { user }: { user: RobloxUser } - ): Promise { - const authorId = message.member.robloxId ?? (await message.member.fetchVerificationData())?.robloxId - if (typeof authorId === 'undefined') { - return await message.reply('This command requires you to be verified with a verification provider.') - } - - const roles: ChangeMemberRole = (await applicationAdapter('POST', `v1/groups/${message.guild.robloxGroupId}/users/${user.id}/demote`, { - authorId - })).data - - return await message.reply(`Successfully demoted **${user.username ?? user.id}** from **${roles.oldRole.name}** to **${roles.newRole.name}**.`) - } -} diff --git a/src/commands/admin/edit-ban.ts b/src/commands/admin/edit-ban.ts deleted file mode 100644 index b7e5e0ef..00000000 --- a/src/commands/admin/edit-ban.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import type { Guild, GuildMember, Message } from 'discord.js' -import BaseCommand from '../base' -import type { RobloxUser } from '../../types/roblox-user' -import { applicationAdapter } from '../../adapters' -import { argumentUtil } from '../../util' -import { userService } from '../../services' - -const { validators, noChannels, noTags, noUrls } = argumentUtil - -export default class EditBanCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'admin', - name: 'editban', - description: 'Edits given user\'s ban\'s key to given data.', - details: 'Key must be author or reason.', - examples: ['editban Happywalker author builderman'], - clientPermissions: ['SEND_MESSAGES'], - requiresApi: true, - requiresRobloxGroup: true, - args: [{ - key: 'user', - type: 'roblox-user', - prompt: 'Whose ban would you like to edit?' - }, { - key: 'key', - type: 'string', - prompt: 'What key would you like to edit?', - oneOf: ['author', 'reason'], - parse: (val: string) => val.toLowerCase() - }, { - key: 'data', - type: 'string', - prompt: 'What would you like to edit this key\'s data to?', - validate: validators([noChannels, noTags, noUrls]) // for when key = 'reason' - }] - }) - } - - public async run ( - message: CommandoMessage & { member: GuildMember, guild: Guild & { robloxGroupId: number } }, - { user, key, data }: { - user: RobloxUser - key: string - data: string - } - ): Promise { - const changes: { authorId?: number, reason?: string } = {} - if (key === 'author') { - changes.authorId = await userService.getIdFromUsername(data) - } else if (key === 'reason') { - changes.reason = data - } - const editorId = message.member.robloxId ?? (await message.member.fetchVerificationData())?.robloxId - if (typeof editorId === 'undefined') { - return await message.reply('This command requires you to be verified with a verification provider.') - } - - await applicationAdapter('PUT', `v1/groups/${message.guild.robloxGroupId}/bans/${user.id}`, { changes, editorId }) - - return await message.reply(`Successfully edited **${user.username ?? user.id}**'s ban.`) - } -} diff --git a/src/commands/admin/edit-training.ts b/src/commands/admin/edit-training.ts deleted file mode 100644 index b92c3c91..00000000 --- a/src/commands/admin/edit-training.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import type { Guild, GuildMember, Message } from 'discord.js' -import { argumentUtil, timeUtil } from '../../util' -import { groupService, userService } from '../../services' -import BaseCommand from '../base' -import { applicationAdapter } from '../../adapters' - -const { validators, noChannels, noTags, noUrls, parseNoneOrType, validDate, validTime } = argumentUtil -const { getDate, getDateInfo, getTime, getTimeInfo } = timeUtil - -export default class EditTrainingCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'admin', - name: 'edittraining', - description: 'Edits given training\'s key to given data.', - details: 'Key must be author, type, date, time or notes. trainingId must be the ID of a currently scheduled ' + - 'training. ', - examples: ['edittraining 1 date 5-3-2020'], - clientPermissions: ['SEND_MESSAGES'], - requiresApi: true, - requiresRobloxGroup: true, - args: [{ - key: 'trainingId', - type: 'integer', - prompt: 'Which training would you like to edit?' - }, { - key: 'key', - type: 'string', - prompt: 'What key would you like to edit?', - oneOf: ['author', 'type', 'date', 'time', 'notes'], - parse: (val: string) => val.toLowerCase() - }, { - key: 'data', - type: 'string', - prompt: 'What would you like to edit this key\'s data to?', - validate: validators([noChannels, noTags, noUrls]), // for when key = 'notes' - parse: parseNoneOrType - }] - }) - } - - public async run ( - message: CommandoMessage & { member: GuildMember, guild: Guild & { robloxGroupId: number } }, - { trainingId, key, data }: { - trainingId: number - key: string - data?: string - } - ): Promise { - if (['author', 'type', 'date', 'time'].includes(key) && typeof data === 'undefined') { - return await message.reply(`Invalid ${key}`) - } - - const changes: { authorId?: number, notes?: string | null, typeId?: number, date?: number } = {} - if (key === 'author') { - changes.authorId = await userService.getIdFromUsername(data as string) - } else if (key === 'notes') { - changes.notes = data ?? null - } else if (key === 'type') { - const type = (data as string).toUpperCase() - const trainingTypes = await groupService.getTrainingTypes(message.guild.robloxGroupId) - let trainingType = trainingTypes.find(trainingType => trainingType.abbreviation.toLowerCase() === type) - trainingType ??= trainingTypes.find(trainingType => trainingType.name.toLowerCase() === type) - if (typeof trainingType === 'undefined') { - return await message.reply('Type not found.') - } - - changes.typeId = trainingType.id - } else if (key === 'date' || key === 'time') { - const training = (await applicationAdapter('GET', `v1/groups/${message.guild.robloxGroupId}/trainings/${trainingId}`)) - .data - const date = new Date(training.date) - - let dateInfo - let timeInfo - if (key === 'date') { - if (!validDate(data as string)) { - return await message.reply('Please enter a valid date.') - } - dateInfo = getDateInfo(data as string) - timeInfo = getTimeInfo(getTime(date)) - } else { - if (!validTime(data as string)) { - return await message.reply('Please enter a valid time.') - } - dateInfo = getDateInfo(getDate(date)) - timeInfo = getTimeInfo(data as string) - } - - changes.date = Math.floor(new Date(dateInfo.year, dateInfo.month, dateInfo.day, timeInfo.hours, timeInfo.minutes) - .getTime()) - } - const editorId = message.member.robloxId ?? (await message.member.fetchVerificationData())?.robloxId - if (typeof editorId === 'undefined') { - return await message.reply('This command requires you to be verified with a verification provider.') - } - - await applicationAdapter('PUT', `v1/groups/${message.guild.robloxGroupId}/trainings/${trainingId}`, { - changes, - editorId - }) - - return await message.reply(`Successfully edited training with ID **${trainingId}**.`) - } -} diff --git a/src/commands/admin/exile.ts b/src/commands/admin/exile.ts deleted file mode 100644 index d80f95f3..00000000 --- a/src/commands/admin/exile.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import type { Guild, GuildMember, Message } from 'discord.js' -import BaseCommand from '../base' -import type { RobloxUser } from '../../types/roblox-user' -import { applicationAdapter } from '../../adapters' -import { argumentUtil } from '../../util' - -const { validators, noChannels, noTags, noUrls } = argumentUtil - -export default class ExileCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'admin', - name: 'exile', - description: 'Exiles given user.', - examples: ['exile Happywalker Spamming the group wall.'], - clientPermissions: ['SEND_MESSAGES'], - requiresApi: true, - requiresRobloxGroup: true, - args: [{ - key: 'user', - type: 'roblox-user', - prompt: 'Who would you like to exile?' - }, { - key: 'reason', - type: 'string', - prompt: 'With what reason would you like to exile this person?', - validate: validators([noChannels, noTags, noUrls]) - }] - }) - } - - public async run ( - message: CommandoMessage & { member: GuildMember, guild: Guild & { robloxGroupId: number } }, - { user, reason }: { - user: RobloxUser - reason: string - } - ): Promise { - const authorId = message.member.robloxId ?? (await message.member.fetchVerificationData())?.robloxId - if (typeof authorId === 'undefined') { - return await message.reply('This command requires you to be verified with a verification provider.') - } - - await applicationAdapter('POST', `v1/groups/${message.guild.robloxGroupId}/exiles`, { - userId: user.id, - authorId, - reason - }) - - return await message.reply(`Successfully exiled **${user.username ?? user.id}**.`) - } -} diff --git a/src/commands/admin/exiles.ts b/src/commands/admin/exiles.ts deleted file mode 100644 index b3d2dd59..00000000 --- a/src/commands/admin/exiles.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import { type Guild, type Message, MessageEmbed } from 'discord.js' -import BaseCommand from '../base' -import type { Exile } from '../../services/group' -import type { RobloxUser } from '../../types/roblox-user' -import { applicationAdapter } from '../../adapters' -import applicationConfig from '../../configs/application' -import { groupService } from '../../services' -import { timeUtil } from '../../util' - -const { getDate, getTime } = timeUtil - -export default class ExilesCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'admin', - name: 'exiles', - aliases: ['exilelist', 'exileinfo'], - description: 'Lists info of current exiles/given user\'s exile.', - clientPermissions: ['SEND_MESSAGES'], - requiresApi: true, - requiresRobloxGroup: true, - args: [{ - key: 'user', - type: 'roblox-user', - prompt: 'Of whose exile would you like to know the information?', - default: '' - }] - }) - } - - public async run ( - message: CommandoMessage & { guild: Guild & { robloxGroupId: number } }, - { user }: { user: RobloxUser | '' } - ): Promise { - if (user !== '') { - const exile: Exile = (await applicationAdapter('GET', `v1/groups/${message.guild.robloxGroupId}/exiles/${user.id}`)).data - - const date = new Date(exile.date) - const embed = new MessageEmbed() - .setTitle(`${user.username ?? user.id}'s exile`) - .setColor(message.guild.primaryColor ?? applicationConfig.defaultColor) - .addField('Start date', getDate(date), true) - .addField('Start time', getTime(date), true) - .addField('Reason', exile.reason) - - return await message.replyEmbed(embed) - } else { - const exiles = (await applicationAdapter('GET', `v1/groups/${message.guild.robloxGroupId}/exiles?sort=date`)).data - if (exiles.length === 0) { - return await message.reply('There are currently no exiles.') - } - - const embeds = await groupService.getExileEmbeds(exiles) - for (const embed of embeds) { - await message.author.send(embed) - } - - return await message.reply('Sent you a DM with the current exiles.') - } - } -} diff --git a/src/commands/admin/extend-ban.ts b/src/commands/admin/extend-ban.ts deleted file mode 100644 index 5975623b..00000000 --- a/src/commands/admin/extend-ban.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import type { Guild, GuildMember, Message } from 'discord.js' -import BaseCommand from '../base' -import type { RobloxUser } from '../../types/roblox-user' -import { applicationAdapter } from '../../adapters' -import { argumentUtil } from '../../util' - -const { validators, noChannels, noTags, noUrls } = argumentUtil - -export default class ExtendBanCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'admin', - name: 'extendban', - description: 'Extends the ban of given user.', - details: 'A ban can be max 7 days long or permanent.', - aliases: ['extend'], - examples: ['extend Happywalker 3 He still doesn\'t understand.'], - clientPermissions: ['SEND_MESSAGES'], - requiresApi: true, - requiresRobloxGroup: true, - args: [{ - key: 'user', - type: 'roblox-user', - prompt: 'Whose ban would you like to extend?' - }, { - key: 'days', - type: 'integer', - prompt: 'With how many days would you like to extend this person\'s ban?', - min: -6, - max: 6 - }, { - key: 'reason', - type: 'string', - prompt: 'With what reason are you extending this person\'s ban?', - validate: validators([noChannels, noTags, noUrls]) - }] - }) - } - - public async run ( - message: CommandoMessage & { member: GuildMember, guild: Guild & { robloxGroupId: number } }, - { user, days, reason }: { - user: RobloxUser - days: number - reason: string - } - ): Promise { - const authorId = message.member.robloxId ?? (await message.member.fetchVerificationData())?.robloxId - if (typeof authorId === 'undefined') { - return await message.reply('This command requires you to be verified with a verification provider.') - } - - await applicationAdapter('POST', `v1/groups/${message.guild.robloxGroupId}/bans/${user.id}/extend`, { - authorId, - duration: days * 24 * 60 * 60 * 1000, - reason - }) - - return await message.reply(`Successfully extended **${user.username ?? user.id}**'s ban.`) - } -} diff --git a/src/commands/admin/persist-role.ts b/src/commands/admin/persist-role.ts deleted file mode 100644 index 5994dce6..00000000 --- a/src/commands/admin/persist-role.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import type { GuildMember, Message, Role } from 'discord.js' -import BaseCommand from '../base' - -export default class PersistRoleCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'admin', - name: 'persistrole', - aliases: ['persist'], - description: 'Persists a role on given member.', - clientPermissions: ['SEND_MESSAGES', 'MANAGE_ROLES'], - args: [{ - key: 'member', - type: 'member', - prompt: 'Who would you like to give a persistent role?' - }, { - key: 'role', - type: 'role', - prompt: 'What role would you like to persist on this person?' - }] - }) - } - - public async run ( - message: CommandoMessage, - { member, role }: { - member: GuildMember - role: Role - } - ): Promise { - await member.persistRole(role) - - return await message.reply(`Successfully persisted role **${role.toString()}** on member **${member.toString()}**.`, { - allowedMentions: { users: [message.author.id] } - }) - } -} diff --git a/src/commands/admin/persistent-roles.ts b/src/commands/admin/persistent-roles.ts deleted file mode 100644 index 1d405319..00000000 --- a/src/commands/admin/persistent-roles.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import { type GuildMember, type Message, MessageEmbed } from 'discord.js' -import BaseCommand from '../base' -import applicationConfig from '../../configs/application' - -export default class PersistentRolesCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'admin', - name: 'persistentroles', - description: 'Fetches given member\'s persistent roles.', - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'member', - type: 'member', - prompt: 'Of who would you like to know the persistent roles?', - default: (message: CommandoMessage) => message.member - }] - }) - } - - public async run ( - message: CommandoMessage, - { member }: { member: GuildMember } - ): Promise { - const persistentRoles = await member.fetchPersistentRoles() - if (persistentRoles.size === 0) { - return await message.reply('No persistent roles found.') - } - - const embed = new MessageEmbed() - .setTitle(`${member.user.tag}'s Persistent Roles`) - .setDescription(persistentRoles.map(role => role.toString())) - .setColor(message.guild.primaryColor ?? applicationConfig.defaultColor) - return await message.replyEmbed(embed) - } -} diff --git a/src/commands/admin/promote.ts b/src/commands/admin/promote.ts deleted file mode 100644 index 596e543b..00000000 --- a/src/commands/admin/promote.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import type { Guild, GuildMember, Message } from 'discord.js' -import BaseCommand from '../base' -import type { ChangeMemberRole } from '../../services/group' -import type { RobloxUser } from '../../types/roblox-user' -import { applicationAdapter } from '../../adapters' - -export default class PromoteCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'admin', - name: 'promote', - description: 'Promotes given user in the group.', - examples: ['promote Happywalker'], - clientPermissions: ['SEND_MESSAGES'], - requiresApi: true, - requiresRobloxGroup: true, - args: [{ - key: 'user', - prompt: 'Who would you like to promote?', - type: 'roblox-user' - }] - }) - } - - public async run ( - message: CommandoMessage & { member: GuildMember, guild: Guild & { robloxGroupId: number } }, - { user }: { user: RobloxUser } - ): Promise { - const authorId = message.member.robloxId ?? (await message.member.fetchVerificationData())?.robloxId - if (typeof authorId === 'undefined') { - return await message.reply('This command requires you to be verified with a verification provider.') - } - - const roles: ChangeMemberRole = (await applicationAdapter('POST', `v1/groups/${message.guild.robloxGroupId}/users/${user.id}/promote`, { - authorId - })).data - - return await message.reply(`Successfully promoted **${user.username ?? user.id}** from **${roles.oldRole.name}** to **${roles.newRole.name}**.`) - } -} diff --git a/src/commands/admin/schedule-training.ts b/src/commands/admin/schedule-training.ts deleted file mode 100644 index df9a40a0..00000000 --- a/src/commands/admin/schedule-training.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import { type Guild, type GuildMember, type Message, MessageEmbed } from 'discord.js' -import { argumentUtil, timeUtil } from '../../util' -import BaseCommand from '../base' -import { applicationAdapter } from '../../adapters' -import applicationConfig from '../../configs/application' -import { groupService } from '../../services' - -const { validators, noChannels, noTags, noUrls, parseNoneOrType, validDate, validTime } = argumentUtil -const { getDateInfo, getTimeInfo } = timeUtil - -export default class ScheduleTrainingCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'admin', - name: 'scheduletraining', - aliases: ['schedule'], - details: 'Type must be CD or CSR. You can add special notes that will be shown in the training\'s announcement.' + - ' The date argument should be dd-mm-yyyy format.', - description: 'Schedules a new training.', - examples: ['schedule CD 4-3-2020 1:00 Be on time!', 'schedule CSR 4-3-2020 2:00 none'], - clientPermissions: ['SEND_MESSAGES'], - requiresApi: true, - requiresRobloxGroup: true, - args: [{ - key: 'type', - type: 'string', - prompt: 'What kind of training is this?', - parse: (val: string) => val.toLowerCase() - }, { - key: 'date', - type: 'string', - prompt: 'At what date would you like to host this training?', - validate: validators([validDate]) - }, { - key: 'time', - type: 'string', - prompt: 'At what time would you like to host this training?', - validate: validators([validTime]) - }, { - key: 'notes', - type: 'string', - prompt: 'What notes would you like to add? Reply with "none" if you don\'t want to add any.', - validate: validators([noChannels, noTags, noUrls]), - parse: parseNoneOrType - }] - }) - } - - public async run ( - message: CommandoMessage & { member: GuildMember, guild: Guild & { robloxGroupId: number } }, - { type, date, time, notes }: { - type: string - date: string - time: string - notes?: string - } - ): Promise { - const dateInfo = getDateInfo(date) - const timeInfo = getTimeInfo(time) - const dateUnix = Math.floor(new Date( - dateInfo.year, - dateInfo.month, - dateInfo.day, - timeInfo.hours, - timeInfo.minutes - ).getTime()) - const afterNow = dateUnix - Date.now() > 0 - if (!afterNow) { - return await message.reply('Please give a date and time that are after now.') - } - const trainingTypes = await groupService.getTrainingTypes(message.guild.robloxGroupId) - let trainingType = trainingTypes.find(trainingType => trainingType.abbreviation.toLowerCase() === type) - trainingType ??= trainingTypes.find(trainingType => trainingType.name.toLowerCase() === type) - if (typeof trainingType === 'undefined') { - return await message.reply('Type not found.') - } - const authorId = message.member.robloxId ?? (await message.member.fetchVerificationData())?.robloxId - if (typeof authorId === 'undefined') { - return await message.reply('This command requires you to be verified with a verification provider.') - } - - const training = (await applicationAdapter('POST', `v1/groups/${message.guild.robloxGroupId}/trainings`, { - authorId, - date: dateUnix, - notes, - typeId: trainingType.id - })).data - - const embed = new MessageEmbed() - .addField('Successfully scheduled', `**${trainingType.name}** training on **${date}** at **${time}**.`) - .addField('Training ID', training.id.toString()) - .setColor(message.guild.primaryColor ?? applicationConfig.defaultColor) - return await message.replyEmbed(embed) - } -} diff --git a/src/commands/admin/shout.ts b/src/commands/admin/shout.ts deleted file mode 100644 index ee39c530..00000000 --- a/src/commands/admin/shout.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import { type Guild, type GuildMember, type Message, MessageEmbed } from 'discord.js' -import BaseCommand from '../base' -import { applicationAdapter } from '../../adapters' -import applicationConfig from '../../configs/application' -import { argumentUtil } from '../../util' - -const { validators, noChannels, noTags, noUrls } = argumentUtil - -export default class ShoutCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'admin', - name: 'shout', - details: 'Shout can be either a message or "clear". When it\'s clear, the group shout will be cleared.', - description: 'Posts shout with given shout to the group.', - examples: ['shout Happywalker is awesome', 'shout "Happywalker is awesome"', 'shout clear'], - clientPermissions: ['SEND_MESSAGES'], - requiresApi: true, - requiresRobloxGroup: true, - args: [{ - key: 'body', - type: 'string', - prompt: 'What would you like to shout?', - max: 255, - validate: validators([noChannels, noTags, noUrls]) - }] - }) - } - - public async run ( - message: CommandoMessage & { member: GuildMember, guild: Guild & { robloxGroupId: number } }, - { body }: { body: string } - ): Promise { - const authorId = message.member.robloxId ?? (await message.member.fetchVerificationData())?.robloxId - if (typeof authorId === 'undefined') { - return await message.reply('This command requires you to be verified with a verification provider.') - } - - const shout = (await applicationAdapter('PUT', `v1/groups/${message.guild.robloxGroupId}/status`, { - authorId, - message: body === 'clear' ? '' : body - })).data - - if (shout.body === '') { - return await message.reply('Successfully cleared shout.') - } else { - const embed = new MessageEmbed() - .addField('Successfully shouted', shout.body) - .setColor(message.guild.primaryColor ?? applicationConfig.defaultColor) - return await message.replyEmbed(embed) - } - } -} diff --git a/src/commands/admin/unban.ts b/src/commands/admin/unban.ts deleted file mode 100644 index 1cf143b6..00000000 --- a/src/commands/admin/unban.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import type { Guild, GuildMember, Message } from 'discord.js' -import BaseCommand from '../base' -import type { RobloxUser } from '../../types/roblox-user' -import { applicationAdapter } from '../../adapters' -import { argumentUtil } from '../../util' - -const { validators, noChannels, noTags, noUrls } = argumentUtil - -export default class UnbanCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'admin', - name: 'unban', - description: 'Unbans given user.', - examples: ['unban Happywalker They apologized.'], - clientPermissions: ['SEND_MESSAGES'], - requiresApi: true, - requiresRobloxGroup: true, - args: [{ - key: 'user', - type: 'roblox-user', - prompt: 'Who would you like to unban?' - }, { - key: 'reason', - type: 'string', - prompt: 'With what reason would you like to unban this person?', - validate: validators([noChannels, noTags, noUrls]) - }] - }) - } - - public async run ( - message: CommandoMessage & { member: GuildMember, guild: Guild & { robloxGroupId: number } }, - { user, reason }: { - user: RobloxUser - reason: string - } - ): Promise { - const authorId = message.member.robloxId ?? (await message.member.fetchVerificationData())?.robloxId - if (typeof authorId === 'undefined') { - return await message.reply('This command requires you to be verified with a verification provider.') - } - - await applicationAdapter('POST', `v1/groups/${message.guild.robloxGroupId}/bans/${user.id}/cancel`, { - authorId, - reason - }) - - return await message.reply(`Successfully unbanned **${user.username ?? user.id}**.`) - } -} diff --git a/src/commands/admin/unexile.ts b/src/commands/admin/unexile.ts deleted file mode 100644 index 8985d986..00000000 --- a/src/commands/admin/unexile.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import type { Guild, GuildMember, Message } from 'discord.js' -import BaseCommand from '../base' -import type { RobloxUser } from '../../types/roblox-user' -import { applicationAdapter } from '../../adapters' -import { argumentUtil } from '../../util' - -const { validators, noChannels, noTags, noUrls } = argumentUtil - -export default class UnexileCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'admin', - name: 'unexile', - description: 'Unexiles given user.', - examples: ['unexile Happywalker They said they won\'t do it again.'], - clientPermissions: ['SEND_MESSAGES'], - requiresApi: true, - requiresRobloxGroup: true, - args: [{ - key: 'user', - type: 'roblox-user', - prompt: 'Who would you like to unexile?' - }, { - key: 'reason', - type: 'string', - prompt: 'With what reason would you like to unexile this person?', - validate: validators([noChannels, noTags, noUrls]) - }] - }) - } - - public async run ( - message: CommandoMessage & { member: GuildMember, guild: Guild & { robloxGroupId: number } }, - { user, reason }: { - user: RobloxUser - reason: string - } - ): Promise { - const authorId = message.member.robloxId ?? (await message.member.fetchVerificationData())?.robloxId - if (typeof authorId === 'undefined') { - return await message.reply('This command requires you to be verified with a verification provider.') - } - - await applicationAdapter('DELETE', `v1/groups/${message.guild.robloxGroupId}/exiles/${user.id}`, { - authorId, - reason - }) - - return await message.reply(`Successfully unexiled **${user.username ?? user.id}**.`) - } -} diff --git a/src/commands/admin/unpersist-role.ts b/src/commands/admin/unpersist-role.ts deleted file mode 100644 index 5e3e9c4f..00000000 --- a/src/commands/admin/unpersist-role.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import type { GuildMember, Message, Role } from 'discord.js' -import BaseCommand from '../base' - -export default class UnpersistRoleCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'admin', - name: 'unpersistrole', - aliases: ['unpersist'], - description: 'Removes a persistent role from given member.', - clientPermissions: ['SEND_MESSAGES', 'MANAGE_ROLES'], - args: [{ - key: 'member', - type: 'member', - prompt: 'From who would you like to remove a persistent role?' - }, { - key: 'role', - type: 'role', - prompt: 'What role would you like to remove?' - }] - }) - } - - public async run ( - message: CommandoMessage, - { member, role }: { - member: GuildMember - role: Role - } - ): Promise { - await member.unpersistRole(role) - - return await message.reply(`Successfully removed persistent role **${role.toString()}** from member **${member.toString()}**.`, { - allowedMentions: { users: [message.author.id] } - }) - } -} diff --git a/src/commands/base.ts b/src/commands/base.ts deleted file mode 100644 index 37ee1cbb..00000000 --- a/src/commands/base.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { - type ArgumentCollectorResult, - Command, - type CommandInfo, - type CommandoClient, - type CommandoMessage -} from 'discord.js-commando' -import type { Message } from 'discord.js' - -interface AroraCommandInfo extends Omit { - requiresApi?: boolean - requiresRobloxGroup?: boolean - requiresSingleGuild?: boolean - - memberName?: string -} - -declare module 'discord.js-commando' { - interface Command { - aroraId?: number - requiresApi: boolean - requiresRobloxGroup: boolean - requiresSingleGuild: boolean - } - - interface CommandGroup { - aroraId?: number - } -} - -export default abstract class BaseCommand extends Command { - protected constructor (client: CommandoClient, info: AroraCommandInfo) { - info.memberName = info.name - info.argsPromptLimit = info.argsPromptLimit ?? ((info.group === 'admin' || info.group === 'settings') ? 3 : 1) - info.guildOnly = typeof info.guildOnly !== 'undefined' ? info.guildOnly : true - super(client, info as CommandInfo) - - this.requiresApi = Boolean(info.requiresApi) - this.requiresRobloxGroup = Boolean(info.requiresRobloxGroup) - this.requiresSingleGuild = Boolean(info.requiresSingleGuild) - } - - public override hasPermission (message: CommandoMessage, ownerOverride = true): boolean | string { - if (ownerOverride && this.client.isOwner(message.author)) { - return true - } - - const result = super.hasPermission(message, ownerOverride) - if (result !== true || this.guarded || this.group.guarded) { - return result - } - - return message.member?.canRunCommand(this) ?? false - } - - public override async onError ( - _err: Error, - _message: CommandoMessage, - _args: object | string | string[], - _fromPattern: boolean, - _result: ArgumentCollectorResult - // @ts-expect-error - ): Promise { - // The commandError event handler takes care of this. - } -} diff --git a/src/commands/bot/restart.ts b/src/commands/bot/restart.ts deleted file mode 100644 index df01c9f7..00000000 --- a/src/commands/bot/restart.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import BaseCommand from '../base' -import type { Message } from 'discord.js' - -export default class RestartCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'bot', - name: 'restart', - description: 'Restarts the bot.', - clientPermissions: ['SEND_MESSAGES'], - ownerOnly: true - }) - } - - public async run (message: CommandoMessage): Promise { - await message.reply('Restarting...') - process.exit() - } -} diff --git a/src/commands/bot/status.ts b/src/commands/bot/status.ts deleted file mode 100644 index 3cac3f69..00000000 --- a/src/commands/bot/status.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import { type Message, MessageEmbed } from 'discord.js' -import { timeUtil, util } from '../../util' -import BaseCommand from '../base' -import { applicationAdapter } from '../../adapters' -import applicationConfig from '../../configs/application' -import os from 'node:os' - -const { formatBytes } = util -const { getDurationString } = timeUtil - -export default class StatusCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'bot', - name: 'status', - aliases: ['stats'], - description: 'Posts the system statuses.', - clientPermissions: ['SEND_MESSAGES'] - }) - } - - public async run (message: CommandoMessage): Promise { - const embed = new MessageEmbed() - .setAuthor(this.client.user?.username ?? 'Arora', this.client.user?.displayAvatarURL()) - .setColor(0xff82d1) - if (message.guild !== null) { - embed.addField('System Statuses', `Tickets System: **${message.guild.supportEnabled ? 'online' : 'offline'}**`) - } - const totalMem = os.totalmem() - embed - .addField('Load Average', os.loadavg().join(', '), true) - .addField('Memory Usage', `${formatBytes(totalMem - os.freemem(), 3)} / ${formatBytes(totalMem, 3)}`, true) - .addField('Uptime', getDurationString(this.client.uptime ?? 0), true) - .setFooter(`Process ID: ${process.pid} | ${os.hostname()}`) - .setTimestamp() - if (applicationConfig.apiEnabled === true) { - const startTime = Date.now() - const status = (await applicationAdapter('GET', 'v1/status')).data - const endTime = Date.now() - embed - .addField('API Latency', `${endTime - startTime}ms`, true) - .addField('API Status', status.state, true) - } - return await message.replyEmbed(embed) - } -} diff --git a/src/commands/main/boost-info.ts b/src/commands/main/boost-info.ts deleted file mode 100644 index 1110ce17..00000000 --- a/src/commands/main/boost-info.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import { type GuildMember, type Message, MessageEmbed } from 'discord.js' -import BaseCommand from '../base' -import pluralize from 'pluralize' -import { timeUtil } from '../../util' - -const { diffDays } = timeUtil - -export default class BoostInfoCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'main', - name: 'boostinfo', - description: 'Posts the boost information of given member.', - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'member', - prompt: 'Whose boost info do you want to know?', - type: 'member', - default: (message: CommandoMessage) => message.member - }] - }) - } - - public async run ( - message: CommandoMessage, - { member }: { member: GuildMember } - ): Promise { - if (member.premiumSince === null) { - return await message.reply(`${message.argString !== '' ? 'Member is not' : 'You\'re not'} a booster.`) - } - const now = new Date() - const diff = diffDays(member.premiumSince, now) - const months = Math.floor(diff / 30) - const days = diff % 30 - const emoji = this.client.mainGuild?.emojis.cache.find(emoji => emoji.name.toLowerCase() === 'boost') - - if (member.user.partial) { - await member.user.fetch() - } - const embed = new MessageEmbed() - .setTitle(`${member.user.tag} ${emoji?.toString() ?? ''}`) - .setThumbnail(member.user.displayAvatarURL()) - .setDescription(`Has been boosting this server for **${pluralize('month', months, true)}** and **${pluralize('day', days, true)}**!`) - .setFooter('* Discord Nitro months are 30 days long.') - .setColor(0xff73fa) - return await message.replyEmbed(embed) - } -} diff --git a/src/commands/main/delete-suggestion.ts b/src/commands/main/delete-suggestion.ts deleted file mode 100644 index 2a840d0e..00000000 --- a/src/commands/main/delete-suggestion.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import BaseCommand from '../base' -import type { Message } from 'discord.js' -import { discordService } from '../../services' - -export default class DeleteSuggestionCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'main', - name: 'deletesuggestion', - aliases: ['delsuggestion'], - description: 'Deletes your last suggested suggestion.', - clientPermissions: ['MANAGE_MESSAGES', 'ADD_REACTIONS', 'SEND_MESSAGES'] - }) - } - - public async run (message: CommandoMessage): Promise { - if (message.guild.suggestionsChannel === null) { - return await message.reply('This server has no suggestionsChannel set yet.') - } - const messages = await message.guild.suggestionsChannel.messages.fetch() - const authorUrl = `https://discord.com/users/${message.author.id}` - - for (const suggestion of messages.values()) { - if (suggestion.embeds[0]?.author?.url === authorUrl) { - const prompt = await message.replyEmbed(suggestion.embeds[0], 'Are you sure you would like to delete this ' + - 'suggestion?') - const choice = (await discordService.prompt(message.author, prompt, ['✅', '🚫']))?.toString() === '✅' - - if (choice) { - await suggestion.delete() - return await message.reply('Successfully deleted your last suggestion.') - } else { - return await message.reply('Didn\'t delete your last suggestion.') - } - } - } - - return await message.reply('Could not find a suggestion you made.') - } -} diff --git a/src/commands/main/get-shout.ts b/src/commands/main/get-shout.ts deleted file mode 100644 index 73d0b638..00000000 --- a/src/commands/main/get-shout.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import { type Guild, type Message, MessageEmbed } from 'discord.js' -import BaseCommand from '../base' -import type { GetGroupStatus } from '../../services/group' -import { applicationAdapter } from '../../adapters' -import applicationConfig from '../../configs/application' - -export default class GetShoutCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'main', - name: 'getshout', - description: 'Gets the current group shout.', - clientPermissions: ['SEND_MESSAGES'], - requiresApi: true, - requiresRobloxGroup: true - }) - } - - public async run ( - message: CommandoMessage & { guild: Guild & { robloxGroupId: number } } - ): Promise { - const shout: GetGroupStatus | '' = (await applicationAdapter('GET', `v1/groups/${message.guild.robloxGroupId}/status`)).data - - if (shout !== '' && shout.body !== '') { - const embed = new MessageEmbed() - .addField(`Current shout by ${shout.poster.username}`, shout.body) - .setTimestamp(new Date(shout.updated)) - .setColor(message.guild.primaryColor ?? applicationConfig.defaultColor) - return await message.replyEmbed(embed) - } else { - return await message.reply('There currently is no shout.') - } - } -} diff --git a/src/commands/main/member-count.ts b/src/commands/main/member-count.ts deleted file mode 100644 index 4a76a2e4..00000000 --- a/src/commands/main/member-count.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import { type Message, MessageEmbed } from 'discord.js' -import BaseCommand from '../base' -import applicationConfig from '../../configs/application' -import { groupService } from '../../services' - -export default class MemberCountCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'main', - name: 'membercount', - description: 'Posts the current member count of the group.', - clientPermissions: ['SEND_MESSAGES'], - args: [ - { - key: 'groupId', - type: 'integer', - prompt: 'From what group would you like to know the member count?', - default: (message: CommandoMessage) => message.guild.robloxGroupId ?? undefined - } - ] - }) - } - - public async run ( - message: CommandoMessage, - { groupId }: { groupId?: number } - ): Promise { - if (typeof groupId === 'undefined') { - return await message.reply('Invalid group ID.') - } - const group = await groupService.getGroup(groupId) - - const embed = new MessageEmbed() - .addField(`${group.name}'s member count`, group.memberCount) - .setColor(message.guild.primaryColor ?? applicationConfig.defaultColor) - return await message.replyEmbed(embed) - } -} diff --git a/src/commands/main/poll.ts b/src/commands/main/poll.ts deleted file mode 100644 index 6bce06d0..00000000 --- a/src/commands/main/poll.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import { type Message, MessageEmbed } from 'discord.js' -import BaseCommand from '../base' -import applicationConfig from '../../configs/application' -import { argumentUtil } from '../../util' - -const { validators, noTags } = argumentUtil - -export default class PollCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'main', - name: 'poll', - details: 'Use (X) with X being a number from 1-10 in your poll to have the bot automatically react the emojis ' + - 'of these numbers on the poll. If not provided, the bot will react with a checkmark and crossmark.', - description: 'Posts a poll of given poll to the current channel.', - examples: ['poll Dogs (1) or cats (2)?'], - clientPermissions: ['ADD_REACTIONS', 'SEND_MESSAGES'], - args: [{ - key: 'poll', - type: 'string', - prompt: 'What would you like the question to be?', - validate: validators([noTags]) - }] - }) - } - - public async run ( - message: CommandoMessage, - { poll }: { poll: string } - ): Promise { - const options = [] - for (let num = 1; num <= 10; num++) { - if (message.content.includes(`(${num})`)) { - options.push(num) - } - } - const embed = new MessageEmbed() - .setDescription(poll) - .setAuthor(message.author.tag, message.author.displayAvatarURL()) - .setColor(message.guild.primaryColor ?? applicationConfig.defaultColor) - - const newMessage = await message.channel.send(embed) - if (options.length > 0) { - for (const option of options) { - await newMessage.react(`${option}⃣`) - } - } else { - await newMessage.react('✔') - await newMessage.react('✖') - } - return null - } -} diff --git a/src/commands/main/suggest.ts b/src/commands/main/suggest.ts deleted file mode 100644 index cde75c91..00000000 --- a/src/commands/main/suggest.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import { type Message, type MessageAttachment, MessageEmbed } from 'discord.js' -import BaseCommand from '../base' -import { argumentUtil } from '../../util' - -const { validators, noTags } = argumentUtil - -export default class SuggestCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'main', - name: 'suggest', - description: 'Suggests given suggestion.', - details: 'Suggestion can be encapsulated in quotes (but this is not necessary).', - examples: ['suggest Add cool new thing', 'suggest "Add cool new thing"'], - clientPermissions: ['ADD_REACTIONS', 'SEND_MESSAGES'], - args: [{ - key: 'suggestion', - prompt: 'What would you like to suggest?', - type: 'string', - validate: validators([noTags]) - }], - throttling: { - usages: 1, - duration: 30 * 60 - } - }) - } - - public async run ( - message: CommandoMessage, - { suggestion }: { suggestion: string } - ): Promise { - if (message.guild.suggestionsChannel === null) { - return await message.reply('This server has no suggestionsChannel set yet.') - } - if (suggestion === '' || /^\s+$/.test(suggestion)) { - return await message.reply('Cannot suggest empty suggestions.') - } - const authorUrl = `https://discord.com/users/${message.author.id}` - const embed = new MessageEmbed() - .setDescription(suggestion) - .setAuthor(message.author.tag, message.author.displayAvatarURL(), authorUrl) - .setColor(0x000af43) - if (message.attachments.size > 0) { - const attachment = message.attachments.first() as MessageAttachment - if (attachment.height !== null) { - embed.setImage(attachment.url) - } - } - - const newMessage = await message.guild.suggestionsChannel.send(embed) - await newMessage.react('⬆️') - await newMessage.react('⬇️') - - return await message.reply('Successfully suggested', { embed }) - } -} diff --git a/src/commands/main/tags.ts b/src/commands/main/tags.ts deleted file mode 100644 index 7f5c3411..00000000 --- a/src/commands/main/tags.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import { type Message, MessageEmbed } from 'discord.js' -import BaseCommand from '../base' -import type { Tag } from '../../structures' -import applicationConfig from '../../configs/application' -import { util } from '../../util' - -const { makeCommaSeparatedString } = util - -export default class TagsCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'main', - name: 'tags', - aliases: ['tag'], - description: 'Posts given tag.', - examples: ['tag rr'], - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'tag', - type: 'tag', - prompt: 'What tag would you like to check out?', - default: '' - }] - }) - } - - public async run ( - message: CommandoMessage, - { tag }: { tag: Tag | '' } - ): Promise { - if (tag !== '') { - return await message.reply( - tag.content, - { allowedMentions: { users: [message.author.id] } } - ) - } else { - let list = '' - for (const tag of message.guild.tags.cache.values()) { - list += `${tag.id}. ${makeCommaSeparatedString(tag.names.cache.map(tagName => `\`${tagName.name}\``))}\n` - } - - const embed = new MessageEmbed() - .setTitle('Tags') - .setDescription(list) - .setFooter(`Page 1/1 (${message.guild.tags.cache.size} entries)`) - .setColor(message.guild.primaryColor ?? applicationConfig.defaultColor) - return await message.replyEmbed(embed) - } - } -} diff --git a/src/commands/main/trainings.ts b/src/commands/main/trainings.ts deleted file mode 100644 index e6573e81..00000000 --- a/src/commands/main/trainings.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import { type Guild, type Message, MessageEmbed } from 'discord.js' -import { groupService, userService } from '../../services' -import BaseCommand from '../base' -import type { Training } from '../../services/group' -import { applicationAdapter } from '../../adapters' -import applicationConfig from '../../configs/application' -import { timeUtil } from '../../util' - -const { getDate, getTime, getTimeZoneAbbreviation } = timeUtil - -export default class TrainingsCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'main', - name: 'trainings', - aliases: ['traininglist', 'training', 'traininginfo'], - description: 'Lists info of all trainings/training with given ID.', - clientPermissions: ['SEND_MESSAGES'], - details: 'TrainingId must be the ID of a currently scheduled training.', - requiresApi: true, - requiresRobloxGroup: true, - args: [{ - key: 'trainingId', - type: 'integer', - prompt: 'Of which training would you like to know the information?', - default: '' - }] - }) - } - - public async run ( - message: CommandoMessage & { guild: Guild & { robloxGroupId: number } }, - { trainingId }: { trainingId: number | '' } - ): Promise { - if (trainingId !== '') { - const training: Training = (await applicationAdapter('GET', `v1/groups/${message.guild.robloxGroupId}/trainings/${trainingId}`)) - .data - const username = (await userService.getUser(training.authorId)).name - const date = new Date(training.date) - - const embed = new MessageEmbed() - .setTitle(`Training ${training.id}`) - .addField('Type', training.type?.abbreviation ?? 'Deleted', true) - .addField('Date', getDate(date), true) - .addField('Time', `${getTime(date)} ${getTimeZoneAbbreviation(date)}`, true) - .addField('Host', username, true) - .setColor(message.guild.primaryColor ?? applicationConfig.defaultColor) - return await message.replyEmbed(embed) - } else { - const trainings: Training[] = (await applicationAdapter('GET', `v1/groups/${message.guild.robloxGroupId}/trainings?sort=date`)) - .data - if (trainings.length === 0) { - return await message.reply('There are currently no hosted trainings.') - } - - const embeds = await groupService.getTrainingEmbeds(trainings) - for (const embed of embeds) { - await message.author.send(embed) - } - return await message.reply('Sent you a DM with the upcoming trainings.') - } - } -} diff --git a/src/commands/main/who-is.ts b/src/commands/main/who-is.ts deleted file mode 100644 index 6888dcd8..00000000 --- a/src/commands/main/who-is.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import { type Message, MessageEmbed } from 'discord.js' -import BaseCommand from '../base' -import type { RobloxUser } from '../../types/roblox-user' -import applicationConfig from '../../configs/application' -import pluralize from 'pluralize' -import { timeUtil } from '../../util' -import { userService } from '../../services' - -const { getDate } = timeUtil - -export default class WhoIsCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'main', - name: 'whois', - aliases: ['user', 'profile'], - description: 'Posts the Roblox information of given user.', - examples: ['whois', 'whois Happywalker', 'whois 6882179', 'whois @Happywalker'], - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'user', - prompt: 'Of which user would you like to know the Roblox information?', - type: 'roblox-user', - default: 'self' - }] - }) - } - - public async run ( - message: CommandoMessage, - { user }: { user: RobloxUser } - ): Promise { - const userInfo = await userService.getUser(user.id) - const age = Math.floor((Date.now() - new Date(userInfo.created).getTime()) / (24 * 60 * 60 * 1000)) - const outfits = await userService.getUserOutfits(user.id) - - const embed = new MessageEmbed() - .setAuthor(userInfo.name ?? 'Unknown', `https://www.roblox.com/headshot-thumbnail/image?width=150&height=150&format=png&userId=${user.id}`) - .setThumbnail(`https://www.roblox.com/outfit-thumbnail/image?width=150&height=150&format=png&userOutfitId=${outfits[0]?.id ?? 0}`) - .setColor(message.guild.primaryColor ?? applicationConfig.defaultColor) - .addField('Blurb', userInfo.description !== '' ? userInfo.description : 'No blurb') - .addField('Join Date', getDate(new Date(userInfo.created)), true) - .addField('Account Age', pluralize('day', age, true), true) - .addField('\u200b', '\u200b', true) - .setFooter(`User ID: ${user.id}`) - .setTimestamp() - if (message.guild.robloxGroupId !== null) { - const groupsRoles = await userService.getGroupsRoles(user.id) - const group = groupsRoles.find(group => group.group.id === message.guild.robloxGroupId) - embed - .addField('Role', group?.role.name ?? 'Guest', true) - .addField('Rank', group?.role.rank ?? 0, true) - .addField('\u200b', '\u200b', true) - } - embed.addField('\u200b', `[Profile](https://www.roblox.com/users/${user.id}/profile)`) - return await message.replyEmbed(embed) - } -} diff --git a/src/commands/settings/add-to-group.ts b/src/commands/settings/add-to-group.ts deleted file mode 100644 index 02936c88..00000000 --- a/src/commands/settings/add-to-group.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import { type Message, Role, TextChannel } from 'discord.js' -import BaseCommand from '../base' -import type { Group } from '../../structures' - -export default class AddToGroupCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'addtogroup', - description: 'Adds a channel or role to a group.', - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'group', - prompt: 'To what group do you want to add a channel or role?', - type: 'arora-group' - }, { - key: 'channelOrRole', - label: 'channel/role', - prompt: 'What channel or role do you want to add to this group?', - type: 'text-channel|role' - }] - }) - } - - public async run ( - message: CommandoMessage, - { group, channelOrRole }: { - group: Group - channelOrRole: TextChannel | Role - } - ): Promise { - if (group.isChannelGroup() && channelOrRole instanceof TextChannel) { - await group.channels.add(channelOrRole) - } else if (group.isRoleGroup() && channelOrRole instanceof Role) { - await group.roles.add(channelOrRole) - } else { - return await message.reply(`Cannot add a ${channelOrRole instanceof TextChannel ? 'channel' : 'role'} to a ${group.type} group.`) - } - - // eslint-disable-next-line @typescript-eslint/no-base-to-string - return await message.reply(`Successfully added ${group.type} ${channelOrRole.toString()} to group \`${group.name}\`.`, { - allowedMentions: { users: [message.author.id] } - }) - } -} diff --git a/src/commands/settings/channel-links.ts b/src/commands/settings/channel-links.ts deleted file mode 100644 index ec4eae00..00000000 --- a/src/commands/settings/channel-links.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import { type Message, MessageEmbed, type VoiceChannel } from 'discord.js' -import BaseCommand from '../base' -import applicationConfig from '../../configs/application' - -export default class ChannelLinksCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'channellinks', - description: 'Fetches given voice channel\'s linked text channels.', - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'channel', - type: 'voice-channel', - prompt: 'Of what voice channel would you like to know the linked text channels?' - }] - }) - } - - public async run ( - message: CommandoMessage, - { channel }: { channel: VoiceChannel } - ): Promise { - const links = await channel.fetchToLinks() - if (links.size === 0) { - return await message.reply('No links found.') - } - - const embed = new MessageEmbed() - .setTitle(`${channel.name}'s Channel Links`) - // eslint-disable-next-line @typescript-eslint/no-base-to-string - .setDescription(links.map(channel => channel.toString())) - .setColor(message.guild.primaryColor ?? applicationConfig.defaultColor) - return await message.replyEmbed(embed) - } -} diff --git a/src/commands/settings/close-ticket.ts b/src/commands/settings/close-ticket.ts deleted file mode 100644 index 4f35d832..00000000 --- a/src/commands/settings/close-ticket.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import type { GuildMember, Message, TextChannel } from 'discord.js' -import BaseCommand from '../base' -import applicationConfig from '../../configs/application' -import { discordService } from '../../services' -import { stripIndents } from 'common-tags' - -export default class CloseTicketCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'closeticket', - aliases: ['close'], - description: 'Closes this ticket.', - clientPermissions: ['ADD_REACTIONS', 'SEND_MESSAGES', 'MANAGE_CHANNELS'], - guarded: true, - hidden: true - }) - } - - public async run ( - message: CommandoMessage & { member: GuildMember, channel: TextChannel } - ): Promise { - const ticket = message.guild.tickets.resolve(message.channel) - if (ticket !== null) { - const prompt = await message.channel.send('Are you sure you want to close this ticket?') - const choice = (await discordService.prompt(message.author, prompt, ['✅', '🚫']))?.toString() === '✅' - - if (choice) { - await message.guild.log( - message.author, - stripIndents` - ${message.author} **closed ticket** \`${ticket.id}\` - ${message.content} - `, - { footer: `Ticket ID: ${ticket.id}` } - ) - - if (message.member.id === ticket.author?.id) { - await ticket.close( - 'Ticket successfully closed.', - false, - message.guild.primaryColor ?? applicationConfig.defaultColor) - } else { - await ticket.close( - 'The moderator has closed your ticket.', - true, - message.guild.primaryColor ?? applicationConfig.defaultColor) - } - } - } - return null - } -} diff --git a/src/commands/settings/create-group.ts b/src/commands/settings/create-group.ts deleted file mode 100644 index 27e46846..00000000 --- a/src/commands/settings/create-group.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import BaseCommand from '../base' -import { GroupType } from '../../util/constants' -import type { Message } from 'discord.js' -import { argumentUtil } from '../../util' - -const { validators, noNumber, noSpaces } = argumentUtil - -export default class CreateGroupCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'creategroup', - description: 'Creates a new group.', - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'name', - prompt: 'What do you want the name of the group to be?', - type: 'string', - validate: validators([noNumber, noSpaces]) - }, { - key: 'type', - prompt: 'What do you want the type of the group to be?', - type: 'string', - oneOf: Object.values(GroupType) - }] - }) - } - - public async run ( - message: CommandoMessage, - { name, type }: { - name: string - type: GroupType - } - ): Promise { - const group = await message.guild.groups.create(name, type) - - return await message.reply(`Successfully created group \`${group.name}\`.`) - } -} diff --git a/src/commands/settings/create-panel.ts b/src/commands/settings/create-panel.ts deleted file mode 100644 index 6093d5b3..00000000 --- a/src/commands/settings/create-panel.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import BaseCommand from '../base' -import type { Message } from 'discord.js' -import { argumentUtil } from '../../util' - -const { validators, isObject, noNumber, noSpaces } = argumentUtil - -export default class CreatePanelCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'createpanel', - aliases: ['createpnl'], - description: 'Creates a new panel.', - details: 'For the content argument, you should input a JSON format embed object. You can use the Embed ' + - 'Visualizer at to create one. When finished, copy the object ' + - '(denoted with {}) on the left output screen after "embed: ".', - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'name', - prompt: 'What do you want the name of the panel to be?', - type: 'string', - validate: validators([noNumber, noSpaces]) - }, { - key: 'content', - prompt: 'What do you want the content of the panel to be?', - type: 'json-object', - validate: validators([isObject]) - }] - }) - } - - public async run ( - message: CommandoMessage, - { name, content }: { - name: string - content: object - } - ): Promise { - const panel = await message.guild.panels.create(name, content) - - return await message.reply(`Successfully created panel \`${panel.name}\`.`) - } -} diff --git a/src/commands/settings/create-role-binding.ts b/src/commands/settings/create-role-binding.ts deleted file mode 100644 index 4a105f67..00000000 --- a/src/commands/settings/create-role-binding.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import type { Message, Role } from 'discord.js' -import BaseCommand from '../base' -import { argumentUtil } from '../../util' - -const { validateNoneOrType, parseNoneOrType } = argumentUtil - -export default class CreateRoleBindingCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'createrolebinding', - aliases: ['createrolebnd'], - description: 'Creates a new Roblox rank to Discord role binding.', - clientPermissions: ['SEND_MESSAGES'], - requiresRobloxGroup: true, - args: [{ - key: 'role', - prompt: 'To what role would you like to bind this binding?', - type: 'role' - }, { - key: 'min', - prompt: 'What do you want the lower limit of this binding to be?', - type: 'integer', - min: 0, - max: 255 - }, { - key: 'max', - prompt: 'What do you want the upper limit of this binding to be? Reply with "none" if you don\'t want one.', - type: 'integer', - min: 0, - max: 255, - validate: validateNoneOrType, - parse: parseNoneOrType - }] - }) - } - - public async run ( - message: CommandoMessage, - { role, min, max }: { - role: Role - min: number - max?: number - } - ): Promise { - const roleBinding = await message.guild.roleBindings.create({ role, min, max }) - - return await message.reply(`Successfully bound group \`${roleBinding.robloxGroupId}\` rank \`${getRangeString(roleBinding.min, roleBinding.max)}\` to role ${roleBinding.role?.toString() ?? 'Unknown'}.`, { - allowedMentions: { users: [message.author.id] } - }) - } -} - -function getRangeString (min: number, max: number | null): string { - return `${max !== null ? '[' : ''}${min}${max !== null ? `, ${max}]` : ''}` -} diff --git a/src/commands/settings/create-role-message.ts b/src/commands/settings/create-role-message.ts deleted file mode 100644 index 18ac2583..00000000 --- a/src/commands/settings/create-role-message.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import type { GuildEmoji, Message, Role } from 'discord.js' -import BaseCommand from '../base' - -export default class CreateRoleMessageCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'createrolemessage', - aliases: ['createrolemsg'], - description: 'Creates a new role message.', - clientPermissions: ['SEND_MESSAGES', 'ADD_REACTIONS'], - args: [{ - key: 'role', - prompt: 'For what role do you want to make a role message?', - type: 'role' - }, { - key: 'message', - prompt: 'What message would you like to make a role message?', - type: 'message' - }, { - key: 'emoji', - prompt: 'What emoji do you want to bind to this message?', - type: 'custom-emoji|default-emoji' - }] - }) - } - - public async run ( - message: CommandoMessage, - { role, message: newMessage, emoji }: { - role: Role - message: Message - emoji: GuildEmoji | string - } - ): Promise { - const roleMessage = await message.guild.roleMessages.create({ role, message: newMessage, emoji }) - - return await message.reply(`Successfully bound role ${roleMessage.role?.toString() ?? 'Unknown'} to emoji ${roleMessage.emoji?.toString() ?? 'Unknown'} on message \`${roleMessage.messageId ?? 'unknown'}\`.`, { - allowedMentions: { users: [message.author.id] } - }) - } -} diff --git a/src/commands/settings/create-tag-alias.ts b/src/commands/settings/create-tag-alias.ts deleted file mode 100644 index 5dd75742..00000000 --- a/src/commands/settings/create-tag-alias.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import BaseCommand from '../base' -import type { Message } from 'discord.js' -import type { Tag } from '../../structures' -import { argumentUtil } from '../../util' - -const { validators, noNumber } = argumentUtil - -export default class CreateTagAliasCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'createtagalias', - aliases: ['createalias'], - description: 'Creates a new alias for a tag.', - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'tag', - prompt: 'To what tag would you like to add this alias?', - type: 'tag' - }, { - key: 'alias', - prompt: 'What would you like the new alias of this tag to be?', - type: 'string', - validate: validators([noNumber]) - }] - }) - } - - public async run ( - message: CommandoMessage, - { tag, alias }: { - tag: Tag - alias: string - } - ): Promise { - const tagName = await tag.names.create(alias) - - return await message.reply(`Successfully created alias \`${tagName.name}\` for tag \`${tag.names.cache.first()?.name ?? 'Unknown'}\`.`) - } -} diff --git a/src/commands/settings/create-tag.ts b/src/commands/settings/create-tag.ts deleted file mode 100644 index 1ca19cae..00000000 --- a/src/commands/settings/create-tag.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import BaseCommand from '../base' -import type { Message } from 'discord.js' -import { argumentUtil } from '../../util' - -const { validators, isObject, noNumber, typeOf } = argumentUtil - -export default class CreateTagCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'createtag', - description: 'Creates a new tag.', - details: 'For the content argument, you can input either any message or a JSON format embed object. You can use' + - ' the Embed Visualizer at to create an embed. When finished, ' + - 'copy object (denoted with {}) on the left output screen after "embed: ".', - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'name', - prompt: 'What do you want the name of the tag to be?', - type: 'string', - validate: validators([noNumber]) - }, { - key: 'content', - prompt: 'What do you want the content of the tag to be?', - type: 'json-object|string', - validate: validators([[isObject, typeOf('string')]]) - }] - }) - } - - public async run ( - message: CommandoMessage, - { name, content }: { - name: string - content: string | object - } - ): Promise { - const tag = await message.guild.tags.create(name, content) - - return await message.reply(`Successfully created tag \`${tag.names.cache.first()?.name ?? 'Unknown'}\`.`) - } -} diff --git a/src/commands/settings/create-ticket-type.ts b/src/commands/settings/create-ticket-type.ts deleted file mode 100644 index dc92e87f..00000000 --- a/src/commands/settings/create-ticket-type.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import BaseCommand from '../base' -import type { Message } from 'discord.js' - -export default class CreateTicketTypeCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'createtickettype', - aliases: ['creatett'], - description: 'Creates a new ticket type.', - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'name', - prompt: 'What do you want the name of the ticket type to be?', - type: 'string', - max: 16 - }] - }) - } - - public async run ( - message: CommandoMessage, - { name }: { name: string } - ): Promise { - const type = await message.guild.ticketTypes.create(name) - - return await message.reply(`Successfully created ticket type \`${type.name}\`.`) - } -} diff --git a/src/commands/settings/delete-group.ts b/src/commands/settings/delete-group.ts deleted file mode 100644 index 5b18fa8c..00000000 --- a/src/commands/settings/delete-group.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import BaseCommand from '../base' -import type { Group } from '../../structures' -import type { Message } from 'discord.js' - -export default class DeleteGroupCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'deletegroup', - aliases: ['delgroup'], - description: 'Deletes a group.', - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'group', - prompt: 'What group would you like to delete?', - type: 'arora-group' - }] - }) - } - - public async run ( - message: CommandoMessage, - { group }: { group: Group } - ): Promise { - await message.guild.groups.delete(group) - - return await message.reply('Successfully deleted group.') - } -} diff --git a/src/commands/settings/delete-panel.ts b/src/commands/settings/delete-panel.ts deleted file mode 100644 index da85b61f..00000000 --- a/src/commands/settings/delete-panel.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import BaseCommand from '../base' -import type { Message } from 'discord.js' -import type { Panel } from '../../structures' - -export default class DeletePanelCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'deletepanel', - aliases: ['deletepnl', 'delpanel', 'delpnl'], - description: 'Deletes a panel.', - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'panel', - prompt: 'What panel would you like to delete?', - type: 'panel' - }] - }) - } - - public async run ( - message: CommandoMessage, - { panel }: { panel: Panel } - ): Promise { - await message.guild.panels.delete(panel) - - return await message.reply('Successfully deleted panel.') - } -} diff --git a/src/commands/settings/delete-role-binding.ts b/src/commands/settings/delete-role-binding.ts deleted file mode 100644 index 41a8f46f..00000000 --- a/src/commands/settings/delete-role-binding.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import BaseCommand from '../base' -import type { Message } from 'discord.js' -import type { RoleBinding } from '../../structures' - -export default class DeleteRoleBindingCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'deleterolebinding', - aliases: ['delrolebinding', 'deleterolebnd', 'delrolebnd'], - description: 'Deletes a Roblox rank to Discord role binding.', - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'roleBinding', - prompt: 'What role binding would you like to delete?', - type: 'integer' - }] - }) - } - - public async run ( - message: CommandoMessage, - { roleBinding }: { roleBinding: RoleBinding } - ): Promise { - await message.guild.roleBindings.delete(roleBinding) - - return await message.reply('Successfully deleted role binding.') - } -} diff --git a/src/commands/settings/delete-role-message.ts b/src/commands/settings/delete-role-message.ts deleted file mode 100644 index 8e6ada45..00000000 --- a/src/commands/settings/delete-role-message.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import BaseCommand from '../base' -import type { Message } from 'discord.js' -import type { RoleMessage } from '../../structures' - -export default class DeleteRoleMessageCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'deleterolemessage', - aliases: ['delrolemessage', 'deleterolemsg', 'delrolemsg'], - description: 'Deletes a role message.', - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'roleMessage', - prompt: 'What role message would you like to delete?', - type: 'integer' - }] - }) - } - - public async run ( - message: CommandoMessage, - { roleMessage }: { roleMessage: RoleMessage } - ): Promise { - await message.guild.roleMessages.delete(roleMessage) - - return await message.reply('Successfully deleted role message.') - } -} diff --git a/src/commands/settings/delete-tag-alias.ts b/src/commands/settings/delete-tag-alias.ts deleted file mode 100644 index 391f34f9..00000000 --- a/src/commands/settings/delete-tag-alias.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import BaseCommand from '../base' -import type { Message } from 'discord.js' - -export default class DeleteTagAliasCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'deletetagalias', - aliases: ['deltagalias', 'deletealias', 'delalias'], - description: 'Deletes a tag alias.', - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'alias', - prompt: 'What tag alias would you like to delete?', - type: 'string' - }] - }) - } - - public async run ( - message: CommandoMessage, - { alias }: { alias: string } - ): Promise { - const tag = message.guild.tags.resolve(alias) - if (tag === null) { - return await message.reply('Tag not found') - } - - await tag.names.delete(alias) - - return await message.reply(`Successfully deleted alias from tag \`${tag.names.cache.first()?.name ?? 'Unknown'}\`.`) - } -} diff --git a/src/commands/settings/delete-tag.ts b/src/commands/settings/delete-tag.ts deleted file mode 100644 index c1373c0a..00000000 --- a/src/commands/settings/delete-tag.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import BaseCommand from '../base' -import type { Message } from 'discord.js' -import type { Tag } from '../../structures' - -export default class DeleteTagCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'deletetag', - aliases: ['deltag'], - description: 'Deletes a tag.', - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'tag', - prompt: 'What tag would you like to delete?', - type: 'tag' - }] - }) - } - - public async run ( - message: CommandoMessage, - { tag }: { tag: Tag } - ): Promise { - await message.guild.tags.delete(tag) - - return await message.reply('Successfully deleted tag.') - } -} diff --git a/src/commands/settings/delete-ticket-type.ts b/src/commands/settings/delete-ticket-type.ts deleted file mode 100644 index 7eb7212a..00000000 --- a/src/commands/settings/delete-ticket-type.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import BaseCommand from '../base' -import type { Message } from 'discord.js' -import type { TicketType } from '../../structures' - -export default class DeleteTicketTypeCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'deletetickettype', - aliases: ['deltickettype', 'deletett', 'deltt'], - description: 'Deletes a ticket type.', - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'type', - prompt: 'What ticket type would you like to delete?', - type: 'ticket-type' - }] - }) - } - - public async run ( - message: CommandoMessage, - { type }: { type: TicketType } - ): Promise { - await message.guild.ticketTypes.delete(type) - - return await message.reply('Successfully deleted ticket type.') - } -} diff --git a/src/commands/settings/edit-panel.ts b/src/commands/settings/edit-panel.ts deleted file mode 100644 index 1c8d6c2b..00000000 --- a/src/commands/settings/edit-panel.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import type { Panel, PanelUpdateOptions } from '../../structures' -import BaseCommand from '../base' -import { Message } from 'discord.js' - -export default class EditPanelCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'editpanel', - aliases: ['editpnl'], - description: 'Edits a panel.', - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'panel', - prompt: 'What panel would you like to edit?', - type: 'panel' - }, { - key: 'key', - type: 'string', - prompt: 'What key would you like to edit?', - oneOf: ['content', 'message'], - parse: (val: string) => val.toLowerCase() - }, { - key: 'data', - prompt: 'What would you like to edit this key\'s data to?', - type: 'json-object|message' - }] - }) - } - - public async run ( - message: CommandoMessage, - { panel, key, data }: { - panel: Panel - key: string - data: object | Message - } - ): Promise { - const changes: PanelUpdateOptions = {} - if (key === 'content') { - if (data instanceof Message) { - return await message.reply('`data` must be an object.') - } - - changes.content = data - } else if (key === 'message') { - if (!(data instanceof Message)) { - return await message.reply('`data` must be a message URL.') - } - - changes.message = data - } - - panel = await message.guild.panels.update(panel, changes) - - return await message.reply(`Successfully edited panel \`${panel.name}\`.`) - } -} diff --git a/src/commands/settings/edit-tag.ts b/src/commands/settings/edit-tag.ts deleted file mode 100644 index 41c7ccf9..00000000 --- a/src/commands/settings/edit-tag.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import BaseCommand from '../base' -import type { Message } from 'discord.js' -import type { Tag } from '../../structures' -import { argumentUtil } from '../../util' - -const { validators, isObject, typeOf } = argumentUtil - -export default class EditTagCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'edittag', - description: 'Edits a tag.', - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'tag', - prompt: 'What tag would you like to edit?', - type: 'tag' - }, { - key: 'content', - prompt: 'What do you want the new content of this tag to be?', - type: 'json-object|string', - validate: validators([[isObject, typeOf('string')]]) - }] - }) - } - - public async run ( - message: CommandoMessage, - { tag, content }: { - tag: Tag - content: string - } - ): Promise { - tag = await message.guild.tags.update(tag, { content }) - - return await message.reply(`Successfully edited tag \`${tag.names.cache.first()?.name ?? 'Unknown'}\`.`) - } -} diff --git a/src/commands/settings/get-setting.ts b/src/commands/settings/get-setting.ts deleted file mode 100644 index 7c26df10..00000000 --- a/src/commands/settings/get-setting.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import { type Guild, GuildChannel, type Message } from 'discord.js' -import { argumentUtil, util } from '../../util' -import BaseCommand from '../base' -import { GuildSetting } from '../../extensions' - -const { guildSettingTransformer, parseEnum } = argumentUtil -const { getEnumKeys } = util - -export default class GetSettingCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'getsetting', - aliases: ['get'], - description: 'Gets a guild\'s setting.', - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'setting', - prompt: 'What setting would you like to get?', - type: 'string', - oneOf: getEnumKeys(GuildSetting) - .map(guildSettingTransformer) - .map(attribute => attribute.toLowerCase()), - parse: parseEnum(GuildSetting, guildSettingTransformer) - }] - }) - } - - public async run ( - message: CommandoMessage & { guild: Guild }, - { setting }: { setting: keyof typeof GuildSetting } - ): Promise { - let settingName: string = setting - let result: GuildChannel | string | boolean | number | null - if (setting === 'primaryColor') { - const color = message.guild.primaryColor?.toString(16) ?? '' - result = `0x${color}${'0'.repeat(6 - color.length)}` - } else if (setting.includes('Channel') || setting.includes('Category')) { - settingName = setting.slice(0, -2) - // @ts-expect-error - result = message.guild[settingName] - } else if (setting.includes('Id')) { - result = message.guild[setting] - settingName = setting.slice(0, -2) - } else { - result = message.guild[setting] - } - - return await message.reply(`The ${settingName} is ${result instanceof GuildChannel ? result.toString() : `\`${String(result)}\``}.`) - } -} diff --git a/src/commands/settings/groups.ts b/src/commands/settings/groups.ts deleted file mode 100644 index e21d408b..00000000 --- a/src/commands/settings/groups.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import { type Message, MessageEmbed } from 'discord.js' -import BaseCommand from '../base' -import type { Group } from '../../structures' -import applicationConfig from '../../configs/application' -import { discordService } from '../../services' - -export default class GroupsCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'groups', - description: 'Lists all role and channel groups.', - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'group', - prompt: 'What group would you like to know the information of?', - type: 'arora-group', - default: '' - }] - }) - } - - public async run ( - message: CommandoMessage, - { group }: { group: Group | '' } - ): Promise { - if (group !== '') { - const embed = new MessageEmbed() - .setTitle(`Group ${group.id}`) - .addField('Name', group.name, true) - .addField('Type', group.type, true) - .addField('Guarded', group.guarded ? 'yes' : 'no', true) - .setColor(message.guild.primaryColor ?? applicationConfig.defaultColor) - if (group.isChannelGroup()) { - const channelsString = Array.from(group.channels.cache.values()).join(' ') - embed.addField('Channels', channelsString !== '' ? channelsString : 'none') - } else if (group.isRoleGroup()) { - const rolesString = Array.from(group.roles.cache.values()).join(' ') - embed.addField('Roles', rolesString !== '' ? rolesString : 'none') - } - return await message.replyEmbed(embed) - } else { - if (message.guild.groups.cache.size === 0) { - return await message.reply('No groups found.') - } - - const embeds = discordService.getListEmbeds( - 'Groups', - message.guild.groups.cache.values(), - getGroupRow - ) - for (const embed of embeds) { - await message.replyEmbed(embed) - } - return null - } - } -} - -function getGroupRow (group: Group): string { - return `${group.id}. \`${group.name}\`` -} diff --git a/src/commands/settings/link-channel.ts b/src/commands/settings/link-channel.ts deleted file mode 100644 index 7609bc7c..00000000 --- a/src/commands/settings/link-channel.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import type { Message, TextChannel, VoiceChannel } from 'discord.js' -import BaseCommand from '../base' - -export default class LinkChannelCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'linkchannel', - aliases: ['linkc'], - description: 'Links a voice channel to a text channel.', - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'fromChannel', - prompt: 'What voice channel would you like to link?', - type: 'voice-channel' - }, { - key: 'toChannel', - prompt: 'What text channel would you like to link to this voice channel?', - type: 'text-channel' - }] - }) - } - - public async run ( - message: CommandoMessage, - { fromChannel, toChannel }: { fromChannel: VoiceChannel, toChannel: TextChannel } - ): Promise { - await fromChannel.linkChannel(toChannel) - - // eslint-disable-next-line @typescript-eslint/no-base-to-string - return await message.reply(`Successfully linked voice channel ${fromChannel.toString()} to text channel ${toChannel.toString()}.`) - } -} diff --git a/src/commands/settings/link-ticket-type.ts b/src/commands/settings/link-ticket-type.ts deleted file mode 100644 index 062849e7..00000000 --- a/src/commands/settings/link-ticket-type.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import type { GuildEmoji, Message } from 'discord.js' -import BaseCommand from '../base' -import type { TicketType } from '../../structures' - -export default class LinkTicketTypeCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'linktickettype', - aliases: ['linktt'], - description: 'Links a message reaction to a ticket type.', - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'type', - prompt: 'What ticket type would you like to link?', - type: 'ticket-type' - }, { - key: 'emoji', - prompt: 'What emoji would you like to link to this ticket type?', - type: 'custom-emoji|default-emoji' - }, { - key: 'message', - prompt: 'On what message would you like this emoji to be reacted?', - type: 'message' - }] - }) - } - - public async run ( - message: CommandoMessage, - { type, message: bindMessage, emoji }: { - type: TicketType - message: Message - emoji: GuildEmoji | string - } - ): Promise { - type = await message.guild.ticketTypes.link(type, bindMessage, emoji) - - return await message.reply(`Successfully linked emoji ${type.emoji?.toString() ?? 'Unknown'} on message \`${type.messageId ?? 'unknown'}\` to ticket type \`${type.name}\`.`) - } -} diff --git a/src/commands/settings/panels.ts b/src/commands/settings/panels.ts deleted file mode 100644 index 589235f8..00000000 --- a/src/commands/settings/panels.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import BaseCommand from '../base' -import type { Message } from 'discord.js' -import type { Panel } from '../../structures' -import { discordService } from '../../services' - -export default class PanelsCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'panels', - aliases: ['pnls'], - description: 'Lists all panels.', - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'panel', - prompt: 'What panel would you like to know the information of?', - type: 'panel', - default: '' - }] - }) - } - - public async run ( - message: CommandoMessage, - { panel }: { panel: Panel | '' } - ): Promise { - if (panel !== '') { - return await message.replyEmbed(panel.embed) - } else { - if (message.guild.panels.cache.size === 0) { - return await message.reply('No panels found.') - } - - const embeds = discordService.getListEmbeds( - 'Panels', - message.guild.panels.cache.values(), - getPanelRow - ) - for (const embed of embeds) { - await message.replyEmbed(embed) - } - return null - } - } -} - -function getPanelRow (panel: Panel): string { - return `${panel.id}. \`${panel.name}\`` -} diff --git a/src/commands/settings/permissions.ts b/src/commands/settings/permissions.ts deleted file mode 100644 index f3628dde..00000000 --- a/src/commands/settings/permissions.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { type Collection, GuildMember, type Message, MessageEmbed, type Role } from 'discord.js' -import type { Command, CommandGroup, CommandoClient, CommandoMessage } from 'discord.js-commando' -import BaseCommand from '../base' -import type { RoleGroup } from '../../structures' -import applicationConfig from '../../configs/application' - -export default class PermissionsCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'permissions', - description: 'Lists a member\'s, role\'s or group\'s command permissions.', - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'memberOrRoleOrGroup', - label: 'member/role/group', - prompt: 'Of what member, role or group would you like to know the command permissions?', - type: 'member|role-group|role' - }] - }) - } - - public async run ( - message: CommandoMessage, - { memberOrRoleOrGroup }: { memberOrRoleOrGroup: GuildMember | Role | RoleGroup } - ): Promise { - const embed = new MessageEmbed() - .setColor(message.guild.primaryColor ?? applicationConfig.defaultColor) - if (memberOrRoleOrGroup instanceof GuildMember) { - embed - .setTitle(`${memberOrRoleOrGroup.user.tag}'s Permissions`) - .setThumbnail(memberOrRoleOrGroup.user.displayAvatarURL()) - } else { - embed - .setTitle(`${memberOrRoleOrGroup.name}'s Permissions`) - .addField('Note', 'If a command group has a permission with allow: true (e.g. "**Settings: true' + - '**"), all commands in it without a permission (e.g. "createtag: `null`") will implicitly also have a ' + - 'permission with allow: true.') - } - - const fn = memberOrRoleOrGroup instanceof GuildMember - ? memberOrRoleOrGroup.canRunCommand.bind(memberOrRoleOrGroup) - : (command: Command | CommandGroup) => memberOrRoleOrGroup.permissionFor(command, true) - const groups = this.client.registry.groups - .filter(group => !group.guarded && group.id !== 'util' && group.isEnabledIn(message.guild)) - .sort((groupA, groupB) => groupB.commands.size - groupA.commands.size) - for (const group of groups.values()) { - const commands = group.commands.filter(command => !command.guarded && command.isEnabledIn(message.guild)) - const commandsPermissions = getCommandsPermissions(commands, fn) - if (commandsPermissions !== '') { - embed.addField(`${group.name}: ${String(fn(group))}`, commandsPermissions, true) - } - } - - return await message.replyEmbed(embed) - } -} - -function getCommandsPermissions ( - commands: Collection, - fn: (command: Command) => boolean | null -): string { - let field = '' - for (const command of commands.values()) { - field += `${command.name}: \`${String(fn(command))}\`\n` - } - return field -} diff --git a/src/commands/settings/permit.ts b/src/commands/settings/permit.ts deleted file mode 100644 index 84b229d0..00000000 --- a/src/commands/settings/permit.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { Command, type CommandGroup, type CommandoClient, type CommandoMessage } from 'discord.js-commando' -import { type Message, Role } from 'discord.js' -import BaseCommand from '../base' -import type { RoleGroup } from '../../structures' -import { argumentUtil } from '../../util' - -const { validateNoneOrType, parseNoneOrType } = argumentUtil - -export default class PermitCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'permit', - description: 'Grants a role or group permission to use or explicitly not use a command or command group. Can ' + - 'also be used to delete a permission.', - details: 'If a member is in at least one role or group that has a permission for a command or command group ' + - 'that explicitly allows them to **not** use it, they won\'t be able to run that command or commands in that ' + - 'group.', - examples: [ - 'permit @Admin Settings true', 'permit @Admin Settings false', 'permit @Admin Settings none', - 'permit @Admin createtag true', 'permit @Admin createtag false', 'permit @Admin createtag none', - 'permit adminGroup Settings true', 'permit adminGroup Settings false', 'permit adminGroup Settings none', - 'permit adminGroup createtag true', 'permit adminGroup createtag false', 'permit adminGroup createtag none' - ], - clientPermissions: ['SEND_MESSAGES'], - userPermissions: ['ADMINISTRATOR'], - guarded: true, - args: [{ - key: 'roleOrGroup', - label: 'role/group', - prompt: 'For what role or group do you want to create, edit or delete a permission?', - type: 'role-group|role' - }, { - key: 'commandOrGroup', - label: 'command/group', - prompt: 'For what command or command group do you want to create, edit or delete a permission?', - type: 'command|group' - }, { - key: 'allow', - prompt: 'What do you want the allow value of this permission to be? Reply with "none" if you want to delete' + - ' the permission.', - type: 'boolean', - validate: validateNoneOrType, - parse: parseNoneOrType - }] - }) - } - - public async run ( - message: CommandoMessage, - { roleOrGroup, commandOrGroup, allow }: { - roleOrGroup: Role | RoleGroup - commandOrGroup: Command | CommandGroup - allow: boolean - } - ): Promise { - const commandType = commandOrGroup instanceof Command ? 'command' : 'group' - const permissibleType = roleOrGroup instanceof Role ? 'role' : 'group' - // eslint-disable-next-line @typescript-eslint/no-base-to-string - const subject = `${permissibleType} ${roleOrGroup instanceof Role ? roleOrGroup.toString() : `\`${roleOrGroup.toString()}\``}` - - if (typeof allow === 'undefined') { - await roleOrGroup.aroraPermissions.delete(commandOrGroup) - return await message.reply(`Successfully deleted \`${commandOrGroup.name}\` ${commandType} permission from ${subject}.`, { - allowedMentions: { users: [message.author.id] } - }) - } else { - const permission = roleOrGroup.aroraPermissions.resolve(commandOrGroup) - if (permission !== null) { - await permission.update({ allow }) - return await message.reply(`Successfully edited \`${commandOrGroup.name}\` ${commandType} permission for ${subject} to allow: \`${String(allow)}\`.`, { - allowedMentions: { users: [message.author.id] } - }) - } else { - await roleOrGroup.aroraPermissions.create(commandOrGroup, allow) - return await message.reply(`Successfully created \`${commandOrGroup.name}\` ${commandType} permission for ${subject} with allow: \`${String(allow)}\`.`, { - allowedMentions: { users: [message.author.id] } - }) - } - } - } -} diff --git a/src/commands/settings/post-panel.ts b/src/commands/settings/post-panel.ts deleted file mode 100644 index 0e297773..00000000 --- a/src/commands/settings/post-panel.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import type { Message, TextChannel } from 'discord.js' -import BaseCommand from '../base' -import type { Panel } from '../../structures' -import { argumentUtil } from '../../util' - -const { validateNoneOrType, parseNoneOrType } = argumentUtil - -export default class PostPanelCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'postpanel', - aliases: ['postpnl'], - description: 'Posts a panel in a channel.', - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'panel', - prompt: 'What panel would you like to post?', - type: 'panel' - }, { - key: 'channel', - prompt: 'In what channel do you want to post this panel? Reply with "none" if you want to remove the panel ' + - 'from the channel it\'s posted in.', - type: 'text-channel', - validate: validateNoneOrType, - parse: parseNoneOrType - }] - }) - } - - public async run ( - message: CommandoMessage, - { panel, channel }: { - panel: Panel - channel?: TextChannel - } - ): Promise { - panel = await message.guild.panels.post(panel, channel) - - return await message.reply(typeof channel !== 'undefined' - // eslint-disable-next-line @typescript-eslint/no-base-to-string - ? `Successfully posted panel \`${panel.name}\` in ${channel.toString()}.` - : `Successfully removed panel \`${panel.name}\` from channel.` - ) - } -} diff --git a/src/commands/settings/raw-panel.ts b/src/commands/settings/raw-panel.ts deleted file mode 100644 index b96492f2..00000000 --- a/src/commands/settings/raw-panel.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import BaseCommand from '../base' -import type { Message } from 'discord.js' -import type { Panel } from '../../structures' - -export default class RawPanelCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'rawpanel', - aliases: ['rawpnl'], - description: 'Posts the raw content of a panel.', - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'panel', - prompt: 'What panel would you like to know the raw content of?', - type: 'panel' - }] - }) - } - - public async run ( - message: CommandoMessage, - { panel }: { panel: Panel } - ): Promise { - return await message.reply( - panel.content, - { code: true, allowedMentions: { users: [message.author.id] } } - ) - } -} diff --git a/src/commands/settings/raw-tag.ts b/src/commands/settings/raw-tag.ts deleted file mode 100644 index cbf5c5db..00000000 --- a/src/commands/settings/raw-tag.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import BaseCommand from '../base' -import type { Message } from 'discord.js' -import type { Tag } from '../../structures' - -export default class RawTagCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'rawtag', - description: 'Posts the raw content of a tag.', - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'tag', - type: 'tag', - prompt: 'What tag would you like the raw content of?' - }] - }) - } - - public async run ( - message: CommandoMessage, - { tag }: { tag: Tag } - ): Promise { - return await message.reply( - tag._content, - { code: true, allowedMentions: { users: [message.author.id] } } - ) - } -} diff --git a/src/commands/settings/remove-from-group.ts b/src/commands/settings/remove-from-group.ts deleted file mode 100644 index c98cb2ec..00000000 --- a/src/commands/settings/remove-from-group.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import { type Message, Role, TextChannel } from 'discord.js' -import BaseCommand from '../base' -import type { Group } from '../../structures' - -export default class RemoveFromGroupCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'removefromgroup', - description: 'Removes a channel or role from a group.', - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'group', - prompt: 'From what group do you want to remove a channel or role?', - type: 'arora-group' - }, { - key: 'channelOrRole', - prompt: 'What channel or role do you want to remove from this group?', - type: 'text-channel|role' - }] - }) - } - - public async run ( - message: CommandoMessage, - { group, channelOrRole }: { - group: Group - channelOrRole: TextChannel | Role - } - ): Promise { - if (group.isChannelGroup() && channelOrRole instanceof TextChannel) { - await group.channels.remove(channelOrRole) - } else if (group.isRoleGroup() && channelOrRole instanceof Role) { - await group.roles.remove(channelOrRole) - } else { - return await message.reply(`Cannot remove a ${channelOrRole instanceof TextChannel ? 'channel' : 'role'} from a ${group.type} group.`) - } - - // eslint-disable-next-line @typescript-eslint/no-base-to-string - return await message.reply(`Successfully removed ${group.type} ${channelOrRole.toString()} from group \`${group.name}\`.`, { - allowedMentions: { users: [message.author.id] } - }) - } -} diff --git a/src/commands/settings/role-bindings.ts b/src/commands/settings/role-bindings.ts deleted file mode 100644 index edc2b462..00000000 --- a/src/commands/settings/role-bindings.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import { type Message, MessageEmbed } from 'discord.js' -import BaseCommand from '../base' -import type { RoleBinding } from '../../structures' -import applicationConfig from '../../configs/application' -import { discordService } from '../../services' -import lodash from 'lodash' - -export default class RoleBindingsCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'rolebindings', - aliases: ['rolebnds', 'rolebinding', 'rolebnd'], - description: 'Lists all Roblox rank to Discord role bindings.', - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'roleBinding', - prompt: 'What role binding would you like to know the information of?', - type: 'role-binding', - default: '' - }] - }) - } - - public async run ( - message: CommandoMessage, - { roleBinding }: { roleBinding: RoleBinding | '' } - ): Promise { - if (roleBinding !== '') { - const embed = new MessageEmbed() - .addField(`Role Binding ${roleBinding.id}`, `\`${roleBinding.robloxGroupId}\` \`${getRangeString(roleBinding.min, roleBinding.max)}\` => ${roleBinding.role?.toString() ?? 'Unknown'}`) - .setColor(message.guild.primaryColor ?? applicationConfig.defaultColor) - return await message.replyEmbed(embed) - } else { - await message.guild.roleBindings.fetch() - if (message.guild.roleBindings.cache.size === 0) { - return await message.reply('No role bindings found.') - } - - const embeds = discordService.getListEmbeds( - 'Role Bindings', - Object.values(lodash.groupBy(Array.from(message.guild.roleBindings.cache.values()), 'roleId')), - getGroupedRoleBindingRow - ) - for (const embed of embeds) { - await message.replyEmbed(embed) - } - return null - } - } -} - -function getGroupedRoleBindingRow (roleBindings: RoleBinding[]): string { - let result = `${roleBindings[0].role?.toString() ?? 'Unknown'}\n` - for (const roleBinding of roleBindings) { - result += `${roleBinding.id}. \`${roleBinding.robloxGroupId}\` \`${getRangeString(roleBinding.min, roleBinding.max)}\`\n` - } - return result -} - -function getRangeString (min: number, max: number | null): string { - return `${max !== null ? '[' : ''}${min}${max !== null ? `, ${max}]` : ''}` -} diff --git a/src/commands/settings/role-messages.ts b/src/commands/settings/role-messages.ts deleted file mode 100644 index 2d0a1f37..00000000 --- a/src/commands/settings/role-messages.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import { type Message, MessageEmbed } from 'discord.js' -import BaseCommand from '../base' -import type { RoleMessage } from '../../structures' -import applicationConfig from '../../configs/application' -import { discordService } from '../../services' -import lodash from 'lodash' - -export default class RoleMessagesCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'rolemessages', - aliases: ['rolemsgs', 'rolemessage', 'rolemsg'], - description: 'Lists all role messages.', - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'roleMessage', - prompt: 'What role message would you like to know the information of?', - type: 'role-message', - default: '' - }] - }) - } - - public async run ( - message: CommandoMessage, - { roleMessage }: { roleMessage: RoleMessage | '' } - ): Promise { - if (roleMessage !== '') { - const embed = new MessageEmbed() - .addField(`Role Message ${roleMessage.id}`, `Message ID: \`${roleMessage.messageId ?? 'unknown'}\`, ${roleMessage.emoji?.toString() ?? 'Unknown'} => ${roleMessage.role?.toString() ?? 'Unknown'}`) - .setColor(message.guild.primaryColor ?? applicationConfig.defaultColor) - return await message.replyEmbed(embed) - } else { - if (message.guild.roleMessages.cache.size === 0) { - return await message.reply('No role messages found.') - } - - const embeds = discordService.getListEmbeds( - 'Role Messages', - Object.values(lodash.groupBy(Array.from(message.guild.roleMessages.cache.values()), 'messageId')), - getGroupedRoleMessageRow - ) - for (const embed of embeds) { - await message.replyEmbed(embed) - } - return null - } - } -} - -function getGroupedRoleMessageRow (roleMessages: RoleMessage[]): string { - let result = `**${roleMessages[0].messageId ?? 'unknown'}**\n` - for (const roleMessage of roleMessages) { - result += `${roleMessage.id}. ${roleMessage.emoji?.toString() ?? 'Unknown'} => ${roleMessage.role?.toString() ?? 'Unknown'}\n` - } - return result -} diff --git a/src/commands/settings/set-activity.ts b/src/commands/settings/set-activity.ts deleted file mode 100644 index be23b981..00000000 --- a/src/commands/settings/set-activity.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { type ActivityType, Constants, type Message } from 'discord.js' -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import BaseCommand from '../base' -import { argumentUtil } from '../../util' - -const { ActivityTypes } = Constants -const { parseNoneOrType, urlRegex, validateNoneOrType } = argumentUtil - -const endUrlRegex = new RegExp(`(?:\\s*)${urlRegex.toString().slice(1, -3)}$`, 'i') - -export default class SetActivityCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'setactivity', - aliases: ['activity'], - description: 'Sets the bot\'s activity.', - details: 'Can only be used if the bot is in exactly one guild. If you choose activity type "STREAMING", the ' + - 'bot will look for an URL at the end of your input for the name argument, remove it and use it as streaming ' + - 'link.', - clientPermissions: ['SEND_MESSAGES'], - userPermissions: ['ADMINISTRATOR'], - requiresSingleGuild: true, - args: [{ - key: 'name', - type: 'string', - prompt: 'What do you want the name of the activity to be? Reply with "none" if you want to change the ' + - 'activity back to the default one.', - parse: parseNoneOrType - }, { - key: 'type', - type: 'string', - prompt: 'What activity type do you want to use? Reply with "none" if you replied "none" to the previous ' + - 'question as well.', - oneOf: ActivityTypes - .filter(type => type !== 'CUSTOM_STATUS') - .map(type => type.toLowerCase()), - validate: validateNoneOrType, - parse: (type: Lowercase> | 'none') => ( - type === 'none' ? undefined : type.toUpperCase() - ) - }], - throttling: { - usages: 1, - duration: 10 * 60 - } - }) - } - - public async run ( - message: CommandoMessage, - { name, type }: { - name?: string - type?: ActivityType - } - ): Promise { - if (typeof name === 'undefined' || typeof type === 'undefined') { - await this.client.startActivityCarousel() - - return await message.reply('Successfully set activity back to default.') - } else { - const options: { type: ActivityType, url?: string } = { type } - if (type === 'STREAMING') { - const match = name.match(endUrlRegex) - if (match === null) { - return await message.reply('No URL specified.') - } - name = name.replace(endUrlRegex, '') - if (name === '') { - return await message.reply('Name cannot be empty.') - } - options.url = match[1] - } - - this.client.stopActivityCarousel() - await this.client.user?.setActivity(name, options) - - return await message.reply(`Successfully set activity to \`${type} ${name}\`.`) - } - } -} diff --git a/src/commands/settings/set-setting.ts b/src/commands/settings/set-setting.ts deleted file mode 100644 index 5972fb94..00000000 --- a/src/commands/settings/set-setting.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { CategoryChannel, GuildChannel, type Message, TextChannel } from 'discord.js' -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import { GuildSetting, type GuildUpdateOptions } from '../../extensions' -import { argumentUtil, util } from '../../util' -import BaseCommand from '../base' -import { VerificationProvider } from '../../util/constants' - -const { guildSettingTransformer, parseEnum, parseNoneOrType } = argumentUtil -const { getEnumKeys, getEnumValues } = util - -export default class SetSettingCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'setsetting', - aliases: ['set'], - description: 'Sets a guild\'s setting.', - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'setting', - prompt: 'What setting would you like to set?', - type: 'string', - oneOf: getEnumKeys(GuildSetting) - .map(guildSettingTransformer) - .map(attribute => attribute.toLowerCase()), - parse: parseEnum(GuildSetting, guildSettingTransformer) - }, { - key: 'value', - prompt: 'What would you like to set this setting to? Reply with "none" if you want to reset the setting.', - type: 'category-channel|text-channel|message|integer|boolean|string', - parse: parseNoneOrType - }] - }) - } - - public async run ( - message: CommandoMessage, - { setting, value }: { - setting: keyof typeof GuildSetting - value: CategoryChannel | TextChannel | Message | number | boolean | string | undefined - } - ): Promise { - const changes: Pick = {} - if (typeof value === 'undefined' && !['robloxUsernamesInNicknames', 'verificationPreference'].includes(setting)) { - changes[setting as Exclude] = null - } else { - if (setting === 'primaryColor') { - if (typeof value !== 'number') { - value = parseInt(String(value), 16) - if (isNaN(value)) { - return await message.reply('Invalid color.') - } - } else if (value < 0 || value > parseInt('0xffffff', 16)) { - return await message.reply('Color out of bounds.') - } - - changes.primaryColor = value - } else if (setting === 'robloxGroupId') { - if (typeof value !== 'number') { - return await message.reply('Invalid ID.') - } - - changes.robloxGroupId = value - } else if (setting === 'robloxUsernamesInNicknames') { - if (typeof value !== 'boolean') { - return await message.reply('Invalid boolean.') - } - - changes.robloxUsernamesInNicknames = value - } else if (setting === 'verificationPreference') { - if (typeof value !== 'string' || getEnumValues(VerificationProvider).includes(value.toLowerCase())) { - return await message.reply('Invalid verification provider.') - } - value = value.toLowerCase() - - changes.verificationPreference = value as VerificationProvider - } else if (setting.includes('Channel') || setting.includes('Category')) { - if (setting === 'ticketsCategoryId') { - if (!(value instanceof CategoryChannel)) { - return await message.reply('Invalid category channel.') - } - } else { - if (!(value instanceof TextChannel)) { - return await message.reply('Invalid channel.') - } - } - - changes[setting] = value.id - } - } - - await message.guild.update(changes) - - // eslint-disable-next-line @typescript-eslint/no-base-to-string - return await message.reply(`Successfully set ${setting.endsWith('Id') ? setting.slice(0, -2) : setting} to ${value instanceof GuildChannel ? value.toString() : `\`${String(value)}\``}.`) - } -} diff --git a/src/commands/settings/ticket-types.ts b/src/commands/settings/ticket-types.ts deleted file mode 100644 index f3e8b07a..00000000 --- a/src/commands/settings/ticket-types.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import { type Message, MessageEmbed } from 'discord.js' -import BaseCommand from '../base' -import type { TicketType } from '../../structures' -import applicationConfig from '../../configs/application' -import { discordService } from '../../services' - -export default class RoleBindingsCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'tickettypes', - description: 'Lists all ticket types.', - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'type', - prompt: 'What ticket type would you like to know the information of?', - type: 'ticket-type', - default: '' - }] - }) - } - - public async run ( - message: CommandoMessage, - { type }: { type: TicketType | '' } - ): Promise { - if (type !== '') { - const embed = new MessageEmbed() - .addField(`Ticket Type ${type.id}`, `Name: \`${type.name}\``) - .setColor(message.guild.primaryColor ?? applicationConfig.defaultColor) - return await message.replyEmbed(embed) - } else { - if (message.guild.ticketTypes.cache.size === 0) { - return await message.reply('No ticket types found.') - } - - const embeds = discordService.getListEmbeds( - 'Ticket Types', - message.guild.ticketTypes.cache.values(), - getTicketTypeRow - ) - for (const embed of embeds) { - await message.replyEmbed(embed) - } - return null - } - } -} - -function getTicketTypeRow (type: TicketType): string { - return `${type.id}. \`${type.name}\`` -} diff --git a/src/commands/settings/toggle-support.ts b/src/commands/settings/toggle-support.ts deleted file mode 100644 index a310db34..00000000 --- a/src/commands/settings/toggle-support.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import { type Message, MessageEmbed } from 'discord.js' -import BaseCommand from '../base' - -export default class ToggleSupportCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'togglesupport', - aliases: ['toggle'], - description: 'Enables/disables the support system.', - clientPermissions: ['SEND_MESSAGES'] - }) - } - - public async run (message: CommandoMessage): Promise { - await message.guild.update({ supportEnabled: !message.guild.supportEnabled }) - - const embed = new MessageEmbed() - .setColor(message.guild.supportEnabled ? 0x00ff00 : 0xff0000) - .setTitle('Successfully toggled support') - .setDescription(`Tickets System: **${message.guild.supportEnabled ? 'online' : 'offline'}**`) - return await message.replyEmbed(embed) - } -} diff --git a/src/commands/settings/unlink-channel.ts b/src/commands/settings/unlink-channel.ts deleted file mode 100644 index d0cf8cc2..00000000 --- a/src/commands/settings/unlink-channel.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import type { Message, TextChannel, VoiceChannel } from 'discord.js' -import BaseCommand from '../base' - -export default class UnlinkChannelCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'unlinkchannel', - aliases: ['unlinkc'], - description: 'Unlinks a text channel from a voice channel.', - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'fromChannel', - prompt: 'What voice channel would you like to unlink?', - type: 'voice-channel' - }, { - key: 'toChannel', - prompt: 'What text channel would you like to unlink from this voice channel?', - type: 'text-channel' - }] - }) - } - - public async run ( - message: CommandoMessage, - { fromChannel, toChannel }: { - fromChannel: VoiceChannel - toChannel: TextChannel - } - ): Promise { - await fromChannel.unlinkChannel(toChannel) - - // eslint-disable-next-line @typescript-eslint/no-base-to-string - return await message.reply(`Successfully unlinked text channel ${toChannel.toString()} from voice channel ${fromChannel.toString()}.`) - } -} diff --git a/src/commands/settings/unlink-ticket-type.ts b/src/commands/settings/unlink-ticket-type.ts deleted file mode 100644 index 5295e3b0..00000000 --- a/src/commands/settings/unlink-ticket-type.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { CommandoClient, CommandoMessage } from 'discord.js-commando' -import BaseCommand from '../base' -import type { Message } from 'discord.js' -import type { TicketType } from '../../structures' - -export default class UnlinkTicketTypeCommand extends BaseCommand { - public constructor (client: CommandoClient) { - super(client, { - group: 'settings', - name: 'unlinktickettype', - aliases: ['unlinktt'], - description: 'Unlinks a ticket type from a message reaction.', - clientPermissions: ['SEND_MESSAGES'], - args: [{ - key: 'type', - prompt: 'What ticket type would you like to unlink?', - type: 'ticket-type' - }] - }) - } - - public async run ( - message: CommandoMessage, - { type }: { type: TicketType } - ): Promise { - type = await message.guild.ticketTypes.unlink(type) - - return await message.reply(`Successfully unlinked message reaction from ticket type \`${type.name}\`.`) - } -} diff --git a/src/configs/application.ts b/src/configs/application.ts index fb4822e8..4a795c23 100644 --- a/src/configs/application.ts +++ b/src/configs/application.ts @@ -1,8 +1,5 @@ const applicationConfig: { [key: string]: any } = { - defaultPrefix: ';', defaultColor: 0xff82d1, - owner: '235476265325428736', - invite: 'https://discord.gg/tJFNC5Y', productionMainGuildId: '761634353859395595', developmentMainGuildId: '761634353859395595', apiEnabled: false diff --git a/src/configs/container.ts b/src/configs/container.ts index 605b6a57..1500e818 100644 --- a/src/configs/container.ts +++ b/src/configs/container.ts @@ -1,62 +1,198 @@ -import { AnnounceTrainingsJob, type BaseJob, HealthCheckJob, PremiumMembersReportJob } from '../jobs' -import { type BaseHandler, eventHandlers, packetHandlers } from '../client' +import * as argumentTypes from '../argument-types' +import * as entities from '../entities' +import * as jobs from '../jobs' +import * as managers from '../managers' +import * as services from '../services' +import * as structures from '../structures' +import { Argument, type ArgumentOptions, type BaseCommand } from '../interactions/application-commands' import { - Channel, - Command, - Emoji, - Group, - Guild, - GuildCommand, - Member, - Message, - Panel, - Permission, - Role, - RoleBinding, - RoleMessage, - Tag, - TagName, - Ticket, - TicketType -} from '../entities' + AroraClient, + type BaseHandler, + Dispatcher, + SettingProvider, + WebSocketManager, + eventHandlers, + packetHandlers +} from '../client' import { Container, type interfaces } from 'inversify' import { type Repository, getRepository } from 'typeorm' -import { constants } from '../util' +import type { BaseArgumentType } from '../argument-types' +import type { BaseJob } from '../jobs' +import { applicationCommands } from '../interactions' +import applicationConfig from '../configs/application' +import { constants } from '../utils' const { TYPES } = constants const container = new Container() const bind = container.bind.bind(container) +// Client +bind(TYPES.Client).to(AroraClient) + .inSingletonScope() +bind(TYPES.Dispatcher).to(Dispatcher) +bind(TYPES.SettingProvider).to(SettingProvider) + +if (applicationConfig.apiEnabled === true) { + bind(TYPES.WebSocketManager).to(WebSocketManager) + .inSingletonScope() +} + +// Arguments +bind>(TYPES.Argument).to(Argument) + +bind>>(TYPES.ArgumentFactory) + .toFactory, [ArgumentOptions]>( + (context: interfaces.Context) => { + return (options: ArgumentOptions) => { + const argument = context.container.get>(TYPES.Argument) + argument.setOptions(options) + return argument + } + } +) + +// Argument Types +bind>(TYPES.ArgumentType).to(argumentTypes.AlwaysArgumentType) + .whenTargetTagged('argumentType', 'always') +bind>(TYPES.ArgumentType).to(argumentTypes.BooleanArgumentType) + .whenTargetTagged('argumentType', 'boolean') +bind>(TYPES.ArgumentType).to(argumentTypes.CategoryChannelArgumentType) + .whenTargetTagged('argumentType', 'category-channel') +bind>(TYPES.ArgumentType).to(argumentTypes.ChannelGroupArgumentType) + .whenTargetTagged('argumentType', 'channel-group') +bind>(TYPES.ArgumentType).to(argumentTypes.CustomEmojiArgumentType) + .whenTargetTagged('argumentType', 'custom-emoji') +bind>(TYPES.ArgumentType).to(argumentTypes.DateArgumentType) + .whenTargetTagged('argumentType', 'date') +bind>(TYPES.ArgumentType).to(argumentTypes.DefaultEmojiArgumentType) + .whenTargetTagged('argumentType', 'default-emoji') +bind>(TYPES.ArgumentType).to(argumentTypes.GroupArgumentType) + .whenTargetTagged('argumentType', 'group') +bind>(TYPES.ArgumentType).to(argumentTypes.IntegerArgumentType) + .whenTargetTagged('argumentType', 'integer') +bind>(TYPES.ArgumentType).to(argumentTypes.JsonObjectArgumentType) + .whenTargetTagged('argumentType', 'json-object') +bind>(TYPES.ArgumentType).to(argumentTypes.MessageArgumentType) + .whenTargetTagged('argumentType', 'message') +bind>(TYPES.ArgumentType).to(argumentTypes.PanelArgumentType) + .whenTargetTagged('argumentType', 'panel') +bind>(TYPES.ArgumentType).to(argumentTypes.RobloxUserArgumentType) + .inSingletonScope() // Singleton because this type has persistent state. + .whenTargetTagged('argumentType', 'roblox-user') +bind>(TYPES.ArgumentType).to(argumentTypes.RoleBindingArgumentType) + .whenTargetTagged('argumentType', 'role-binding') +bind>(TYPES.ArgumentType).to(argumentTypes.RoleGroupArgumentType) + .whenTargetTagged('argumentType', 'role-group') +bind>(TYPES.ArgumentType).to(argumentTypes.RoleMessageArgumentType) + .whenTargetTagged('argumentType', 'role-message') +bind>(TYPES.ArgumentType).to(argumentTypes.TagArgumentType) + .whenTargetTagged('argumentType', 'tag') +bind>(TYPES.ArgumentType).to(argumentTypes.TextChannelArgumentType) + .whenTargetTagged('argumentType', 'text-channel') +bind>(TYPES.ArgumentType).to(argumentTypes.TicketTypeArgumentType) + .whenTargetTagged('argumentType', 'ticket-type') +bind>(TYPES.ArgumentType).to(argumentTypes.TimeArgumentType) + .whenTargetTagged('argumentType', 'time') + +bind>>(TYPES.ArgumentTypeFactory).toFactory, [string]>( + (context: interfaces.Context) => { + return (argumentTypeName: string) => { + return context.container.getTagged>(TYPES.ArgumentType, 'argumentType', argumentTypeName) + } + } +) + +// Commands +bind(TYPES.Command).to(applicationCommands.BansCommand) + .whenTargetTagged('command', 'bans') +bind(TYPES.Command).to(applicationCommands.DemoteCommand) + .whenTargetTagged('command', 'demote') +bind(TYPES.Command).to(applicationCommands.ExilesCommand) + .whenTargetTagged('command', 'exiles') +bind(TYPES.Command).to(applicationCommands.PersistentRolesCommand) + .whenTargetTagged('command', 'persistentroles') +bind(TYPES.Command).to(applicationCommands.PromoteCommand) + .whenTargetTagged('command', 'promote') +bind(TYPES.Command).to(applicationCommands.ShoutCommand) + .whenTargetTagged('command', 'shout') +bind(TYPES.Command).to(applicationCommands.TrainingsCommand) + .whenTargetTagged('command', 'trainings') + +bind(TYPES.Command).to(applicationCommands.RestartCommand) + .whenTargetTagged('command', 'restart') +bind(TYPES.Command).to(applicationCommands.StatusCommand) + .whenTargetTagged('command', 'status') + +bind(TYPES.Command).to(applicationCommands.BoostInfoCommand) + .whenTargetTagged('command', 'boostinfo') +bind(TYPES.Command).to(applicationCommands.DeleteSuggestionCommand) + .whenTargetTagged('command', 'deletesuggestion') +bind(TYPES.Command).to(applicationCommands.GetShoutCommand) + .whenTargetTagged('command', 'getshout') +bind(TYPES.Command).to(applicationCommands.MemberCountCommand) + .whenTargetTagged('command', 'membercount') +bind(TYPES.Command).to(applicationCommands.PollCommand) + .whenTargetTagged('command', 'poll') +bind(TYPES.Command).to(applicationCommands.SuggestCommand) + .whenTargetTagged('command', 'suggest') +bind(TYPES.Command).to(applicationCommands.TagCommand) + .whenTargetTagged('command', 'tag') +bind(TYPES.Command).to(applicationCommands.WhoIsCommand) + .whenTargetTagged('command', 'whois') + +bind(TYPES.Command).to(applicationCommands.ChannelLinksCommand) + .whenTargetTagged('command', 'channellinks') +bind(TYPES.Command).to(applicationCommands.CloseTicketCommand) + .whenTargetTagged('command', 'closeticket') +bind(TYPES.Command).to(applicationCommands.GroupsCommand) + .whenTargetTagged('command', 'groups') +bind(TYPES.Command).to(applicationCommands.PanelsCommand) + .whenTargetTagged('command', 'panels') +bind(TYPES.Command).to(applicationCommands.RoleBindingsCommand) + .whenTargetTagged('command', 'rolebindings') +bind(TYPES.Command).to(applicationCommands.RoleMessagesCommand) + .whenTargetTagged('command', 'rolemessages') +bind(TYPES.Command).to(applicationCommands.SetActivityCommand) + .whenTargetTagged('command', 'setactivity') +bind(TYPES.Command).to(applicationCommands.SettingsCommand) + .whenTargetTagged('command', 'settings') +bind(TYPES.Command).to(applicationCommands.TagsCommand) + .whenTargetTagged('command', 'tags') +bind(TYPES.Command).to(applicationCommands.TicketTypesCommand) + .whenTargetTagged('command', 'tickettypes') +bind(TYPES.Command).to(applicationCommands.ToggleSupportCommand) + .whenTargetTagged('command', 'togglesupport') + +bind>(TYPES.CommandFactory).toFactory( + (context: interfaces.Context) => { + return (commandName: string) => { + const command = context.container.getTagged(TYPES.Command, 'command', commandName) + command?.setOptions(Reflect.getMetadata('options', command.constructor)) + return command + } + } +) + // Event Handlers bind(TYPES.Handler).to(eventHandlers.ChannelDeleteEventHandler) .whenTargetTagged('eventHandler', 'channelDelete') -bind(TYPES.Handler).to(eventHandlers.CommandCancelEventHandler) - .whenTargetTagged('eventHandler', 'commandCancel') -bind(TYPES.Handler).to(eventHandlers.CommandErrorEventHandler) - .whenTargetTagged('eventHandler', 'commandError') -bind(TYPES.Handler).to(eventHandlers.CommandPrefixChangeEventHandler) - .whenTargetTagged('eventHandler', 'commandPrefixChange') -bind(TYPES.Handler).to(eventHandlers.CommandRunEventHandler) - .whenTargetTagged('eventHandler', 'commandRun') -bind(TYPES.Handler).to(eventHandlers.CommandStatusChangeEventHandler) - .whenTargetTagged('eventHandler', 'commandStatusChange') bind(TYPES.Handler).to(eventHandlers.EmojiDeleteEventHandler) .whenTargetTagged('eventHandler', 'emojiDelete') -bind(TYPES.Handler).to(eventHandlers.GroupStatusChangeEventHandler) - .whenTargetTagged('eventHandler', 'groupStatusChange') bind(TYPES.Handler).to(eventHandlers.GuildCreateEventHandler) .whenTargetTagged('eventHandler', 'guildCreate') bind(TYPES.Handler).to(eventHandlers.GuildMemberAddEventHandler) .whenTargetTagged('eventHandler', 'guildMemberAdd') bind(TYPES.Handler).to(eventHandlers.GuildMemberUpdateEventHandler) .whenTargetTagged('eventHandler', 'guildMemberUpdate') +bind(TYPES.Handler).to(eventHandlers.InteractionCreateEventHandler) + .whenTargetTagged('eventHandler', 'interactionCreate') bind(TYPES.Handler).to(eventHandlers.MessageDeleteEventHandler) .whenTargetTagged('eventHandler', 'messageDelete') bind(TYPES.Handler).to(eventHandlers.MessageDeleteBulkEventHandler) .whenTargetTagged('eventHandler', 'messageDeleteBulk') -bind(TYPES.Handler).to(eventHandlers.MessageEventHandler) - .whenTargetTagged('eventHandler', 'message') +bind(TYPES.Handler).to(eventHandlers.MessageCreateEventHandler) + .whenTargetTagged('eventHandler', 'messageCreate') bind(TYPES.Handler).to(eventHandlers.MessageReactionAddEventHandler) .whenTargetTagged('eventHandler', 'messageReactionAdd') bind(TYPES.Handler).to(eventHandlers.MessageReactionRemoveEventHandler) @@ -75,26 +211,18 @@ bind>(TYPES.EventHandlerFactory).toFactory(TYPES.Job).to(AnnounceTrainingsJob) - .whenTargetTagged('job', 'announceTrainings') -bind(TYPES.Job).to(HealthCheckJob) - .whenTargetTagged('job', 'healthCheck') -bind(TYPES.Job).to(PremiumMembersReportJob) - .whenTargetTagged('job', 'premiumMembersReport') - -bind>(TYPES.JobFactory).toFactory( - (context: interfaces.Context) => { - return (jobName: string) => { - return context.container.getTagged(TYPES.Job, 'job', jobName) - } - } -) +bind(TYPES.Job).to(jobs.AnnounceTrainingsJob) + .whenTargetNamed('announceTrainings') +bind(TYPES.Job).to(jobs.HealthCheckJob) + .whenTargetNamed('healthCheck') +bind(TYPES.Job).to(jobs.PremiumMembersReportJob) + .whenTargetNamed('premiumMembersReport') + +bind>(TYPES.JobFactory).toAutoNamedFactory(TYPES.Job) // Packet Handlers bind(TYPES.Handler).to(packetHandlers.RankChangePacketHandler) .whenTargetTagged('packetHandler', 'rankChange') -bind(TYPES.Handler).to(packetHandlers.TrainDeveloperPayoutReportPacketHandler) - .whenTargetTagged('packetHandler', 'trainDeveloperPayoutReport') bind>(TYPES.PacketHandlerFactory).toFactory( (context: interfaces.Context) => { @@ -104,57 +232,138 @@ bind>(TYPES.PacketHandlerFactory).toFactory(TYPES.Manager).to(managers.GroupRoleManager) + .whenTargetNamed('GroupRoleManager') +bind(TYPES.Manager).to(managers.GroupTextChannelManager) + .whenTargetNamed('GroupTextChannelManager') +bind(TYPES.Manager).to(managers.GuildContextManager) + .inSingletonScope() + .whenTargetNamed('GuildContextManager') +bind(TYPES.Manager).to(managers.GuildGroupManager) + .whenTargetNamed('GuildGroupManager') +bind(TYPES.Manager).to(managers.GuildPanelManager) + .whenTargetNamed('GuildPanelManager') +bind(TYPES.Manager).to(managers.GuildRoleBindingManager) + .whenTargetNamed('GuildRoleBindingManager') +bind(TYPES.Manager).to(managers.GuildRoleMessageManager) + .whenTargetNamed('GuildRoleMessageManager') +bind(TYPES.Manager).to(managers.GuildTagManager) + .whenTargetNamed('GuildTagManager') +bind(TYPES.Manager).to(managers.GuildTicketManager) + .whenTargetNamed('GuildTicketManager') +bind(TYPES.Manager).to(managers.GuildTicketTypeManager) + .whenTargetNamed('GuildTicketTypeManager') +bind(TYPES.Manager).to(managers.TagTagNameManager) + .whenTargetNamed('TagTagNameManager') +bind(TYPES.Manager).to(managers.TicketGuildMemberManager) + .whenTargetNamed('TicketGuildMemberManager') + +bind>>(TYPES.ManagerFactory) + // eslint-disable-next-line no-extra-parens + .toFactory<(...args: unknown[]) => managers.BaseManager, [string]>( + // @ts-expect-error + (context: interfaces.Context) => { + return >(managerName: string) => { + if (!context.container.isBoundNamed(TYPES.Manager, managerName)) { + throw new Error(`Unknown manager ${managerName}.`) + } + return (...args: T['setOptions'] extends ((...args: infer P) => void) ? P : never[]) => { + const manager = context.container.getNamed(TYPES.Manager, managerName) + manager.setOptions?.(...(args ?? [])) + return manager + } + } + } +) + +// Structures +bind(TYPES.Structure).to(structures.ChannelGroup) + .whenTargetNamed('ChannelGroup') +bind(TYPES.Structure).to(structures.Group) + .whenTargetNamed('Group') +bind(TYPES.Structure).to(structures.GuildContext) + .whenTargetNamed('GuildContext') +bind(TYPES.Structure).to(structures.Panel) + .whenTargetNamed('Panel') +bind(TYPES.Structure).to(structures.RoleBinding) + .whenTargetNamed('RoleBinding') +bind(TYPES.Structure).to(structures.RoleGroup) + .whenTargetNamed('RoleGroup') +bind(TYPES.Structure).to(structures.RoleMessage) + .whenTargetNamed('RoleMessage') +bind(TYPES.Structure).to(structures.Tag) + .whenTargetNamed('Tag') +bind(TYPES.Structure).to(structures.TagName) + .whenTargetNamed('TagName') +bind(TYPES.Structure).to(structures.Ticket) + .whenTargetNamed('Ticket') +bind(TYPES.Structure).to(structures.TicketType) + .whenTargetNamed('TicketType') + +bind>>(TYPES.StructureFactory) + // eslint-disable-next-line no-extra-parens + .toFactory<(...args: unknown[]) => structures.BaseStructure, [string]>( + (context: interfaces.Context) => { + return >(structureName: string) => { + if (!context.container.isBoundNamed(TYPES.Structure, structureName)) { + throw new Error(`Unknown structure ${structureName}.`) + } + return (...args: Parameters) => { + const structure = context.container.getNamed(TYPES.Structure, structureName) + structure.setOptions(args[0], ...args.slice(1)) + return structure + } + } + } +) + // Repositories -bind>(TYPES.ChannelRepository).toDynamicValue(() => { - return getRepository(Channel) -}) -bind>(TYPES.CommandRepository).toDynamicValue(() => { - return getRepository(Command) -}) -bind>(TYPES.EmojiRepository).toDynamicValue(() => { - return getRepository(Emoji) +bind>(TYPES.ChannelRepository).toDynamicValue(() => { + return getRepository(entities.Channel) }) -bind>(TYPES.GroupRepository).toDynamicValue(() => { - return getRepository(Group) +bind>(TYPES.EmojiRepository).toDynamicValue(() => { + return getRepository(entities.Emoji) }) -bind>(TYPES.GuildRepository).toDynamicValue(() => { - return getRepository(Guild) +bind>(TYPES.GroupRepository).toDynamicValue(() => { + return getRepository(entities.Group) }) -bind>(TYPES.GuildCommandRepository).toDynamicValue(() => { - return getRepository(GuildCommand) +bind>(TYPES.GuildRepository).toDynamicValue(() => { + return getRepository(entities.Guild) }) -bind>(TYPES.MemberRepository).toDynamicValue(() => { - return getRepository(Member) +bind>(TYPES.MemberRepository).toDynamicValue(() => { + return getRepository(entities.Member) }) -bind>(TYPES.MessageRepository).toDynamicValue(() => { - return getRepository(Message) +bind>(TYPES.MessageRepository).toDynamicValue(() => { + return getRepository(entities.Message) }) -bind>(TYPES.PanelRepository).toDynamicValue(() => { - return getRepository(Panel) +bind>(TYPES.PanelRepository).toDynamicValue(() => { + return getRepository(entities.Panel) }) -bind>(TYPES.PermissionRepository).toDynamicValue(() => { - return getRepository(Permission) +bind>(TYPES.RoleRepository).toDynamicValue(() => { + return getRepository(entities.Role) }) -bind>(TYPES.RoleRepository).toDynamicValue(() => { - return getRepository(Role) +bind>(TYPES.RoleBindingRepository).toDynamicValue(() => { + return getRepository(entities.RoleBinding) }) -bind>(TYPES.RoleBindingRepository).toDynamicValue(() => { - return getRepository(RoleBinding) +bind>(TYPES.RoleMessageRepository).toDynamicValue(() => { + return getRepository(entities.RoleMessage) }) -bind>(TYPES.RoleMessageRepository).toDynamicValue(() => { - return getRepository(RoleMessage) +bind>(TYPES.TagRepository).toDynamicValue(() => { + return getRepository(entities.Tag) }) -bind>(TYPES.TagRepository).toDynamicValue(() => { - return getRepository(Tag) +bind>(TYPES.TagNameRepository).toDynamicValue(() => { + return getRepository(entities.TagName) }) -bind>(TYPES.TagNameRepository).toDynamicValue(() => { - return getRepository(TagName) +bind>(TYPES.TicketRepository).toDynamicValue(() => { + return getRepository(entities.Ticket) }) -bind>(TYPES.TicketRepository).toDynamicValue(() => { - return getRepository(Ticket) -}) -bind>(TYPES.TicketTypeRepository).toDynamicValue(() => { - return getRepository(TicketType) +bind>(TYPES.TicketTypeRepository).toDynamicValue(() => { + return getRepository(entities.TicketType) }) +// Services +bind(TYPES.ChannelLinkService).to(services.ChannelLinkService) +bind(TYPES.PersistentRoleService).to(services.PersistentRoleService) + export default container diff --git a/src/entities/base.ts b/src/entities/base.ts new file mode 100644 index 00000000..7a3d1d29 --- /dev/null +++ b/src/entities/base.ts @@ -0,0 +1,3 @@ +export interface IdentifiableEntity { + id: number | string +} diff --git a/src/entities/command.ts b/src/entities/command.ts deleted file mode 100644 index 73b8daeb..00000000 --- a/src/entities/command.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm' -import { CommandType } from '../util/constants' -import GuildCommand from './guild-command' -import { IsNotEmpty } from 'class-validator' -import Permission from './permission' - -@Entity('commands') -export default class Command { - @PrimaryGeneratedColumn() - public readonly id!: number - - @Column({ length: 255 }) - @IsNotEmpty() - public name!: string - - @Column({ type: 'enum', enum: CommandType }) - public type!: CommandType - - @OneToMany(() => GuildCommand, guildCommand => guildCommand.command) - public guildCommands?: GuildCommand[] - - @OneToMany(() => Permission, permission => permission.command) - public permissions?: Permission[] -} diff --git a/src/entities/group.ts b/src/entities/group.ts index 5fe4b873..3fba7db5 100644 --- a/src/entities/group.ts +++ b/src/entities/group.ts @@ -1,18 +1,8 @@ -import { - Column, - Entity, - JoinColumn, - JoinTable, - ManyToMany, - ManyToOne, - OneToMany, - PrimaryGeneratedColumn -} from 'typeorm' +import { Column, Entity, JoinColumn, JoinTable, ManyToMany, ManyToOne, PrimaryGeneratedColumn } from 'typeorm' import Channel from './channel' -import { GroupType } from '../util/constants' +import { GroupType } from '../utils/constants' import Guild from './guild' import { IsNotEmpty } from 'class-validator' -import Permission from './permission' import Role from './role' @Entity('groups') @@ -52,7 +42,4 @@ export default class Group { inverseJoinColumn: { name: 'role_id' } }) public roles?: Role[] - - @OneToMany(() => Permission, permission => permission.group) - public permissions?: Permission[] } diff --git a/src/entities/guild-command.ts b/src/entities/guild-command.ts deleted file mode 100644 index d351fceb..00000000 --- a/src/entities/guild-command.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm' -import Command from './command' -import Guild from './guild' - -@Entity('guilds_commands') -export default class GuildCommand { - @PrimaryColumn({ type: 'bigint', name: 'guild_id' }) - public guildId!: string - - @PrimaryColumn({ name: 'command_id' }) - public commandId!: number - - @Column() - public enabled!: boolean - - @ManyToOne(() => Guild, guild => guild.guildCommands, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'guild_id' }) - public guild?: Guild - - @ManyToOne(() => Command, command => command.guildCommands, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'command_id' }) - public command?: Command -} diff --git a/src/entities/guild.ts b/src/entities/guild.ts index da68dfb9..34148189 100644 --- a/src/entities/guild.ts +++ b/src/entities/guild.ts @@ -1,9 +1,7 @@ import { Column, Entity, JoinColumn, OneToMany, OneToOne, PrimaryColumn } from 'typeorm' -import { IsNotEmpty, ValidateIf } from 'class-validator' import Channel from './channel' import Emoji from './emoji' import Group from './group' -import GuildCommand from './guild-command' import Member from './member' import Message from './message' import Panel from './panel' @@ -13,18 +11,13 @@ import RoleMessage from './role-message' import Tag from './tag' import Ticket from './ticket' import TicketType from './ticket-type' -import { VerificationProvider } from '../util/constants' +import { VerificationProvider } from '../utils/constants' @Entity('guilds') export default class Guild { @PrimaryColumn({ type: 'bigint' }) public id!: string - @Column('varchar', { name: 'command_prefix', nullable: true, length: 255 }) - @ValidateIf(guild => guild.commandPrefix != null) - @IsNotEmpty() - public commandPrefix?: string | null - @Column('int', { name: 'primary_color', nullable: true }) public primaryColor?: number | null @@ -60,9 +53,6 @@ export default class Guild { @Column('bigint', { name: 'tickets_category_id', nullable: true }) public ticketsCategoryId?: string | null - @OneToMany(() => GuildCommand, guildCommand => guildCommand.guild) - public guildCommands?: GuildCommand[] - @OneToMany(() => Tag, tag => tag.guild) public tags?: Tag[] diff --git a/src/entities/index.ts b/src/entities/index.ts index a30bf2d7..97c7c686 100644 --- a/src/entities/index.ts +++ b/src/entities/index.ts @@ -1,13 +1,11 @@ +export * from './base' export { default as Channel } from './channel' -export { default as Command } from './command' export { default as Emoji } from './emoji' export { default as Group } from './group' export { default as Guild } from './guild' -export { default as GuildCommand } from './guild-command' export { default as Member } from './member' export { default as Message } from './message' export { default as Panel } from './panel' -export { default as Permission } from './permission' export { default as Role } from './role' export { default as RoleBinding } from './role-binding' export { default as RoleMessage } from './role-message' diff --git a/src/entities/permission.ts b/src/entities/permission.ts deleted file mode 100644 index e6015e4d..00000000 --- a/src/entities/permission.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm' -import Command from './command' -import Group from './group' -import Role from './role' -import { decorators } from '../util' - -const { Xor } = decorators - -@Entity('permissions') -export default class Permission { - @PrimaryGeneratedColumn() - public readonly id!: number - - @Column() - public allow!: boolean - - @Column('bigint', { name: 'role_id', nullable: true }) - @Xor('groupId') - public roleId?: string | null - - @Column('int', { name: 'group_id', nullable: true }) - @Xor('roleId') - public groupId?: number | null - - @Column({ name: 'command_id' }) - public commandId!: number - - @ManyToOne(() => Role, role => role.permissions, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'role_id' }) - public role?: Role | null - - @ManyToOne(() => Group, group => group.permissions, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'group_id' }) - public group?: Group | null - - @ManyToOne(() => Command, command => command.permissions, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'command_id' }) - public command?: Command -} diff --git a/src/entities/role-message.ts b/src/entities/role-message.ts index 53ffc4c8..d9706931 100644 --- a/src/entities/role-message.ts +++ b/src/entities/role-message.ts @@ -4,7 +4,7 @@ import { IsNotEmpty, ValidateIf } from 'class-validator' import Guild from './guild' import Message from './message' import Role from './role' -import { decorators } from '../util' +import { decorators } from '../utils' const { Xor } = decorators diff --git a/src/entities/role.ts b/src/entities/role.ts index e7cfc070..a5a8de20 100644 --- a/src/entities/role.ts +++ b/src/entities/role.ts @@ -2,7 +2,6 @@ import { Column, Entity, JoinColumn, ManyToMany, ManyToOne, OneToMany, PrimaryCo import Group from './group' import Guild from './guild' import Member from './member' -import Permission from './permission' import RoleBinding from './role-binding' import RoleMessage from './role-message' @@ -29,7 +28,4 @@ export default class Role { @ManyToMany(() => Member, member => member.roles) public members?: Member[] - - @OneToMany(() => Permission, permission => permission.role) - public permissions?: Permission[] } diff --git a/src/entities/tag-name.ts b/src/entities/tag-name.ts index b3ec2ad8..77cf7534 100644 --- a/src/entities/tag-name.ts +++ b/src/entities/tag-name.ts @@ -15,4 +15,8 @@ export default class TagName { @ManyToOne(() => Tag, tag => tag.names, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'tag_id' }) public tag?: Tag + + public get id (): string { + return this.name + } } diff --git a/src/entities/ticket-type.ts b/src/entities/ticket-type.ts index 0e8d9628..b0337701 100644 --- a/src/entities/ticket-type.ts +++ b/src/entities/ticket-type.ts @@ -4,7 +4,7 @@ import { IsNotEmpty, ValidateIf } from 'class-validator' import Guild from './guild' import Message from './message' import Ticket from './ticket' -import { decorators } from '../util' +import { decorators } from '../utils' const { Nand } = decorators diff --git a/src/extensions/guild-member.ts b/src/extensions/guild-member.ts deleted file mode 100644 index ee5dd2a0..00000000 --- a/src/extensions/guild-member.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { type Collection, type GuildMember, Structures } from 'discord.js' -import type { Command, CommandGroup } from 'discord.js-commando' -import type { Member as MemberEntity, Role as RoleEntity } from '../entities' -import { bloxlinkAdapter, roVerAdapter } from '../adapters' -import { Repository } from 'typeorm' -import { VerificationProvider } from '../util/constants' -import { constants } from '../util' -import container from '../configs/container' -import getDecorators from 'inversify-inject-decorators' - -export interface VerificationData { - provider: VerificationProvider - robloxId: number - robloxUsername: string | null -} - -const { TYPES } = constants -const { lazyInject } = getDecorators(container) - -declare module 'discord.js' { - interface GuildMember { - verificationData: VerificationData | null - readonly robloxId: number | null - readonly robloxUsername: string | null - - canRunCommand: (command: Command | CommandGroup) => boolean - fetchPersistentRoles: () => Promise> - persistRole: (role: Role) => Promise - unpersistRole: (role: Role) => Promise - fetchVerificationData: (verificationPreference?: VerificationProvider) => Promise - } -} - -// @ts-expect-error -const AroraGuildMember: GuildMember = Structures.extend('GuildMember', GuildMember => { - class AroraGuildMember extends GuildMember { - @lazyInject(TYPES.MemberRepository) - private readonly memberRepository!: Repository - - public constructor (...args: any[]) { - // @ts-expect-error - super(...args) - - this.verificationData = null - } - - public override get robloxId (): number | null { - return this.verificationData?.robloxId ?? null - } - - public override get robloxUsername (): string | null { - return this.verificationData?.robloxUsername ?? null - } - - // @ts-expect-error - public override canRunCommand (command: Command | CommandGroup): boolean { - let result = null - const groupsChecked: number[] = [] - for (const role of this.roles.cache.values()) { - for (const group of role.groups.cache.values()) { - if (groupsChecked.includes(group.id)) { - continue - } - result = group.permissionFor(command) ?? result - if (result === false) { - return false - } - groupsChecked.push(group.id) - } - result = role.permissionFor(command) ?? result - if (result === false) { - return false - } - } - return result === true - } - - // @ts-expect-error - public override async fetchPersistentRoles (): Promise> { - const data = await this.getData(this) - - return this.guild.roles.cache.filter(role => ( - data?.roles.some(persistentRole => persistentRole.id === role.id) === true - )) - } - - // @ts-expect-error - public override async persistRole (role: Role): Promise { - await this.roles.add(role) - const data = await this.getData(this) ?? await this.memberRepository.save(this.memberRepository.create({ - userId: this.id, - guildId: this.guild.id - })) - if (typeof data.roles === 'undefined') { - data.roles = [] - } - - if (data.roles.some(otherRole => otherRole.id === role.id)) { - throw new Error('Member does already have role.') - } else { - data.roles.push({ id: role.id, guildId: this.guild.id }) - await this.memberRepository.save(data) - return this - } - } - - // @ts-expect-error - public override async unpersistRole (role: Role): Promise { - const data = await this.getData(this) - - if (typeof data === 'undefined' || !data?.roles.some(otherRole => otherRole.id === role.id)) { - throw new Error('Member does not have role.') - } else { - data.roles = data.roles.filter(otherRole => otherRole.id !== role.id) - await this.memberRepository.save(data) - await this.roles.remove(role) - return this - } - } - - // @ts-expect-error - public override async fetchVerificationData ( - verificationPreference = this.guild.verificationPreference - ): Promise { - if (this.verificationData?.provider === verificationPreference) { - return this.verificationData - } - - let data = null - let error - try { - const fetch = verificationPreference === VerificationProvider.RoVer ? fetchRoVerData : fetchBloxlinkData - data = await fetch(this.id, this.guild.id) - } catch (err) { - error = err - } - if ((data ?? false) === false) { - try { - const fetch = verificationPreference === VerificationProvider.RoVer ? fetchBloxlinkData : fetchRoVerData - data = await fetch(this.id, this.guild.id) - } catch (err) { - throw error ?? err - } - } - if (typeof data === 'number') { - data = { - provider: VerificationProvider.Bloxlink, - robloxId: data, - robloxUsername: null - } - } else if (data !== null) { - data = { - provider: VerificationProvider.RoVer, - ...data - } - } - - if (data === null || data.provider === this.guild.verificationPreference) { - this.verificationData = data - } - return data - } - - private async getData (member: GuildMember): Promise<(MemberEntity & { roles: RoleEntity[] }) | undefined> { - return await this.memberRepository.findOne( - { userId: member.id, guildId: member.guild.id }, - { relations: ['moderatingTickets', 'roles'] } - ) as (MemberEntity & { roles: RoleEntity[] }) | undefined - } - } - - return AroraGuildMember -}) - -export default AroraGuildMember - -async function fetchRoVerData (userId: string): Promise<{ robloxUsername: string, robloxId: number } | null> { - let response: { robloxUsername: string, robloxId: number } - try { - response = (await roVerAdapter('GET', `user/${userId}`)).data - } catch (err: any) { - if (err.response?.data?.errorCode === 404) { - return null - } - throw err.response?.data?.error ?? err - } - - return { - robloxUsername: response.robloxUsername, - robloxId: response.robloxId - } -} - -async function fetchBloxlinkData (userId: string, guildId?: string): Promise { - const response = (await bloxlinkAdapter( - 'GET', - `user/${userId}${typeof guildId !== 'undefined' ? `?guild=${guildId}` : ''}` - )).data - if (response.status === 'error') { - if ((response.error as string).includes('not linked')) { - return null - } - return response.status - } - - return (response.matchingAccount !== null || response.primaryAccount !== null) - ? parseInt(response.matchingAccount ?? response.primaryAccount) - : null -} diff --git a/src/extensions/guild.ts b/src/extensions/guild.ts deleted file mode 100644 index 4887bd76..00000000 --- a/src/extensions/guild.ts +++ /dev/null @@ -1,332 +0,0 @@ -import { - type CategoryChannel, - type Client, - type ColorResolvable, - type Guild, - GuildEmoji, - type Message, - MessageEmbed, - type MessageReaction, - type Snowflake, - Structures, - type TextChannel, - type User -} from 'discord.js' -import { - GuildGroupManager, - GuildMemberManager, - GuildPanelManager, - GuildRoleBindingManager, - GuildRoleMessageManager, - GuildTagManager, - GuildTicketManager, - GuildTicketTypeManager -} from '../managers' -import type { BaseJob } from '../jobs' -import type { BaseStructure } from '../structures' -import type { Guild as GuildEntity } from '../entities' -import { Repository } from 'typeorm' -import type { VerificationProvider } from '../util/constants' -import applicationConfig from '../configs/application' -import { constants } from '../util' -import container from '../configs/container' -import cron from 'node-cron' -import cronConfig from '../configs/cron' -import getDecorators from 'inversify-inject-decorators' - -export enum GuildSetting { - logsChannelId, - primaryColor, - ratingsChannelId, - robloxGroupId, - robloxUsernamesInNicknames, - suggestionsChannelId, - ticketArchivesChannelId, - ticketsCategoryId, - verificationPreference -} - -export interface GuildUpdateOptions { - commandPrefix?: string | null - logsChannelId?: Snowflake | null - primaryColor?: number | null - ratingsChannelId?: Snowflake | null - robloxGroupId?: number | null - robloxUsernamesInNicknames?: boolean - suggestionsChannelId?: Snowflake | null - supportEnabled?: boolean - ticketArchivesChannelId?: Snowflake | null - ticketsCategoryId?: Snowflake | null - verificationPreference?: VerificationProvider -} - -const { TYPES } = constants -const { lazyInject } = getDecorators(container) - -declare module 'discord.js' { - interface Guild { - logsChannelId: Snowflake | null - primaryColor: number | null - ratingsChannelId: Snowflake | null - robloxGroupId: number | null - robloxUsernamesInNicknames: boolean - suggestionsChannelId: Snowflake | null - supportEnabled: boolean - ticketArchivesChannelId: Snowflake | null - ticketsCategoryId: Snowflake | null - trainingsInfoPanelId: number | null - trainingsPanelId: number | null - verificationPreference: VerificationProvider - - members: GuildMemberManager - groups: GuildGroupManager - panels: GuildPanelManager - roleBindings: GuildRoleBindingManager - roleMessages: GuildRoleMessageManager - tags: GuildTagManager - tickets: GuildTicketManager - ticketTypes: GuildTicketTypeManager - - readonly logsChannel: TextChannel | null - readonly suggestionsChannel: TextChannel | null - readonly ratingsChannel: TextChannel | null - readonly ticketArchivesChannel: TextChannel | null - readonly ticketsCategory: CategoryChannel | null - - setup: (data: GuildEntity) => void - init: () => Promise - handleRoleMessage: (type: 'add' | 'remove', reaction: MessageReaction, user: User) => Promise - log: ( - author: User, - content: string, - options?: { color?: ColorResolvable, footer?: string } - ) => Promise - update: (data: GuildUpdateOptions) => Promise - } -} - -// @ts-expect-error -const AroraGuild: Guild = Structures.extend('Guild', Guild => { - class AroraGuild extends Guild implements Omit { - @lazyInject(TYPES.GuildRepository) - private readonly guildRepository!: Repository - - @lazyInject(TYPES.JobFactory) - private readonly jobFactory!: (jobName: string) => BaseJob - - public constructor (client: Client, data: object) { - super(client, data) - - this.members = new GuildMemberManager(this) - this.groups = new GuildGroupManager(this) - this.panels = new GuildPanelManager(this) - this.roleBindings = new GuildRoleBindingManager(this) - this.roleMessages = new GuildRoleMessageManager(this) - this.tags = new GuildTagManager(this) - this.tickets = new GuildTicketManager(this) - this.ticketTypes = new GuildTicketTypeManager(this) - } - - // @ts-expect-error - public override setup (data: GuildEntity): void { - this.logsChannelId = data.logsChannelId ?? null - this.primaryColor = data.primaryColor ?? null - this.ratingsChannelId = data.ratingsChannelId ?? null - this.robloxGroupId = data.robloxGroupId ?? null - this.robloxUsernamesInNicknames = data.robloxUsernamesInNicknames - this.suggestionsChannelId = data.suggestionsChannelId ?? null - this.supportEnabled = data.supportEnabled - this.ticketArchivesChannelId = data.ticketArchivesChannelId ?? null - this.ticketsCategoryId = data.ticketsCategoryId ?? null - this.verificationPreference = data.verificationPreference - - if (typeof data.groups !== 'undefined') { - for (const rawGroup of data.groups) { - this.groups.add(rawGroup) - } - } - - if (typeof data.panels !== 'undefined') { - for (const rawPanel of data.panels) { - this.panels.add(rawPanel) - } - } - - if (typeof data.roles !== 'undefined') { - for (const rawRole of data.roles) { - const role = this.roles.cache.get(rawRole.id) - if (typeof role !== 'undefined') { - role.setup(rawRole) - } - } - } - - if (typeof data.roleBindings !== 'undefined') { - for (const rawRoleBinding of data.roleBindings) { - this.roleBindings.add(rawRoleBinding) - } - } - - if (typeof data.roleMessages !== 'undefined') { - for (const rawRoleMessage of data.roleMessages) { - this.roleMessages.add(rawRoleMessage) - } - } - - if (typeof data.tags !== 'undefined') { - for (const rawTag of data.tags) { - this.tags.add(rawTag) - } - } - - if (typeof data.tickets !== 'undefined') { - for (const rawTicket of data.tickets) { - this.tickets.add(rawTicket) - } - } - - if (typeof data.ticketTypes !== 'undefined') { - for (const rawTicketType of data.ticketTypes) { - this.ticketTypes.add(rawTicketType) - } - } - } - - public _patch (data: any): void { - // Below patch was done so that Discord.js' Guild._patch method doesn't - // clear the roles manager which makes it lose all data. When channels - // ever get data that needs to be cached, this has to be done on that - // manager too. - const roles: any[] = data.roles - delete data.roles - - // @ts-expect-error - super._patch(data) - - for (const roleData of roles) { - const role = this.roles.cache.get(roleData.id) - if (typeof role !== 'undefined') { - // @ts-expect-error - role._patch(roleData) - } else { - this.roles.add(roleData) - } - } - for (const role of this.roles.cache.values()) { - if (!roles.some(roleData => roleData.id === role.id)) { - this.roles.cache.delete(role.id) - } - } - } - - // @ts-expect-error - public override async init (): Promise { - if (applicationConfig.apiEnabled === true) { - const announceTrainingsJobConfig = cronConfig.announceTrainingsJob - const announceTrainingsJob = this.jobFactory(announceTrainingsJobConfig.name) - cron.schedule( - announceTrainingsJobConfig.expression, - // eslint-disable-next-line @typescript-eslint/no-misused-promises - announceTrainingsJob.run.bind(announceTrainingsJob, this) - ) - } - - const premiumMembersReportJobConfig = cronConfig.premiumMembersReportJob - const premiumMembersReportJob = this.jobFactory(premiumMembersReportJobConfig.name) - cron.schedule( - premiumMembersReportJobConfig.expression, - // eslint-disable-next-line @typescript-eslint/no-misused-promises - premiumMembersReportJob.run.bind(premiumMembersReportJob, this) - ) - } - - public override get logsChannel (): TextChannel | null { - return this.logsChannelId !== null - ? (this.channels.cache.get(this.logsChannelId) as TextChannel | undefined) ?? null - : null - } - - public override get ratingsChannel (): TextChannel | null { - return this.ratingsChannelId !== null - ? (this.channels.cache.get(this.ratingsChannelId) as TextChannel | undefined) ?? null - : null - } - - public override get suggestionsChannel (): TextChannel | null { - return this.suggestionsChannelId !== null - ? (this.channels.cache.get(this.suggestionsChannelId) as TextChannel | undefined) ?? null - : null - } - - public override get ticketArchivesChannel (): TextChannel | null { - return this.ticketArchivesChannelId !== null - ? (this.channels.cache.get(this.ticketArchivesChannelId) as TextChannel | undefined) ?? null - : null - } - - public override get ticketsCategory (): CategoryChannel | null { - return this.ticketsCategoryId !== null - ? (this.channels.cache.get(this.ticketsCategoryId) as CategoryChannel | undefined) ?? null - : null - } - - // @ts-expect-error - public override async handleRoleMessage ( - type: 'add' | 'remove', - reaction: MessageReaction, - user: User - ): Promise { - const member = await this.members.fetch(user) - for (const roleMessage of this.roleMessages.cache.values()) { - if (reaction.message.id === roleMessage.messageId && (reaction.emoji instanceof GuildEmoji - ? roleMessage.emoji instanceof GuildEmoji && reaction.emoji.id === roleMessage.emojiId - : !(roleMessage.emoji instanceof GuildEmoji) && reaction.emoji.name === roleMessage.emojiId)) { - await member.roles[type](roleMessage.roleId) - } - } - } - - // @ts-expect-error - public override async log ( - author: User, - content: string, - options: { color?: ColorResolvable, footer?: string } = {} - ): Promise { - if (this.logsChannel !== null) { - if (author.partial) { - await author.fetch() - } - - const embed = new MessageEmbed() - .setAuthor(author.tag, author.displayAvatarURL()) - .setColor(this.primaryColor ?? applicationConfig.defaultColor) - .setDescription(content) - if (typeof options.color !== 'undefined') { - embed.setColor(options.color) - } - if (typeof options.footer !== 'undefined') { - embed.setFooter(options.footer) - } - - return await this.logsChannel.send(embed) - } - return null - } - - // @ts-expect-error - public override async update (data: GuildUpdateOptions): Promise { - await this.guildRepository.save(this.guildRepository.create({ - id: this.id, - ...data - })) - const newData = await this.guildRepository.findOne(this.id) as GuildEntity - - this.setup(newData) - return this - } - } - - return AroraGuild -}) - -export default AroraGuild diff --git a/src/extensions/index.ts b/src/extensions/index.ts deleted file mode 100644 index 2534dadb..00000000 --- a/src/extensions/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from './guild' -export { default as AroraGuild } from './guild' -export { default as AroraGuildMember } from './guild-member' -export { default as AroraRole } from './role' -export { default as AroraTextChannel } from './text-channel' -export { default as AroraVoiceChannel } from './voice-channel' diff --git a/src/extensions/role.ts b/src/extensions/role.ts deleted file mode 100644 index 718cc4d7..00000000 --- a/src/extensions/role.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { Command, CommandGroup } from 'discord.js-commando' -import { type PermissionManager, RoleGroupManager } from '../managers' -import { type Role, Structures } from 'discord.js' -import type { BaseStructure } from '../structures' -import Permissible from '../structures/mixins/permissible' -import type { Role as RoleEntity } from '../entities' - -declare module 'discord.js' { - interface Role { - groups: RoleGroupManager - readonly aroraPermissions: PermissionManager - - setup: (data: RoleEntity) => void - permissionFor: (commandOrGroup: Command | CommandGroup) => boolean | null - } -} - -// @ts-expect-error -const AroraRole: Role = Structures.extend('Role', Role => ( - class AroraRole extends Permissible(Role) implements Omit { - // @ts-expect-error - public override setup (data: RoleEntity): void { - if (typeof data.permissions !== 'undefined') { - for (const rawPermission of data.permissions) { - this.aroraPermissions.add(rawPermission) - } - } - } - - public override get groups (): RoleGroupManager { - return new RoleGroupManager(this) - } - } -)) - -export default AroraRole diff --git a/src/extensions/text-channel.ts b/src/extensions/text-channel.ts deleted file mode 100644 index 8a40c2d2..00000000 --- a/src/extensions/text-channel.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Structures, type TextChannel } from 'discord.js' -import { TextChannelGroupManager } from '../managers' - -declare module 'discord.js' { - interface TextChannel { - groups: TextChannelGroupManager - } -} - -// @ts-expect-error -const AroraTextChannel: TextChannel = Structures.extend('TextChannel', TextChannel => ( - class AroraTextChannel extends TextChannel { - public override get groups (): TextChannelGroupManager { - return new TextChannelGroupManager(this) - } - } -)) - -export default AroraTextChannel diff --git a/src/extensions/voice-channel.ts b/src/extensions/voice-channel.ts deleted file mode 100644 index 51bec67c..00000000 --- a/src/extensions/voice-channel.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { type Collection, type GuildChannel, Structures, type TextChannel, type VoiceChannel } from 'discord.js' -import type { Channel as ChannelEntity } from '../entities' -import { Repository } from 'typeorm' -import { constants } from '../util' -import container from '../configs/container' -import getDecorators from 'inversify-inject-decorators' - -const { TYPES } = constants -const { lazyInject } = getDecorators(container) - -declare module 'discord.js' { - interface VoiceChannel { - fetchToLinks: () => Promise> - linkChannel: (channel: TextChannel) => Promise - unlinkChannel: (channel: TextChannel) => Promise - } -} - -// @ts-expect-error -const AroraVoiceChannel: VoiceChannel = Structures.extend('VoiceChannel', VoiceChannel => { - class AroraVoiceChannel extends VoiceChannel { - @lazyInject(TYPES.ChannelRepository) - private readonly channelRepository!: Repository - - // @ts-expect-error - public override async fetchToLinks (): Promise> { - const data = await this.getData(this) - - return this.guild.channels.cache.filter(channel => ( - channel.isText() && data?.toLinks.some(link => link.id === channel.id)) === true - ) as Collection - } - - // @ts-expect-error - public override async linkChannel (channel: TextChannel): Promise { - const data = await this.getData(this) ?? await this.channelRepository.save(this.channelRepository.create({ - id: this.id, - guildId: this.guild.id - })) - if (typeof data.toLinks === 'undefined') { - data.toLinks = [] - } - - if (data.toLinks.some(link => link.id === channel.id)) { - throw new Error('Voice channel does already have linked text channel.') - } else { - data.toLinks.push({ id: channel.id, guildId: this.guild.id }) - await this.channelRepository.save(data) - return this - } - } - - // @ts-expect-error - public override async unlinkChannel (channel: TextChannel): Promise { - const data = await this.getData(this) - - if (typeof data === 'undefined' || !data?.toLinks.some(link => link.id === channel.id)) { - throw new Error('Voice channel does not have linked text channel.') - } else { - data.toLinks = data.toLinks.filter(link => link.id !== channel.id) - await this.channelRepository.save(data) - return this - } - } - - private async getData (channel: GuildChannel): Promise<(ChannelEntity & { toLinks: ChannelEntity[] }) | undefined> { - return await this.channelRepository.findOne( - { id: channel.id, guildId: channel.guild.id }, - { relations: ['toLinks'] } - ) as (ChannelEntity & { toLinks: ChannelEntity[] }) | undefined - } - } - - return AroraVoiceChannel -}) - -export default AroraVoiceChannel diff --git a/src/interactions/application-commands/index.ts b/src/interactions/application-commands/index.ts new file mode 100644 index 00000000..2ba28e26 --- /dev/null +++ b/src/interactions/application-commands/index.ts @@ -0,0 +1 @@ +export * from './slash-commands' diff --git a/src/interactions/application-commands/slash-commands/admin/bans.ts b/src/interactions/application-commands/slash-commands/admin/bans.ts new file mode 100644 index 00000000..54ff8e9c --- /dev/null +++ b/src/interactions/application-commands/slash-commands/admin/bans.ts @@ -0,0 +1,223 @@ +import { type CommandInteraction, MessageEmbed } from 'discord.js' +import { argumentUtil, constants, timeUtil } from '../../../../utils' +import { groupService, userService, verificationService } from '../../../../services' +import { inject, injectable, named } from 'inversify' +import { ApplyOptions } from '../../../../utils/decorators' +import type { GuildContext } from '../../../../structures' +import type { GuildContextManager } from '../../../../managers' +import type { RobloxUser } from '../../../../argument-types' +import { SubCommandCommand } from '../base' +import type { SubCommandCommandOptions } from '..' +import { applicationAdapter } from '../../../../adapters' +import applicationConfig from '../../../../configs/application' +import pluralize from 'pluralize' + +const { TYPES } = constants +const { getDate, getTime } = timeUtil +const { validators, noChannels, noTags, noUrls } = argumentUtil + +const validateReason = validators([noChannels, noTags, noUrls]) + +@injectable() +@ApplyOptions>({ + requiresApi: true, + requiresRobloxGroup: true, + subCommands: { + create: { + args: [ + { key: 'username', name: 'user', type: 'roblox-user' }, + { key: 'duration', required: false }, + { key: 'reason', validate: validateReason } + ] + }, + delete: { + args: [ + { key: 'username', name: 'user', type: 'roblox-user' }, + { key: 'reason', validate: validateReason } + ] + }, + edit: { + args: [ + { key: 'username', name: 'user', type: 'roblox-user' }, + { + key: 'key', + parse: (val: string) => val.toLowerCase() + }, + { key: 'value', validate: validateReason } + ] + }, + extend: { + args: [ + { key: 'username', name: 'user', type: 'roblox-user' }, + { + key: 'days', + validate: (val: string) => parseInt(val) !== 0 + }, + { key: 'reason', validate: validateReason } + ] + }, + list: { + args: [ + { + key: 'username', + name: 'user', + type: 'roblox-user', + required: false + } + ] + } + } +}) +export default class BansCommand extends SubCommandCommand { + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + public async create ( + interaction: CommandInteraction<'raw' | 'cached'>, + { user, duration, reason }: { + user: RobloxUser + duration: number | null + reason: string + } + ): Promise { + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext & { robloxGroupId: number } + + const authorId = (await verificationService.fetchVerificationData(interaction.user.id))?.robloxId + if (typeof authorId === 'undefined') { + return await interaction.reply({ + content: 'This command requires you to be verified with a verification provider.', + ephemeral: true + }) + } + + await applicationAdapter('POST', `v1/groups/${context.robloxGroupId}/bans`, { + userId: user.id, + authorId, + duration: duration === null ? undefined : duration * 86_400_000, + reason + }) + + return await interaction.reply(`Successfully banned **${user.username ?? user.id}**.`) + } + + public async delete ( + interaction: CommandInteraction<'raw' | 'cached'>, + { user, reason }: { user: RobloxUser, reason: string } + ): Promise { + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext & { robloxGroupId: number } + + const authorId = (await verificationService.fetchVerificationData(interaction.user.id))?.robloxId + if (typeof authorId === 'undefined') { + return await interaction.reply({ + content: 'This command requires you to be verified with a verification provider.', + ephemeral: true + }) + } + + await applicationAdapter('POST', `v1/groups/${context.robloxGroupId}/bans/${user.id}/cancel`, { + authorId, + reason + }) + + return await interaction.reply(`Successfully unbanned **${user.username ?? user.id}**.`) + } + + public async edit ( + interaction: CommandInteraction<'raw' | 'cached'>, + { user, key, value }: { + user: RobloxUser + key: string + value: string + } + ): Promise { + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext & { robloxGroupId: number } + + const changes: { authorId?: number, reason?: string } = {} + if (key === 'author') { + changes.authorId = await userService.getIdFromUsername(value) + } else if (key === 'reason') { + changes.reason = value + } + const editorId = (await verificationService.fetchVerificationData(interaction.user.id))?.robloxId + if (typeof editorId === 'undefined') { + return await interaction.reply({ + content: 'This command requires you to be verified with a verification provider.', + ephemeral: true + }) + } + + await applicationAdapter('PUT', `v1/groups/${context.robloxGroupId}/bans/${user.id}`, { changes, editorId }) + + return await interaction.reply(`Successfully edited **${user.username ?? user.id}**'s ban.`) + } + + public async extend ( + interaction: CommandInteraction<'raw' | 'cached'>, + { user, days, reason }: { + user: RobloxUser + days: number + reason: string + } + ): Promise { + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext & { robloxGroupId: number } + + const authorId = (await verificationService.fetchVerificationData(interaction.user.id))?.robloxId + if (typeof authorId === 'undefined') { + return await interaction.reply({ + content: 'This command requires you to be verified with a verification provider.', + ephemeral: true + }) + } + + await applicationAdapter('POST', `v1/groups/${context.robloxGroupId}/bans/${user.id}/extend`, { + authorId, + duration: days * 86_400_000, + reason + }) + + return await interaction.reply(`Successfully extended **${user.username ?? user.id}**'s ban.`) + } + + public async list ( + interaction: CommandInteraction<'raw' | 'cached'>, + { user }: { user: RobloxUser | null } + ): Promise { + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext & { robloxGroupId: number } + + if (user !== null) { + const ban = (await applicationAdapter('GET', `v1/groups/${context.robloxGroupId}/bans/${user.id}`)).data + + const days = ban.duration / 86_400_000 + const date = new Date(ban.date) + let extensionDays = 0 + for (const extension of ban.extensions) { + extensionDays += extension.duration / 86_400_000 + } + const extensionString = extensionDays !== 0 + ? ` (${Math.sign(extensionDays) === 1 ? '+' : ''}${extensionDays})` + : '' + const embed = new MessageEmbed() + .setTitle(`${user.username ?? user.id}'s ban`) + .setColor(context.primaryColor ?? applicationConfig.defaultColor) + .addField('Start date', getDate(date), true) + .addField('Start time', getTime(date), true) + .addField('Duration', `${days}${extensionString} ${pluralize('day', days + extensionDays)}`, true) + .addField('Reason', ban.reason) + + return await interaction.reply({ embeds: [embed] }) + } else { + const bans = (await applicationAdapter('GET', `v1/groups/${context.robloxGroupId}/bans?sort=date`)).data + if (bans.length === 0) { + return await interaction.reply('There are currently no bans.') + } + + const embeds = await groupService.getBanEmbeds(context.robloxGroupId, bans) + for (const embed of embeds) { + await interaction.user.send({ embeds: [embed] }) + } + + return await interaction.reply('Sent you a DM with the banlist.') + } + } +} diff --git a/src/interactions/application-commands/slash-commands/admin/demote.ts b/src/interactions/application-commands/slash-commands/admin/demote.ts new file mode 100644 index 00000000..73f98300 --- /dev/null +++ b/src/interactions/application-commands/slash-commands/admin/demote.ts @@ -0,0 +1,49 @@ +import { inject, injectable, named } from 'inversify' +import { ApplyOptions } from '../../../../utils/decorators' +import type { ChangeMemberRole } from '../../../../services/group' +import { Command } from '../base' +import type { CommandInteraction } from 'discord.js' +import type { CommandOptions } from '..' +import type { GuildContext } from '../../../../structures' +import type { GuildContextManager } from '../../../../managers' +import type { RobloxUser } from '../../../../argument-types' +import { applicationAdapter } from '../../../../adapters' +import { constants } from '../../../../utils' +import { verificationService } from '../../../../services' + +const { TYPES } = constants + +@injectable() +@ApplyOptions({ + requiresApi: true, + requiresRobloxGroup: true, + command: { + args: [{ key: 'username', name: 'user', type: 'roblox-user' }] + } +}) +export default class DemoteCommand extends Command { + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + public async execute ( + interaction: CommandInteraction<'raw' | 'cached'>, + { user }: { user: RobloxUser } + ): Promise { + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext & { robloxGroupId: number } + + const authorId = (await verificationService.fetchVerificationData(interaction.user.id))?.robloxId + if (typeof authorId === 'undefined') { + return await interaction.reply({ + content: 'This command requires you to be verified with a verification provider.', + ephemeral: true + }) + } + + const roles: ChangeMemberRole = (await applicationAdapter('POST', `v1/groups/${context.robloxGroupId}/users/${user.id}/demote`, { + authorId + })).data + + return await interaction.reply(`Successfully demoted **${user.username ?? user.id}** from **${roles.oldRole.name}** to **${roles.newRole.name}**.`) + } +} diff --git a/src/interactions/application-commands/slash-commands/admin/exiles.ts b/src/interactions/application-commands/slash-commands/admin/exiles.ts new file mode 100644 index 00000000..068f6327 --- /dev/null +++ b/src/interactions/application-commands/slash-commands/admin/exiles.ts @@ -0,0 +1,123 @@ +import { type CommandInteraction, MessageEmbed } from 'discord.js' +import { constants, timeUtil } from '../../../../utils' +import { groupService, verificationService } from '../../../../services' +import { inject, injectable, named } from 'inversify' +import { ApplyOptions } from '../../../../utils/decorators' +import type { Exile } from '../../../../services/group' +import type { GuildContext } from '../../../../structures' +import type { GuildContextManager } from '../../../../managers' +import type { RobloxUser } from '../../../../argument-types' +import { SubCommandCommand } from '../base' +import type { SubCommandCommandOptions } from '..' +import { applicationAdapter } from '../../../../adapters' +import applicationConfig from '../../../../configs/application' + +const { TYPES } = constants +const { getDate, getTime } = timeUtil + +@injectable() +@ApplyOptions>({ + requiresApi: true, + requiresRobloxGroup: true, + subCommands: { + create: { + args: [{ key: 'username', name: 'user', type: 'roblox-user' }] + }, + delete: { + args: [{ key: 'username', name: 'user', type: 'roblox-user' }] + }, + list: { + args: [ + { + key: 'username', + name: 'user', + type: 'roblox-user', + required: false + } + ] + } + } +}) +export default class ExilesCommand extends SubCommandCommand { + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + public async create ( + interaction: CommandInteraction<'raw' | 'cached'>, + { user, reason }: { user: RobloxUser, reason: string } + ): Promise { + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext & { robloxGroupId: number } + + const authorId = (await verificationService.fetchVerificationData(interaction.user.id))?.robloxId + if (typeof authorId === 'undefined') { + return await interaction.reply({ + content: 'This command requires you to be verified with a verification provider.', + ephemeral: true + }) + } + + await applicationAdapter('POST', `v1/groups/${context.robloxGroupId}/exiles`, { + userId: user.id, + authorId, + reason + }) + + return await interaction.reply(`Successfully exiled **${user.username ?? user.id}**.`) + } + + public async delete ( + interaction: CommandInteraction<'raw' | 'cached'>, + { user, reason }: { user: RobloxUser, reason: string } + ): Promise { + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext & { robloxGroupId: number } + + const authorId = (await verificationService.fetchVerificationData(interaction.user.id))?.robloxId + if (typeof authorId === 'undefined') { + return await interaction.reply({ + content: 'This command requires you to be verified with a verification provider.', + ephemeral: true + }) + } + + await applicationAdapter('DELETE', `v1/groups/${context.robloxGroupId}/exiles/${user.id}`, { + authorId, + reason + }) + + return await interaction.reply(`Successfully unexiled **${user.username ?? user.id}**.`) + } + + public async list ( + interaction: CommandInteraction<'raw' | 'cached'>, + { user }: { user: RobloxUser | null } + ): Promise { + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext & { robloxGroupId: number } + + if (user !== null) { + const exile: Exile = (await applicationAdapter('GET', `v1/groups/${context.robloxGroupId}/exiles/${user.id}`)).data + + const date = new Date(exile.date) + const embed = new MessageEmbed() + .setTitle(`${user.username ?? user.id}'s exile`) + .setColor(context.primaryColor ?? applicationConfig.defaultColor) + .addField('Start date', getDate(date), true) + .addField('Start time', getTime(date), true) + .addField('Reason', exile.reason) + + return await interaction.reply({ embeds: [embed] }) + } else { + const exiles = (await applicationAdapter('GET', `v1/groups/${context.robloxGroupId}/exiles?sort=date`)).data + if (exiles.length === 0) { + return await interaction.reply('There are currently no exiles.') + } + + const embeds = await groupService.getExileEmbeds(exiles) + for (const embed of embeds) { + await interaction.user.send({ embeds: [embed] }) + } + + return await interaction.reply('Sent you a DM with the current exiles.') + } + } +} diff --git a/src/interactions/application-commands/slash-commands/admin/index.ts b/src/interactions/application-commands/slash-commands/admin/index.ts new file mode 100644 index 00000000..500002e7 --- /dev/null +++ b/src/interactions/application-commands/slash-commands/admin/index.ts @@ -0,0 +1,7 @@ +export { default as BansCommand } from './bans' +export { default as DemoteCommand } from './demote' +export { default as ExilesCommand } from './exiles' +export { default as PersistentRolesCommand } from './persistent-roles' +export { default as PromoteCommand } from './promote' +export { default as ShoutCommand } from './shout' +export { default as TrainingsCommand } from './trainings' diff --git a/src/interactions/application-commands/slash-commands/admin/persistent-roles.ts b/src/interactions/application-commands/slash-commands/admin/persistent-roles.ts new file mode 100644 index 00000000..c1738c5d --- /dev/null +++ b/src/interactions/application-commands/slash-commands/admin/persistent-roles.ts @@ -0,0 +1,77 @@ +import { type CommandInteraction, type GuildMember, MessageEmbed, type Role } from 'discord.js' +import { inject, injectable, named } from 'inversify' +import { ApplyOptions } from '../../../../utils/decorators' +import type { GuildContext } from '../../../../structures' +import type { GuildContextManager } from '../../../../managers' +import type { PersistentRoleService } from '../../../../services' +import { SubCommandCommand } from '../base' +import type { SubCommandCommandOptions } from '..' +import applicationConfig from '../../../../configs/application' +import { constants } from '../../../../utils' + +const { TYPES } = constants + +@injectable() +@ApplyOptions>({ + subCommands: { + persist: { + args: [{ key: 'member' }, { key: 'role' }] + }, + unpersist: { + args: [{ key: 'member' }, { key: 'role' }] + }, + list: { + args: [{ key: 'member' }] + } + } +}) +export default class PersistentRolesCommand extends SubCommandCommand { + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + @inject(TYPES.PersistentRoleService) + private readonly persistentRoleService!: PersistentRoleService + + public async persist ( + interaction: CommandInteraction<'raw' | 'cached'>, + { member, role }: { member: GuildMember, role: Role } + ): Promise { + await this.persistentRoleService.persistRole(member, role) + + return await interaction.reply({ + content: `Successfully persisted role **${role.toString()}** on member **${member.toString()}**.`, + allowedMentions: {} + }) + } + + public async unpersist ( + interaction: CommandInteraction<'raw' | 'cached'>, + { member, role }: { member: GuildMember, role: Role } + ): Promise { + await this.persistentRoleService.unpersistRole(member, role) + + return await interaction.reply({ + content: `Successfully removed persistent role **${role.toString()}** from member **${member.toString()}**.`, + allowedMentions: {} + }) + } + + public async list ( + interaction: CommandInteraction<'raw' | 'cached'>, + { member }: { member: GuildMember } + ): Promise { + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + + const persistentRoles = await this.persistentRoleService.fetchPersistentRoles(member) + if (persistentRoles.size === 0) { + return await interaction.reply('No persistent roles found.') + } + + const embed = new MessageEmbed() + .setTitle(`${member.user.tag}'s Persistent Roles`) + .setDescription(persistentRoles.map(role => role.toString()).toString()) + .setColor(context.primaryColor ?? applicationConfig.defaultColor) + return await interaction.reply({ embeds: [embed] }) + } +} diff --git a/src/interactions/application-commands/slash-commands/admin/promote.ts b/src/interactions/application-commands/slash-commands/admin/promote.ts new file mode 100644 index 00000000..1483eaf4 --- /dev/null +++ b/src/interactions/application-commands/slash-commands/admin/promote.ts @@ -0,0 +1,49 @@ +import { inject, injectable, named } from 'inversify' +import { ApplyOptions } from '../../../../utils/decorators' +import type { ChangeMemberRole } from '../../../../services/group' +import { Command } from '../base' +import type { CommandInteraction } from 'discord.js' +import type { CommandOptions } from '..' +import type { GuildContext } from '../../../../structures' +import type { GuildContextManager } from '../../../../managers' +import type { RobloxUser } from '../../../../argument-types' +import { applicationAdapter } from '../../../../adapters' +import { constants } from '../../../../utils' +import { verificationService } from '../../../../services' + +const { TYPES } = constants + +@injectable() +@ApplyOptions({ + requiresApi: true, + requiresRobloxGroup: true, + command: { + args: [{ key: 'username', name: 'user', type: 'roblox-user' }] + } +}) +export default class PromoteCommand extends Command { + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + public async execute ( + interaction: CommandInteraction<'raw' | 'cached'>, + { user }: { user: RobloxUser } + ): Promise { + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext & { robloxGroupId: number } + + const authorId = (await verificationService.fetchVerificationData(interaction.user.id))?.robloxId + if (typeof authorId === 'undefined') { + return await interaction.reply({ + content: 'This command requires you to be verified with a verification provider.', + ephemeral: true + }) + } + + const roles: ChangeMemberRole = (await applicationAdapter('POST', `v1/groups/${context.robloxGroupId}/users/${user.id}/promote`, { + authorId + })).data + + return await interaction.reply(`Successfully promoted **${user.username ?? user.id}** from **${roles.oldRole.name}** to **${roles.newRole.name}**.`) + } +} diff --git a/src/interactions/application-commands/slash-commands/admin/shout.ts b/src/interactions/application-commands/slash-commands/admin/shout.ts new file mode 100644 index 00000000..bb9559ce --- /dev/null +++ b/src/interactions/application-commands/slash-commands/admin/shout.ts @@ -0,0 +1,62 @@ +import { type CommandInteraction, MessageEmbed } from 'discord.js' +import { argumentUtil, constants } from '../../../../utils' +import { inject, injectable, named } from 'inversify' +import { ApplyOptions } from '../../../../utils/decorators' +import { Command } from '../base' +import type { CommandOptions } from '..' +import type { GuildContext } from '../../../../structures' +import type { GuildContextManager } from '../../../../managers' +import { applicationAdapter } from '../../../../adapters' +import applicationConfig from '../../../../configs/application' +import { verificationService } from '../../../../services' + +const { TYPES } = constants +const { validators, noChannels, noTags, noUrls } = argumentUtil + +@injectable() +@ApplyOptions({ + requiresApi: true, + requiresRobloxGroup: true, + command: { + args: [ + { + key: 'message', + validate: validators([noChannels, noTags, noUrls]) + } + ] + } +}) +export default class ShoutCommand extends Command { + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + public async execute ( + interaction: CommandInteraction<'raw' | 'cached'>, + { message }: { message: string | null } + ): Promise { + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext & { robloxGroupId: number } + + const authorId = (await verificationService.fetchVerificationData(interaction.user.id))?.robloxId + if (typeof authorId === 'undefined') { + return await interaction.reply({ + content: 'This command requires you to be verified with a verification provider.', + ephemeral: true + }) + } + + const shout = (await applicationAdapter('PUT', `v1/groups/${context.robloxGroupId}/status`, { + authorId, + message: message ?? '' + })).data + + if (shout.body === '') { + return await interaction.reply('Successfully cleared shout.') + } else { + const embed = new MessageEmbed() + .addField('Successfully shouted', shout.body) + .setColor(context.primaryColor ?? applicationConfig.defaultColor) + return await interaction.reply({ embeds: [embed] }) + } + } +} diff --git a/src/interactions/application-commands/slash-commands/admin/trainings.ts b/src/interactions/application-commands/slash-commands/admin/trainings.ts new file mode 100644 index 00000000..83438514 --- /dev/null +++ b/src/interactions/application-commands/slash-commands/admin/trainings.ts @@ -0,0 +1,238 @@ +import { type CommandInteraction, MessageEmbed } from 'discord.js' +import { argumentUtil, constants, timeUtil } from '../../../../utils' +import { groupService, userService, verificationService } from '../../../../services' +import { inject, injectable, named } from 'inversify' +import { ApplyOptions } from '../../../../utils/decorators' +import type { GuildContext } from '../../../../structures' +import type { GuildContextManager } from '../../../../managers' +import { SubCommandCommand } from '../base' +import type { SubCommandCommandOptions } from '..' +import type { Training } from '../../../../services/group' +import { applicationAdapter } from '../../../../adapters' +import applicationConfig from '../../../../configs/application' + +const { TYPES } = constants +const { getDate, getDateInfo, getTime, getTimeInfo, getTimeZoneAbbreviation } = timeUtil +const { noChannels, noTags, noUrls, validDate, validTime, validators } = argumentUtil + +const parseKey = (val: string): string => val.toLowerCase() +const validateReason = validators([noChannels, noTags, noUrls]) + +@injectable() +@ApplyOptions>({ + requiresApi: true, + requiresRobloxGroup: true, + subCommands: { + create: { + args: [ + { key: 'type', parse: parseKey }, + { key: 'date', type: 'date' }, + { key: 'time', type: 'time' }, + { key: 'notes', validate: validateReason, required: false } + ] + }, + cancel: { + args: [ + { key: 'id' }, + { key: 'reason', validate: validateReason } + ] + }, + edit: { + args: [ + { key: 'id' }, + { key: 'key', parse: parseKey }, + { + key: 'value', + validate: validateReason, + parse: (val: string) => val.toLowerCase() === 'none' ? null : val + } + ] + }, + list: { + args: [{ key: 'id', required: false }] + } + } +}) +export default class TrainingsCommand extends SubCommandCommand { + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + public async create ( + interaction: CommandInteraction<'raw' | 'cached'>, + { type, date, time, notes }: { + type: string + date: string + time: string + notes?: string + } + ): Promise { + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext & { robloxGroupId: number } + + const dateInfo = getDateInfo(date) + const timeInfo = getTimeInfo(time) + const dateUnix = Math.floor(new Date( + dateInfo.year, + dateInfo.month, + dateInfo.day, + timeInfo.hours, + timeInfo.minutes + ).getTime()) + const afterNow = dateUnix - Date.now() > 0 + if (!afterNow) { + return await interaction.reply({ content: 'Please give a date and time that are after now.', ephemeral: true }) + } + const trainingTypes = await groupService.getTrainingTypes(context.robloxGroupId) + let trainingType = trainingTypes.find(trainingType => trainingType.abbreviation.toLowerCase() === type) + trainingType ??= trainingTypes.find(trainingType => trainingType.name.toLowerCase() === type) + if (typeof trainingType === 'undefined') { + return await interaction.reply({ content: 'Type not found.', ephemeral: true }) + } + const authorId = (await verificationService.fetchVerificationData(interaction.user.id))?.robloxId + if (typeof authorId === 'undefined') { + return await interaction.reply({ + content: 'This command requires you to be verified with a verification provider.', + ephemeral: true + }) + } + + const training = (await applicationAdapter('POST', `v1/groups/${context.robloxGroupId}/trainings`, { + authorId, + date: dateUnix, + notes, + typeId: trainingType.id + })).data + + const embed = new MessageEmbed() + .addField('Successfully scheduled', `**${trainingType.name}** training on **${date}** at **${time}**.`) + .addField('Training ID', training.id.toString()) + .setColor(context.primaryColor ?? applicationConfig.defaultColor) + return await interaction.reply({ embeds: [embed] }) + } + + public async cancel ( + interaction: CommandInteraction<'raw' | 'cached'>, + { id, reason }: { id: number, reason: string } + ): Promise { + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext & { robloxGroupId: number } + + const authorId = (await verificationService.fetchVerificationData(interaction.user.id))?.robloxId + if (typeof authorId === 'undefined') { + return await interaction.reply({ + content: 'This command requires you to be verified with a verification provider.', + ephemeral: true + }) + } + + await applicationAdapter('POST', `v1/groups/${context.robloxGroupId}/trainings/${id}/cancel`, { + authorId, + reason + }) + + return await interaction.reply(`Successfully cancelled training with ID **${id}**.`) + } + + public async edit ( + interaction: CommandInteraction<'raw' | 'cached'>, + { id, key, value }: { + id: number + key: string + value: string | null + } + ): Promise { + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext & { robloxGroupId: number } + + if (['author', 'type', 'date', 'time'].includes(key) && value === null) { + return await interaction.reply({ content: `Invalid ${key}`, ephemeral: true }) + } + + const changes: { authorId?: number, notes?: string | null, typeId?: number, date?: number } = {} + if (key === 'author') { + changes.authorId = await userService.getIdFromUsername(value as string) + } else if (key === 'notes') { + changes.notes = value + } else if (key === 'type') { + const type = (value as string).toUpperCase() + const trainingTypes = await groupService.getTrainingTypes(context.robloxGroupId) + let trainingType = trainingTypes.find(trainingType => trainingType.abbreviation.toLowerCase() === type) + trainingType ??= trainingTypes.find(trainingType => trainingType.name.toLowerCase() === type) + if (typeof trainingType === 'undefined') { + return await interaction.reply({ content: 'Type not found.', ephemeral: true }) + } + + changes.typeId = trainingType.id + } else if (key === 'date' || key === 'time') { + const training = (await applicationAdapter('GET', `v1/groups/${context.robloxGroupId}/trainings/${id}`)) + .data + const date = new Date(training.date) + + let dateInfo + let timeInfo + if (key === 'date') { + if (!validDate(value as string)) { + return await interaction.reply({ content: 'Please enter a valid date.', ephemeral: true }) + } + dateInfo = getDateInfo(value as string) + timeInfo = getTimeInfo(getTime(date)) + } else { + if (!validTime(value as string)) { + return await interaction.reply({ content: 'Please enter a valid time.', ephemeral: true }) + } + dateInfo = getDateInfo(getDate(date)) + timeInfo = getTimeInfo(value as string) + } + + changes.date = Math.floor(new Date(dateInfo.year, dateInfo.month, dateInfo.day, timeInfo.hours, timeInfo.minutes) + .getTime()) + } + const editorId = (await verificationService.fetchVerificationData(interaction.user.id))?.robloxId + if (typeof editorId === 'undefined') { + return await interaction.reply({ + content: 'This command requires you to be verified with a verification provider.', + ephemeral: true + }) + } + + await applicationAdapter('PUT', `v1/groups/${context.robloxGroupId}/trainings/${id}`, { + changes, + editorId + }) + + return await interaction.reply(`Successfully edited training with ID **${id}**.`) + } + + public async list ( + interaction: CommandInteraction<'raw' | 'cached'>, + { id }: { id: number | null } + ): Promise { + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext & { robloxGroupId: number } + + if (id !== null) { + const training: Training = (await applicationAdapter('GET', `v1/groups/${context.robloxGroupId}/trainings/${id}`)) + .data + const username = (await userService.getUser(training.authorId)).name + const date = new Date(training.date) + + const embed = new MessageEmbed() + .setTitle(`Training ${training.id}`) + .addField('Type', training.type?.abbreviation ?? 'Deleted', true) + .addField('Date', getDate(date), true) + .addField('Time', `${getTime(date)} ${getTimeZoneAbbreviation(date)}`, true) + .addField('Host', username, true) + .setColor(context.primaryColor ?? applicationConfig.defaultColor) + return await interaction.reply({ embeds: [embed] }) + } else { + const trainings: Training[] = (await applicationAdapter('GET', `v1/groups/${context.robloxGroupId}/trainings?sort=date`)) + .data + if (trainings.length === 0) { + return await interaction.reply('There are currently no hosted trainings.') + } + + const embeds = await groupService.getTrainingEmbeds(trainings) + for (const embed of embeds) { + await interaction.user.send({ embeds: [embed] }) + } + return await interaction.reply('Sent you a DM with the upcoming trainings.') + } + } +} diff --git a/src/interactions/application-commands/slash-commands/argument.ts b/src/interactions/application-commands/slash-commands/argument.ts new file mode 100644 index 00000000..da756cb2 --- /dev/null +++ b/src/interactions/application-commands/slash-commands/argument.ts @@ -0,0 +1,135 @@ +import { inject, injectable, type interfaces } from 'inversify' +import type { BaseArgumentType } from '../../../argument-types' +import type { CommandInteraction } from 'discord.js' +import type { GuildContextManager } from '../../../managers' +import { constants } from '../../../utils' + +const { TYPES } = constants + +export type DefaultFunction = ((interaction: CommandInteraction, guildContexts: GuildContextManager) => T) +export type ValidatorFunction = +(value: string, interaction: CommandInteraction, arg: Argument) => boolean | string | Promise +export type ParserFunction = +(value: string, interaction: CommandInteraction, arg: Argument) => T | null | Promise + +export interface ArgumentOptions { + key: string + name?: string + type?: string + required?: boolean + default?: string | DefaultFunction + validate?: ValidatorFunction + parse?: ParserFunction +} + +interface ArgumentResolvedOptions extends Omit, 'type'> { + type?: BaseArgumentType | Array> +} + +@injectable() +export default class Argument { + @inject(TYPES.ArgumentTypeFactory) + private readonly argumentTypeFactory!: interfaces.AutoNamedFactory | undefined> + + public key!: string + public name?: string + public type?: BaseArgumentType | Array> + public required?: boolean + public default?: string | DefaultFunction + public validator?: ValidatorFunction + public parser?: ParserFunction + + public setOptions (options: ArgumentOptions): void { + this.validateOptions(options) + + const resolvedOptions = this.resolveOptions(options) + this.key = resolvedOptions.key + this.name = resolvedOptions.name + this.type = resolvedOptions.type + this.required = resolvedOptions.required + this.default = resolvedOptions.default + this.validator = resolvedOptions.validate + this.parser = resolvedOptions.parse + } + + public get validate (): ValidatorFunction | null { + return this.validator ?? (!Array.isArray(this.type) + ? this.type?.validate.bind(this.type) ?? null + : async function ( + this: Argument & { type: Array> }, + value: string, + interaction: CommandInteraction, + arg: Argument + ) { + const results = await Promise.all(this.type.map(type => type.validate(value, interaction, arg))) + if (results.some(result => result === true)) { + return true + } + const errors = results.filter(result => typeof result === 'string') + if (errors.length > 0) { + return errors.join('\n') + } + return false + } + ) + } + + public get parse (): ParserFunction | null { + return this.parser ?? (!Array.isArray(this.type) + ? this.type?.parse.bind(this.type) ?? null + : async function ( + this: Argument & { type: Array> }, + value: string, + interaction: CommandInteraction, + arg: Argument + ) { + const results = await Promise.all(this.type.map(type => type.validate(value, interaction, arg))) + for (let i = 0; i < results.length; i++) { + if (results[i] === true) { + return await this.type[i].parse(value, interaction, arg) + } + } + return null + } + ) + } + + private resolveOptions (options: ArgumentOptions): ArgumentResolvedOptions { + let resolvedType + if (typeof options.type !== 'undefined') { + if (!options.type.includes('|')) { + resolvedType = this.argumentTypeFactory(options.type) + } else { + resolvedType = [] + const typeNames = options.type.split('|') + for (const typeName of typeNames) { + const type = this.argumentTypeFactory(typeName) + if (typeof type !== 'undefined') { + resolvedType.push(type) + } + } + } + } + return { + ...options, + type: resolvedType + } + } + + private validateOptions (options: ArgumentOptions): void { + if (typeof options.type !== 'undefined') { + if (!options.type.includes('|')) { + if (typeof this.argumentTypeFactory(options.type) === 'undefined') { + throw new Error(`Argument type "${options.type}" not found.`) + } + } else { + const typeNames = options.type.split('|') + for (const typeName of typeNames) { + if (typeof this.argumentTypeFactory(typeName) === 'undefined') { + throw new Error(`Argument type "${typeName}" not found.`) + } + } + } + } + } +} diff --git a/src/interactions/application-commands/slash-commands/base.ts b/src/interactions/application-commands/slash-commands/base.ts new file mode 100644 index 00000000..3a5e8af9 --- /dev/null +++ b/src/interactions/application-commands/slash-commands/base.ts @@ -0,0 +1,130 @@ +import { type AnyFunction, type KeyOfType, type OverloadedParameters, constants } from '../../../utils' +import type { Argument, ArgumentOptions } from '.' +import { inject, injectable, type interfaces } from 'inversify' +import type { AroraClient } from '../../../client' +import type { CommandInteraction } from 'discord.js' + +const { TYPES } = constants + +interface BaseCommandOptions { + ownerOwnly?: boolean + requiresApi?: boolean + requiresRobloxGroup?: boolean + requiresSingleGuild?: boolean +} + +type SubCommandOptions = { + args: Array> +} | true + +export interface CommandOptions extends BaseCommandOptions { + command?: SubCommandOptions +} + +type SubCommandNames> = { + [K in keyof U as U[K] extends any[] ? K : never]: U[K] extends any[] ? U[K][1] : never +} + +export interface SubCommandCommandOptions> extends BaseCommandOptions { + subCommands: { + [K in Exclude, 'setOptions' | 'execute'>]: T[K] extends AnyFunction + ? Parameters[1] extends string + ? { [U in keyof SubCommandNames as SubCommandNames[U]]: SubCommandOptions } + : SubCommandOptions + : never + } +} + +export default abstract class BaseCommand { + @inject(TYPES.Client) + protected readonly client!: AroraClient + + @inject(TYPES.ArgumentFactory) + protected readonly argumentFactory!: interfaces.SimpleFactory, [ArgumentOptions]> + + public options!: T + + public setOptions (options: T): void { + this.options = options + } +} + +@injectable() +export abstract class Command extends BaseCommand { + public readonly args: Record> = {} + + public override setOptions (options?: CommandOptions): void { + super.setOptions(options ?? {}) + + if (typeof this.options?.command !== 'undefined' && this.options.command !== true) { + for (const argumentOptions of this.options.command.args) { + this.args[argumentOptions.name ?? argumentOptions.key] = this.argumentFactory(argumentOptions) + } + } + } + + public abstract execute ( + interaction: CommandInteraction, + args: Record + ): Promise +} + +@injectable() +export class SubCommandCommand> extends BaseCommand> { + public readonly args: Record | Record>>> = {} + + public override setOptions (options: SubCommandCommandOptions): void { + super.setOptions(options) + + for (const [subCommandName, subCommand] of + Object.entries | undefined>(this.options.subCommands)) { + if (typeof subCommand === 'undefined' || subCommand === true) { + continue + } + + this.args[subCommandName] = {} + if (Reflect.has(subCommand, 'args')) { + for (const argumentOptions of (subCommand as Exclude).args) { + this.args[subCommandName][argumentOptions.name ?? argumentOptions.key] = this.argumentFactory(argumentOptions) + } + } else { + for (const [subSubCommandName, subSubCommand] of + Object.entries(subCommand as Record)) { + if (subSubCommand === true) { + continue + } + + const subSubCommandArgs = (this.args[subCommandName][subSubCommandName] = {}) as Record> + for (const argumentOptions of subSubCommand.args) { + subSubCommandArgs[argumentOptions.name ?? argumentOptions.key] = this.argumentFactory(argumentOptions) + } + } + } + } + } + + public execute ( + interaction: CommandInteraction, + subCommandName: string, + args: Record + ): Promise + public execute ( + interaction: CommandInteraction, + subCommandGroupName: string, + subCommandName: string, + args: Record + ): Promise + public async execute ( + interaction: CommandInteraction, + subCommandNameOrSubCommandGroupName: string, + argsOrSubCommandName: string | Record, + args?: Record + ): Promise { + const fn = this[subCommandNameOrSubCommandGroupName as keyof this] + if (typeof fn === 'function') { + return await Promise.resolve(fn.call(this, interaction, argsOrSubCommandName, args)) + } else { + throw new Error(`Subcommand "${subCommandNameOrSubCommandGroupName.toString()}" does not exist.`) + } + } +} diff --git a/src/interactions/application-commands/slash-commands/bot/index.ts b/src/interactions/application-commands/slash-commands/bot/index.ts new file mode 100644 index 00000000..3cdd8ac9 --- /dev/null +++ b/src/interactions/application-commands/slash-commands/bot/index.ts @@ -0,0 +1,2 @@ +export { default as RestartCommand } from './restart' +export { default as StatusCommand } from './status' diff --git a/src/interactions/application-commands/slash-commands/bot/restart.ts b/src/interactions/application-commands/slash-commands/bot/restart.ts new file mode 100644 index 00000000..dbf4724e --- /dev/null +++ b/src/interactions/application-commands/slash-commands/bot/restart.ts @@ -0,0 +1,16 @@ +import { ApplyOptions } from '../../../../utils/decorators' +import { Command } from '../base' +import type { CommandInteraction } from 'discord.js' +import type { CommandOptions } from '..' +import { injectable } from 'inversify' + +@injectable() +@ApplyOptions({ + ownerOwnly: true +}) +export default class RestartCommand extends Command { + public async execute (interaction: CommandInteraction): Promise { + await interaction.reply('Restarting...') + process.exit() + } +} diff --git a/src/interactions/application-commands/slash-commands/bot/status.ts b/src/interactions/application-commands/slash-commands/bot/status.ts new file mode 100644 index 00000000..87582897 --- /dev/null +++ b/src/interactions/application-commands/slash-commands/bot/status.ts @@ -0,0 +1,53 @@ +import { type CommandInteraction, MessageEmbed } from 'discord.js' +import { constants, timeUtil, util } from '../../../../utils' +import { inject, injectable, named } from 'inversify' +import { Command } from '../base' +import type { GuildContext } from '../../../../structures' +import type { GuildContextManager } from '../../../../managers' +import { applicationAdapter } from '../../../../adapters' +import applicationConfig from '../../../../configs/application' +import os from 'node:os' + +const { TYPES } = constants +const { formatBytes } = util +const { getDurationString } = timeUtil + +@injectable() +export default class StatusCommand extends Command { + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + public async execute (interaction: CommandInteraction): Promise { + const embed = new MessageEmbed() + .setAuthor({ + name: interaction.client.user?.username ?? 'Arora', + iconURL: interaction.client.user?.displayAvatarURL() + }) + .setColor(0xff82d1) + + if (interaction.inGuild()) { + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + embed.addField('System Statuses', `Tickets System: **${context.supportEnabled ? 'online' : 'offline'}**`) + } + + const totalMem = os.totalmem() + embed + .addField('Load Average', os.loadavg().join(', '), true) + .addField('Memory Usage', `${formatBytes(totalMem - os.freemem(), 3)} / ${formatBytes(totalMem, 3)}`, true) + .addField('Uptime', getDurationString(interaction.client.uptime ?? 0), true) + .setFooter({ text: `Process ID: ${process.pid} | ${os.hostname()}` }) + .setTimestamp() + + if (applicationConfig.apiEnabled === true) { + const startTime = Date.now() + const status = (await applicationAdapter('GET', 'v1/status')).data + const endTime = Date.now() + embed + .addField('API Latency', `${endTime - startTime}ms`, true) + .addField('API Status', status.state, true) + } + + return await interaction.reply({ embeds: [embed] }) + } +} diff --git a/src/interactions/application-commands/slash-commands/index.ts b/src/interactions/application-commands/slash-commands/index.ts new file mode 100644 index 00000000..8e509bbb --- /dev/null +++ b/src/interactions/application-commands/slash-commands/index.ts @@ -0,0 +1,8 @@ +export * from './admin' +export * from './argument' +export * from './base' +export * from './bot' +export * from './main' +export * from './settings' +export { default as Argument } from './argument' +export { default as BaseCommand } from './base' diff --git a/src/interactions/application-commands/slash-commands/main/boost-info.ts b/src/interactions/application-commands/slash-commands/main/boost-info.ts new file mode 100644 index 00000000..8875070c --- /dev/null +++ b/src/interactions/application-commands/slash-commands/main/boost-info.ts @@ -0,0 +1,46 @@ +import { type CommandInteraction, type GuildMember, MessageEmbed } from 'discord.js' +import { ApplyOptions } from '../../../../utils/decorators' +import { Command } from '../base' +import type { CommandOptions } from '..' +import { injectable } from 'inversify' +import pluralize from 'pluralize' +import { timeUtil } from '../../../../utils' + +const { diffDays } = timeUtil + +@injectable() +@ApplyOptions({ + command: { + args: [ + { + key: 'member', + required: false, + default: (interaction: CommandInteraction) => interaction.member + } + ] + } +}) +export default class BoostInfoCommand extends Command { + public async execute (interaction: CommandInteraction, { member }: { member: GuildMember }): Promise { + if (member.premiumSince === null) { + return await interaction.reply('Member is not a booster.') + } + + const now = new Date() + const diff = diffDays(member.premiumSince, now) + const months = Math.floor(diff / 30) + const days = diff % 30 + const emoji = this.client.mainGuild?.emojis.cache.find(emoji => emoji.name?.toLowerCase() === 'boost') + + if (member.user.partial) { + await member.user.fetch() + } + const embed = new MessageEmbed() + .setTitle(`${member.user.tag} ${emoji?.toString() ?? ''}`) + .setThumbnail(member.user.displayAvatarURL()) + .setDescription(`Has been boosting this server for **${pluralize('month', months, true)}** and **${pluralize('day', days, true)}**!`) + .setFooter({ text: '* Discord Nitro months are 30 days long.' }) + .setColor(0xff73fa) + return await interaction.reply({ embeds: [embed] }) + } +} diff --git a/src/interactions/application-commands/slash-commands/main/delete-suggestion.ts b/src/interactions/application-commands/slash-commands/main/delete-suggestion.ts new file mode 100644 index 00000000..b1cd9c61 --- /dev/null +++ b/src/interactions/application-commands/slash-commands/main/delete-suggestion.ts @@ -0,0 +1,50 @@ +import type { CommandInteraction, Message } from 'discord.js' +import { inject, injectable, named } from 'inversify' +import { Command } from '../base' +import type { GuildContext } from '../../../../structures' +import type { GuildContextManager } from '../../../../managers' +import { constants } from '../../../../utils' +import { discordService } from '../../../../services' + +const { TYPES } = constants + +@injectable() +export default class DeleteSuggestionCommand extends Command { + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + public async execute (interaction: CommandInteraction): Promise { + if (!interaction.inGuild()) { + return + } + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + + if (context.suggestionsChannel === null) { + return await interaction.reply({ content: 'This server has no suggestionsChannel set yet.', ephemeral: true }) + } + const messages = await context.suggestionsChannel.messages.fetch() + const authorUrl = `https://discord.com/users/${interaction.user.id}` + + for (const suggestion of messages.values()) { + if (suggestion.embeds[0]?.author?.url === authorUrl) { + const prompt = await interaction.reply({ + content: 'Are you sure you would like to delete this suggestion?', + embeds: [suggestion.embeds[0]], + fetchReply: true + }) as Message + const choice = (await discordService.prompt(interaction.user, prompt, ['✅', '🚫']))?.toString() === '✅' + + if (choice) { + await suggestion.delete() + await interaction.followUp('Successfully deleted your last suggestion.') + } else { + await interaction.followUp('Didn\'t delete your last suggestion.') + } + return + } + } + + return await interaction.reply('Could not find a suggestion you made.') + } +} diff --git a/src/interactions/application-commands/slash-commands/main/get-shout.ts b/src/interactions/application-commands/slash-commands/main/get-shout.ts new file mode 100644 index 00000000..040f6510 --- /dev/null +++ b/src/interactions/application-commands/slash-commands/main/get-shout.ts @@ -0,0 +1,40 @@ +import { type CommandInteraction, MessageEmbed } from 'discord.js' +import { inject, injectable, named } from 'inversify' +import { ApplyOptions } from '../../../../utils/decorators' +import { Command } from '../base' +import type { CommandOptions } from '..' +import type { GetGroupStatus } from '../../../../services/group' +import type { GuildContext } from '../../../../structures' +import type { GuildContextManager } from '../../../../managers' +import { applicationAdapter } from '../../../../adapters' +import applicationConfig from '../../../../configs/application' +import { constants } from '../../../../utils' + +const { TYPES } = constants + +@injectable() +@ApplyOptions({ + requiresApi: true, + requiresRobloxGroup: true +}) +export default class GetShoutCommand extends Command { + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + public async execute (interaction: CommandInteraction<'raw' | 'cached'>): Promise { + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext & { robloxGroupId: number } + + const shout: GetGroupStatus | '' = (await applicationAdapter('GET', `v1/groups/${context.robloxGroupId}/status`)).data + + if (shout !== '' && shout.body !== '') { + const embed = new MessageEmbed() + .addField(`Current shout by ${shout.poster.username}`, shout.body) + .setTimestamp(new Date(shout.updated)) + .setColor(context.primaryColor ?? applicationConfig.defaultColor) + return await interaction.reply({ embeds: [embed] }) + } else { + return await interaction.reply('There currently is no shout.') + } + } +} diff --git a/src/interactions/application-commands/slash-commands/main/index.ts b/src/interactions/application-commands/slash-commands/main/index.ts new file mode 100644 index 00000000..bc23e74d --- /dev/null +++ b/src/interactions/application-commands/slash-commands/main/index.ts @@ -0,0 +1,8 @@ +export { default as BoostInfoCommand } from './boost-info' +export { default as DeleteSuggestionCommand } from './delete-suggestion' +export { default as GetShoutCommand } from './get-shout' +export { default as MemberCountCommand } from './member-count' +export { default as PollCommand } from './poll' +export { default as SuggestCommand } from './suggest' +export { default as TagCommand } from './tag' +export { default as WhoIsCommand } from './who-is' diff --git a/src/interactions/application-commands/slash-commands/main/member-count.ts b/src/interactions/application-commands/slash-commands/main/member-count.ts new file mode 100644 index 00000000..b61ef4eb --- /dev/null +++ b/src/interactions/application-commands/slash-commands/main/member-count.ts @@ -0,0 +1,50 @@ +import { type CommandInteraction, MessageEmbed } from 'discord.js' +import { inject, injectable, named } from 'inversify' +import { ApplyOptions } from '../../../../utils/decorators' +import { Command } from '../base' +import type { CommandOptions } from '..' +import type { GuildContext } from '../../../../structures' +import type { GuildContextManager } from '../../../../managers' +import applicationConfig from '../../../../configs/application' +import { constants } from '../../../../utils' +import { groupService } from '../../../../services' + +const { TYPES } = constants + +@injectable() +@ApplyOptions({ + command: { + args: [ + { + key: 'id', + required: false, + default: (interaction: CommandInteraction, guildContexts: GuildContextManager) => ( + interaction.inGuild() + ? (guildContexts.resolve(interaction.guildId) as GuildContext).robloxGroupId + : null + ) + } + ] + } +}) +export default class MemberCountCommand extends Command { + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + public async execute (interaction: CommandInteraction, { id }: { id: number | null }): Promise { + const context = interaction.inGuild() + ? this.guildContexts.resolve(interaction.guildId) as GuildContext + : null + + if (id === null) { + return await interaction.reply({ content: 'Invalid group ID.', ephemeral: true }) + } + const group = await groupService.getGroup(id) + + const embed = new MessageEmbed() + .addField(`${group.name}'s member count`, group.memberCount.toString()) + .setColor(context?.primaryColor ?? applicationConfig.defaultColor) + return await interaction.reply({ embeds: [embed] }) + } +} diff --git a/src/interactions/application-commands/slash-commands/main/poll.ts b/src/interactions/application-commands/slash-commands/main/poll.ts new file mode 100644 index 00000000..69cac94f --- /dev/null +++ b/src/interactions/application-commands/slash-commands/main/poll.ts @@ -0,0 +1,56 @@ +import { type CommandInteraction, type Message, MessageEmbed } from 'discord.js' +import { argumentUtil, constants } from '../../../../utils' +import { inject, injectable, named } from 'inversify' +import { ApplyOptions } from '../../../../utils/decorators' +import { Command } from '../base' +import type { CommandOptions } from '..' +import type { GuildContext } from '../../../../structures' +import type { GuildContextManager } from '../../../../managers' +import applicationConfig from '../../../../configs/application' + +const { TYPES } = constants +const { validators, noTags } = argumentUtil + +@injectable() +@ApplyOptions({ + command: { + args: [ + { + key: 'poll', + validate: validators([noTags]) + } + ] + } +}) +export default class PollCommand extends Command { + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + public async execute (interaction: CommandInteraction, { poll }: { poll: string }): Promise { + const context = interaction.inGuild() + ? this.guildContexts.resolve(interaction.guildId) as GuildContext + : null + + const options = [] + for (let num = 1; num <= 10; num++) { + if (poll.includes(`(${num})`)) { + options.push(num) + } + } + const embed = new MessageEmbed() + .setDescription(poll) + .setAuthor({ name: interaction.user.tag, iconURL: interaction.user.displayAvatarURL() }) + .setColor(context?.primaryColor ?? applicationConfig.defaultColor) + + const newMessage = await interaction.reply({ embeds: [embed], fetchReply: true }) as Message + if (options.length > 0) { + for (const option of options) { + await newMessage.react(`${option}⃣`) + } + } else { + await newMessage.react('✔') + await newMessage.react('✖') + } + } +} diff --git a/src/interactions/application-commands/slash-commands/main/suggest.ts b/src/interactions/application-commands/slash-commands/main/suggest.ts new file mode 100644 index 00000000..6a1bbf20 --- /dev/null +++ b/src/interactions/application-commands/slash-commands/main/suggest.ts @@ -0,0 +1,62 @@ +import { type CommandInteraction, type MessageAttachment, MessageEmbed } from 'discord.js' +import { argumentUtil, constants } from '../../../../utils' +import { inject, injectable, named } from 'inversify' +import { ApplyOptions } from '../../../../utils/decorators' +import { Command } from '../base' +import type { CommandOptions } from '..' +import type { GuildContext } from '../../../../structures' +import type { GuildContextManager } from '../../../../managers' + +const { TYPES } = constants +const { validators, noTags } = argumentUtil + +@injectable() +@ApplyOptions({ + command: { + args: [ + { + key: 'suggestion', + validate: validators([noTags]) + }, + { key: 'attachment', required: false } + ] + } +}) +export default class SuggestCommand extends Command { + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + public async execute ( + interaction: CommandInteraction, + { suggestion, attachment }: { suggestion: string, attachment: MessageAttachment | null } + ): Promise { + if (!interaction.inGuild()) { + return + } + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + + if (context.suggestionsChannel === null) { + return await interaction.reply({ content: 'This server has no suggestionsChannel set yet.', ephemeral: true }) + } + if (/^\s*$/.test(suggestion)) { + return await interaction.reply({ content: 'Cannot suggest empty suggestions.', ephemeral: true }) + } + const authorUrl = `https://discord.com/users/${interaction.user.id}` + const embed = new MessageEmbed() + .setDescription(suggestion) + .setAuthor({ name: interaction.user.tag, iconURL: interaction.user.displayAvatarURL(), url: authorUrl }) + .setColor(0x000af43) + if (attachment !== null) { + if (attachment.height !== null) { + embed.setImage(attachment.url) + } + } + + const newMessage = await context.suggestionsChannel.send({ embeds: [embed] }) + await newMessage.react('⬆️') + await newMessage.react('⬇️') + + return await interaction.reply({ content: 'Successfully suggested', embeds: [embed] }) + } +} diff --git a/src/interactions/application-commands/slash-commands/main/tag.ts b/src/interactions/application-commands/slash-commands/main/tag.ts new file mode 100644 index 00000000..b111b681 --- /dev/null +++ b/src/interactions/application-commands/slash-commands/main/tag.ts @@ -0,0 +1,63 @@ +import { type CommandInteraction, type GuildMember, MessageEmbed } from 'discord.js' +import type { GuildContext, Tag } from '../../../../structures' +import { constants, util } from '../../../../utils' +import { inject, injectable, named } from 'inversify' +import { ApplyOptions } from '../../../../utils/decorators' +import { Command } from '../base' +import type { CommandOptions } from '..' +import type { GuildContextManager } from '../../../../managers' +import applicationConfig from '../../../../configs/application' + +const { TYPES } = constants +const { makeCommaSeparatedString } = util + +@injectable() +@ApplyOptions({ + command: { + args: [ + { + key: 'query', + name: 'tag', + type: 'tag', + required: false + }, + { key: 'who', required: false } + ] + } +}) +export default class TagsCommand extends Command { + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + public async execute ( + interaction: CommandInteraction, + { tag, who }: { tag: Tag | null, who: GuildMember | null } + ): Promise { + if (!interaction.inGuild()) { + return + } + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + + if (tag !== null) { + const memberMention = who?.toString() + return await interaction.reply({ + content: typeof tag.content === 'string' ? `${typeof memberMention !== 'undefined' ? `${memberMention}, ` : ''}${tag.content}` : memberMention ?? undefined, + embeds: typeof tag.content !== 'string' ? [tag.content] : undefined, + allowedMentions: { users: [who?.id ?? interaction.user.id] } + }) + } else { + let list = '' + for (const tag of context.tags.cache.values()) { + list += `${tag.id}. ${makeCommaSeparatedString(tag.names.cache.map(tagName => `\`${tagName.name}\``))}\n` + } + + const embed = new MessageEmbed() + .setTitle('Tags') + .setDescription(list) + .setFooter({ text: `Page 1/1 (${context.tags.cache.size} entries)` }) + .setColor(context.primaryColor ?? applicationConfig.defaultColor) + return await interaction.reply({ embeds: [embed] }) + } + } +} diff --git a/src/interactions/application-commands/slash-commands/main/who-is.ts b/src/interactions/application-commands/slash-commands/main/who-is.ts new file mode 100644 index 00000000..dda65247 --- /dev/null +++ b/src/interactions/application-commands/slash-commands/main/who-is.ts @@ -0,0 +1,70 @@ +import { type CommandInteraction, MessageEmbed } from 'discord.js' +import { constants, timeUtil } from '../../../../utils' +import { inject, injectable, named } from 'inversify' +import { ApplyOptions } from '../../../../utils/decorators' +import { Command } from '../base' +import type { CommandOptions } from '..' +import type { GuildContext } from '../../../../structures' +import type { GuildContextManager } from '../../../../managers' +import type { RobloxUser } from '../../../../argument-types' +import applicationConfig from '../../../../configs/application' +import pluralize from 'pluralize' +import { userService } from '../../../../services' + +const { TYPES } = constants +const { getDate } = timeUtil + +@injectable() +@ApplyOptions({ + command: { + args: [ + { + key: 'username', + name: 'user', + type: 'roblox-user', + required: false, + default: 'self' + } + ] + } +}) +export default class WhoIsCommand extends Command { + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + public async execute (interaction: CommandInteraction, { user }: { user: RobloxUser }): Promise { + const context = interaction.inGuild() + ? this.guildContexts.resolve(interaction.guildId) as GuildContext + : null + + const userInfo = await userService.getUser(user.id) + const age = Math.floor((Date.now() - new Date(userInfo.created).getTime()) / 86_400_000) + const outfits = await userService.getUserOutfits(user.id) + + const embed = new MessageEmbed() + .setAuthor({ + name: userInfo.name ?? 'Unknown', + iconURL: `https://www.roblox.com/headshot-thumbnail/image?width=150&height=150&format=png&userId=${user.id}` + }) + .setThumbnail(`https://www.roblox.com/outfit-thumbnail/image?width=150&height=150&format=png&userOutfitId=${outfits[0]?.id ?? 0}`) + .setColor(context?.primaryColor ?? applicationConfig.defaultColor) + .addField('Blurb', userInfo.description !== '' ? userInfo.description : 'No blurb') + .addField('Join Date', getDate(new Date(userInfo.created)), true) + .addField('Account Age', pluralize('day', age, true), true) + .addField('\u200b', '\u200b', true) + .setFooter({ text: `User ID: ${user.id}` }) + .setTimestamp() + // eslint-disable-next-line @typescript-eslint/prefer-optional-chain + if (context !== null && context.robloxGroupId !== null) { + const groupsRoles = await userService.getGroupsRoles(user.id) + const group = groupsRoles.find(group => group.group.id === context.robloxGroupId) + embed + .addField('Role', group?.role.name ?? 'Guest', true) + .addField('Rank', (group?.role.rank ?? 0).toString(), true) + .addField('\u200b', '\u200b', true) + } + embed.addField('\u200b', `[Profile](https://www.roblox.com/users/${user.id}/profile)`) + return await interaction.reply({ embeds: [embed] }) + } +} diff --git a/src/interactions/application-commands/slash-commands/settings/channel-links.ts b/src/interactions/application-commands/slash-commands/settings/channel-links.ts new file mode 100644 index 00000000..d05e6dd3 --- /dev/null +++ b/src/interactions/application-commands/slash-commands/settings/channel-links.ts @@ -0,0 +1,86 @@ +import { type CommandInteraction, MessageEmbed, type TextChannel, type VoiceChannel } from 'discord.js' +import { inject, injectable, named } from 'inversify' +import { ApplyOptions } from '../../../../utils/decorators' +import type { ChannelLinkService } from '../../../../services' +import type { GuildContext } from '../../../../structures' +import type { GuildContextManager } from '../../../../managers' +import { SubCommandCommand } from '../base' +import type { SubCommandCommandOptions } from '..' +import applicationConfig from '../../../../configs/application' +import { constants } from '../../../../utils' + +const { TYPES } = constants + +@injectable() +@ApplyOptions>({ + subCommands: { + link: { + args: [ + { key: 'fromchannel', name: 'fromChannel' }, + { key: 'tochannel', name: 'toChannel' } + ] + }, + unlink: { + args: [ + { key: 'fromchannel', name: 'fromChannel' }, + { key: 'tochannel', name: 'toChannel' } + ] + }, + list: { + args: [{ key: 'channel' }] + } + } +}) +export default class ChannelLinksCommand extends SubCommandCommand { + @inject(TYPES.ChannelLinkService) + private readonly channelLinkService!: ChannelLinkService + + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + public async link ( + interaction: CommandInteraction<'raw' | 'cached'>, + { fromChannel, toChannel }: { + fromChannel: VoiceChannel + toChannel: TextChannel + } + ): Promise { + await this.channelLinkService.linkChannel(fromChannel, toChannel) + + // eslint-disable-next-line @typescript-eslint/no-base-to-string + return await interaction.reply(`Successfully linked voice channel ${fromChannel.toString()} to text channel ${toChannel.toString()}.`) + } + + public async unlink ( + interaction: CommandInteraction<'raw' | 'cached'>, + { fromChannel, toChannel }: { + fromChannel: VoiceChannel + toChannel: TextChannel + } + ): Promise { + await this.channelLinkService.unlinkChannel(fromChannel, toChannel) + + // eslint-disable-next-line @typescript-eslint/no-base-to-string + return await interaction.reply(`Successfully unlinked text channel ${toChannel.toString()} from voice channel ${fromChannel.toString()}.`) + } + + public async list ( + interaction: CommandInteraction<'raw' | 'cached'>, + { channel }: { channel: VoiceChannel } + ): Promise { + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + + const links = await this.channelLinkService.fetchToLinks(channel) + if (links.size === 0) { + return await interaction.reply('No links found.') + } + + const embed = new MessageEmbed() + .setTitle(`${channel.name}'s Channel Links`) + // eslint-disable-next-line @typescript-eslint/no-base-to-string + .setDescription(links.map(channel => channel.toString()).toString()) + .setColor(context.primaryColor ?? applicationConfig.defaultColor) + return await interaction.reply({ embeds: [embed] }) + } +} diff --git a/src/interactions/application-commands/slash-commands/settings/close-ticket.ts b/src/interactions/application-commands/slash-commands/settings/close-ticket.ts new file mode 100644 index 00000000..44081848 --- /dev/null +++ b/src/interactions/application-commands/slash-commands/settings/close-ticket.ts @@ -0,0 +1,55 @@ +import type { CommandInteraction, Message } from 'discord.js' +import { inject, injectable, named } from 'inversify' +import { Command } from '../base' +import type { GuildContext } from '../../../../structures' +import type { GuildContextManager } from '../../../../managers' +import applicationConfig from '../../../../configs/application' +import { constants } from '../../../../utils' +import { discordService } from '../../../../services' + +const { TYPES } = constants + +@injectable() +export default class CloseTicketCommand extends Command { + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + public async execute (interaction: CommandInteraction): Promise { + if (!interaction.inGuild()) { + return + } + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + + const ticket = context.tickets.resolve(interaction.channelId) + if (ticket !== null) { + const prompt = await interaction.reply({ + content: 'Are you sure you want to close this ticket?', + fetchReply: true + }) as Message + const choice = (await discordService.prompt(interaction.user, prompt, ['✅', '🚫']))?.toString() === '✅' + + if (choice) { + await context.log( + interaction.user, + `${interaction.user.toString()} **closed ticket** \`${ticket.id}\``, + { footer: `Ticket ID: ${ticket.id}` } + ) + + if (interaction.user.id === ticket.author?.id) { + await ticket.close( + 'Ticket successfully closed.', + false, + context.primaryColor ?? applicationConfig.defaultColor + ) + } else { + await ticket.close( + 'The moderator has closed your ticket.', + true, + context.primaryColor ?? applicationConfig.defaultColor + ) + } + } + } + } +} diff --git a/src/interactions/application-commands/slash-commands/settings/groups.ts b/src/interactions/application-commands/slash-commands/settings/groups.ts new file mode 100644 index 00000000..c52dc128 --- /dev/null +++ b/src/interactions/application-commands/slash-commands/settings/groups.ts @@ -0,0 +1,187 @@ +import type { ChannelGroup, Group, GuildContext, RoleGroup } from '../../../../structures' +import { type CommandInteraction, MessageEmbed, type Role, type TextChannel } from 'discord.js' +import { argumentUtil, constants } from '../../../../utils' +import { inject, injectable, named } from 'inversify' +import { ApplyOptions } from '../../../../utils/decorators' +import type { GroupType } from '../../../../utils/constants' +import type { GuildContextManager } from '../../../../managers' +import { SubCommandCommand } from '../base' +import type { SubCommandCommandOptions } from '..' +import applicationConfig from '../../../../configs/application' +import { discordService } from '../../../../services' + +const { TYPES } = constants +const { validators, noNumber, noWhitespace } = argumentUtil + +@injectable() +@ApplyOptions>({ + subCommands: { + create: { + args: [ + { + key: 'name', + validate: validators([noNumber, noWhitespace]) + }, + { key: 'type' } + ] + }, + delete: { + args: [{ key: 'id', name: 'group', type: 'group' }] + }, + channels: { + add: { + args: [ + { key: 'id', name: 'group', type: 'channel-group' }, + { key: 'channel' } + ] + }, + remove: { + args: [ + { key: 'id', name: 'group', type: 'channel-group' }, + { key: 'channel' } + ] + } + }, + roles: { + add: { + args: [ + { key: 'id', name: 'group', type: 'role-group' }, + { key: 'role' } + ] + }, + remove: { + args: [ + { key: 'id', name: 'group', type: 'role-group' }, + { key: 'role' } + ] + } + }, + list: { + args: [ + { + key: 'id', + name: 'group', + type: 'group', + required: false + } + ] + } + } +}) +export default class GroupsCommand extends SubCommandCommand { + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + public async create ( + interaction: CommandInteraction, + { name, type }: { name: string, type: GroupType } + ): Promise { + if (!interaction.inGuild()) { + return + } + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + + const group = await context.groups.create(name, type) + + return await interaction.reply(`Successfully created group \`${group.name}\`.`) + } + + public async delete ( + interaction: CommandInteraction<'raw' | 'cached'>, + { group }: { group: Group } + ): Promise { + await group.delete() + + return await interaction.reply('Successfully deleted group.') + } + + public async channels ( + interaction: CommandInteraction<'raw' | 'cached'>, + subCommand: 'add' | 'remove', + { group, channel }: { group: ChannelGroup, channel: TextChannel } + ): Promise { + switch (subCommand) { + case 'add': { + await group.channels.add(channel) + + // eslint-disable-next-line @typescript-eslint/no-base-to-string + return await interaction.reply(`Successfully added channel ${channel.toString()} to group \`${group.name}\`.`) + } + case 'remove': { + await group.channels.remove(channel) + + // eslint-disable-next-line @typescript-eslint/no-base-to-string + return await interaction.reply(`Successfully removed channel ${channel.toString()} from group \`${group.name}\`.`) + } + } + } + + public async roles ( + interaction: CommandInteraction<'raw' | 'cached'>, + subCommand: 'add' | 'remove', + { group, role }: { group: RoleGroup, role: Role } + ): Promise { + switch (subCommand) { + case 'add': { + await group.roles.add(role) + + return await interaction.reply({ + content: `Successfully added role ${role.toString()} to group \`${group.name}\`.`, + allowedMentions: { users: [interaction.user.id] } + }) + } + case 'remove': { + await group.roles.remove(role) + + // eslint-disable-next-line @typescript-eslint/no-base-to-string + return await interaction.reply({ + content: `Successfully removed role ${role.toString()} from group \`${group.name}\`.`, + allowedMentions: { users: [interaction.user.id] } + }) + } + } + } + + public async list ( + interaction: CommandInteraction, + { group }: { group: Group | null } + ): Promise { + if (!interaction.inGuild()) { + return + } + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + + if (group !== null) { + const embed = new MessageEmbed() + .setTitle(`Group ${group.id}`) + .addField('Name', group.name, true) + .addField('Type', group.type, true) + .addField('Guarded', group.guarded ? 'yes' : 'no', true) + .setColor(context.primaryColor ?? applicationConfig.defaultColor) + if (group.isChannelGroup()) { + const channelsString = Array.from(group.channels.cache.values()).join(' ') + embed.addField('Channels', channelsString !== '' ? channelsString : 'none') + } else if (group.isRoleGroup()) { + const rolesString = Array.from(group.roles.cache.values()).join(' ') + embed.addField('Roles', rolesString !== '' ? rolesString : 'none') + } + return await interaction.reply({ embeds: [embed] }) + } else { + if (context.groups.cache.size === 0) { + return await interaction.reply('No groups found.') + } + + const embeds = discordService.getListEmbeds( + 'Groups', + context.groups.cache.values(), + getGroupRow + ) + await interaction.reply({ embeds }) + } + } +} + +function getGroupRow (group: Group): string { + return `${group.id}. \`${group.name}\`` +} diff --git a/src/interactions/application-commands/slash-commands/settings/index.ts b/src/interactions/application-commands/slash-commands/settings/index.ts new file mode 100644 index 00000000..67d5e85c --- /dev/null +++ b/src/interactions/application-commands/slash-commands/settings/index.ts @@ -0,0 +1,11 @@ +export { default as ChannelLinksCommand } from './channel-links' +export { default as CloseTicketCommand } from './close-ticket' +export { default as GroupsCommand } from './groups' +export { default as PanelsCommand } from './panels' +export { default as RoleBindingsCommand } from './role-bindings' +export { default as RoleMessagesCommand } from './role-messages' +export { default as SetActivityCommand } from './set-activity' +export { default as SettingsCommand } from './settings' +export { default as TagsCommand } from './tags' +export { default as TicketTypesCommand } from './ticket-types' +export { default as ToggleSupportCommand } from './toggle-support' diff --git a/src/interactions/application-commands/slash-commands/settings/panels.ts b/src/interactions/application-commands/slash-commands/settings/panels.ts new file mode 100644 index 00000000..c33f059b --- /dev/null +++ b/src/interactions/application-commands/slash-commands/settings/panels.ts @@ -0,0 +1,182 @@ +import { type CommandInteraction, Formatters, Message, type TextChannel } from 'discord.js' +import type { GuildContext, Panel, PanelUpdateOptions } from '../../../../structures' +import { argumentUtil, constants } from '../../../../utils' +import { inject, injectable, named } from 'inversify' +import { ApplyOptions } from '../../../../utils/decorators' +import type { GuildContextManager } from '../../../../managers' +import { SubCommandCommand } from '../base' +import type { SubCommandCommandOptions } from '..' +import { discordService } from '../../../../services' + +const { TYPES } = constants +const { validators, isObject, noNumber, noWhitespace } = argumentUtil + +@injectable() +@ApplyOptions>({ + subCommands: { + create: { + args: [ + { + key: 'name', + validate: validators([noNumber, noWhitespace]) + }, + { + key: 'content', + validate: validators([isObject]) + } + ] + }, + delete: { + args: [{ key: 'id', name: 'panel', type: 'panel' }] + }, + edit: { + args: [ + { key: 'id', name: 'panel', type: 'panel' }, + { + key: 'key', + parse: (val: string) => val.toLowerCase() + }, + { key: 'value', type: 'json-object|message' } + ] + }, + post: { + args: [ + { key: 'id', name: 'panel', type: 'panel' }, + { key: 'channel', required: false } + ] + }, + list: { + args: [ + { + key: 'id', + name: 'panel', + type: 'panel', + required: false + } + ] + }, + raw: { + args: [{ key: 'id', name: 'panel', type: 'panel' }] + } + } +}) +export default class PanelsCommand extends SubCommandCommand { + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + public async create ( + interaction: CommandInteraction, + { name, content }: { + name: string + content: object + } + ): Promise { + if (!interaction.inGuild()) { + return + } + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + + const panel = await context.panels.create(name, content) + + return await interaction.reply(`Successfully created panel \`${panel.name}\`.`) + } + + public async delete ( + interaction: CommandInteraction<'raw' | 'cached'>, + { panel }: { panel: Panel } + ): Promise { + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + + await context.panels.delete(panel) + + return await interaction.reply('Successfully deleted panel.') + } + + public async edit ( + interaction: CommandInteraction<'raw' | 'cached'>, + { panel, key, value }: { + panel: Panel + key: string + value: object | Message + } + ): Promise { + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + + const changes: PanelUpdateOptions = {} + if (key === 'content') { + if (value instanceof Message) { + return await interaction.reply({ content: '`value` must be an object.', ephemeral: true }) + } + + changes.content = value + } else if (key === 'message') { + if (!(value instanceof Message)) { + return await interaction.reply({ content: '`value` must be a message URL.', ephemeral: true }) + } + + changes.message = value + } + + panel = await context.panels.update(panel, changes) + + return await interaction.reply(`Successfully edited panel \`${panel.name}\`.`) + } + + public async post ( + interaction: CommandInteraction<'raw' | 'cached'>, + { panel, channel }: { + panel: Panel + channel: TextChannel | null + } + ): Promise { + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + + panel = await context.panels.post(panel, channel ?? undefined) + + return await interaction.reply(channel !== null + // eslint-disable-next-line @typescript-eslint/no-base-to-string + ? `Successfully posted panel \`${panel.name}\` in ${channel.toString()}.` + : `Successfully removed panel \`${panel.name}\` from channel.` + ) + } + + public async list ( + interaction: CommandInteraction, + { panel }: { panel: Panel | null } + ): Promise { + if (!interaction.inGuild()) { + return + } + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + + if (panel !== null) { + return await interaction.reply({ embeds: [panel.embed] }) + } else { + if (context.panels.cache.size === 0) { + return await interaction.reply('No panels found.') + } + + const embeds = discordService.getListEmbeds( + 'Panels', + context.panels.cache.values(), + getPanelRow + ) + await interaction.reply({ embeds }) + } + } + + public async raw ( + interaction: CommandInteraction<'raw' | 'cached'>, + { panel }: { panel: Panel } + ): Promise { + await interaction.reply({ + content: Formatters.codeBlock(panel.content), + allowedMentions: { users: [interaction.user.id] } + }) + } +} + +function getPanelRow (panel: Panel): string { + return `${panel.id}. \`${panel.name}\`` +} diff --git a/src/interactions/application-commands/slash-commands/settings/role-bindings.ts b/src/interactions/application-commands/slash-commands/settings/role-bindings.ts new file mode 100644 index 00000000..6ae4c0a9 --- /dev/null +++ b/src/interactions/application-commands/slash-commands/settings/role-bindings.ts @@ -0,0 +1,112 @@ +import { type CommandInteraction, MessageEmbed, type Role } from 'discord.js' +import type { GuildContext, RoleBinding } from '../../../../structures' +import { inject, injectable, named } from 'inversify' +import { ApplyOptions } from '../../../../utils/decorators' +import type { GuildContextManager } from '../../../../managers' +import { SubCommandCommand } from '../base' +import type { SubCommandCommandOptions } from '..' +import applicationConfig from '../../../../configs/application' +import { constants } from '../../../../utils' +import { discordService } from '../../../../services' +import lodash from 'lodash' + +const { TYPES } = constants + +@injectable() +@ApplyOptions>({ + requiresRobloxGroup: true, + subCommands: { + create: { + args: [ + { key: 'role' }, + { key: 'min' }, + { key: 'max', required: false } + ] + }, + delete: { + args: [{ key: 'id', name: 'roleBinding', type: 'role-binding' }] + }, + list: { + args: [ + { + key: 'id', + name: 'roleBinding', + type: 'role-binding', + required: false + } + ] + } + } +}) +export default class RoleBindingsCommand extends SubCommandCommand { + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + public async create ( + interaction: CommandInteraction<'raw' | 'cached'>, + { role, min, max }: { + role: Role + min: number + max: number | null + } + ): Promise { + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + + const roleBinding = await context.roleBindings.create({ role, min, max: max ?? undefined }) + + return await interaction.reply({ + content: `Successfully bound group \`${roleBinding.robloxGroupId}\` rank \`${getRangeString(roleBinding.min, roleBinding.max)}\` to role ${roleBinding.role?.toString() ?? 'Unknown'}.`, + allowedMentions: { users: [interaction.user.id] } + }) + } + + public async delete ( + interaction: CommandInteraction<'raw' | 'cached'>, + { roleBinding }: { roleBinding: RoleBinding } + ): Promise { + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + + await context.roleBindings.delete(roleBinding) + + return await interaction.reply('Successfully deleted role binding.') + } + + public async list ( + interaction: CommandInteraction<'raw' | 'cached'>, + { roleBinding }: { roleBinding: RoleBinding | null } + ): Promise { + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + + if (roleBinding !== null) { + const embed = new MessageEmbed() + .addField(`Role Binding ${roleBinding.id}`, `\`${roleBinding.robloxGroupId}\` \`${getRangeString(roleBinding.min, roleBinding.max)}\` => ${roleBinding.role?.toString() ?? 'Unknown'}`) + .setColor(context.primaryColor ?? applicationConfig.defaultColor) + return await interaction.reply({ embeds: [embed] }) + } else { + await context.roleBindings.fetch() + if (context.roleBindings.cache.size === 0) { + return await interaction.reply('No role bindings found.') + } + + const embeds = discordService.getListEmbeds( + 'Role Bindings', + Object.values(lodash.groupBy(Array.from(context.roleBindings.cache.values()), 'roleId')), + getGroupedRoleBindingRow + ) + await interaction.reply({ embeds }) + } + } +} + +function getGroupedRoleBindingRow (roleBindings: RoleBinding[]): string { + let result = `${roleBindings[0].role?.toString() ?? 'Unknown'}\n` + for (const roleBinding of roleBindings) { + result += `${roleBinding.id}. \`${roleBinding.robloxGroupId}\` \`${getRangeString(roleBinding.min, roleBinding.max)}\`\n` + } + return result +} + +function getRangeString (min: number, max: number | null): string { + return `${max !== null ? '[' : ''}${min}${max !== null ? `, ${max}]` : ''}` +} diff --git a/src/interactions/application-commands/slash-commands/settings/role-messages.ts b/src/interactions/application-commands/slash-commands/settings/role-messages.ts new file mode 100644 index 00000000..a770f3a4 --- /dev/null +++ b/src/interactions/application-commands/slash-commands/settings/role-messages.ts @@ -0,0 +1,109 @@ +import { type CommandInteraction, type Message, MessageEmbed, type Role } from 'discord.js' +import type { GuildContext, RoleMessage } from '../../../../structures' +import { inject, injectable, named } from 'inversify' +import { ApplyOptions } from '../../../../utils/decorators' +import type { GuildContextManager } from '../../../../managers' +import { SubCommandCommand } from '../base' +import type { SubCommandCommandOptions } from '..' +import applicationConfig from '../../../../configs/application' +import { constants } from '../../../../utils' +import { discordService } from '../../../../services' +import lodash from 'lodash' + +const { TYPES } = constants + +@injectable() +@ApplyOptions>({ + subCommands: { + create: { + args: [ + { key: 'role' }, + { key: 'message', type: 'message' }, + { key: 'emoji', type: 'custom-emoji|default-emoji' } + ] + }, + delete: { + args: [{ key: 'id', name: 'roleMessage', type: 'role-message' }] + }, + list: { + args: [ + { + key: 'id', + name: 'roleMessage', + type: 'role-message', + required: false + } + ] + } + } +}) +export default class RoleMessagesCommand extends SubCommandCommand { + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + public async create ( + interaction: CommandInteraction<'raw' | 'cached'>, + { role, message, emoji }: { + role: Role + message: Message + emoji: string + } + ): Promise { + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + + const roleMessage = await context.roleMessages.create({ role, message, emoji }) + + return await interaction.reply({ + content: `Successfully bound role ${roleMessage.role?.toString() ?? 'Unknown'} to emoji ${roleMessage.emoji?.toString() ?? 'Unknown'} on message \`${roleMessage.messageId ?? 'unknown'}\`.`, + allowedMentions: { users: [message.author.id] } + }) + } + + public async delete ( + interaction: CommandInteraction<'raw' | 'cached'>, + { roleMessage }: { roleMessage: RoleMessage } + ): Promise { + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + + await context.roleMessages.delete(roleMessage) + + return await interaction.reply('Successfully deleted role message.') + } + + public async list ( + interaction: CommandInteraction, + { roleMessage }: { roleMessage: RoleMessage | null } + ): Promise { + if (!interaction.inGuild()) { + return + } + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + + if (roleMessage !== null) { + const embed = new MessageEmbed() + .addField(`Role Message ${roleMessage.id}`, `Message ID: \`${roleMessage.messageId ?? 'unknown'}\`, ${roleMessage.emoji?.toString() ?? 'Unknown'} => ${roleMessage.role?.toString() ?? 'Unknown'}`) + .setColor(context.primaryColor ?? applicationConfig.defaultColor) + return await interaction.reply({ embeds: [embed] }) + } else { + if (context.roleMessages.cache.size === 0) { + return await interaction.reply('No role messages found.') + } + + const embeds = discordService.getListEmbeds( + 'Role Messages', + Object.values(lodash.groupBy(Array.from(context.roleMessages.cache.values()), 'messageId')), + getGroupedRoleMessageRow + ) + await interaction.reply({ embeds }) + } + } +} + +function getGroupedRoleMessageRow (roleMessages: RoleMessage[]): string { + let result = `**${roleMessages[0].messageId ?? 'unknown'}**\n` + for (const roleMessage of roleMessages) { + result += `${roleMessage.id}. ${roleMessage.emoji?.toString() ?? 'Unknown'} => ${roleMessage.role?.toString() ?? 'Unknown'}\n` + } + return result +} diff --git a/src/interactions/application-commands/slash-commands/settings/set-activity.ts b/src/interactions/application-commands/slash-commands/settings/set-activity.ts new file mode 100644 index 00000000..6fb107c0 --- /dev/null +++ b/src/interactions/application-commands/slash-commands/settings/set-activity.ts @@ -0,0 +1,55 @@ +import type { ActivityType, CommandInteraction } from 'discord.js' +import { ApplyOptions } from '../../../../utils/decorators' +import { Command } from '../base' +import type { CommandOptions } from '..' +import { argumentUtil } from '../../../../utils' +import { injectable } from 'inversify' + +const { urlRegex } = argumentUtil + +const endUrlRegex = new RegExp(`(?:\\s*)${urlRegex.toString().slice(1, -3)}$`, 'i') + +@injectable() +@ApplyOptions({ + requiresSingleGuild: true, + command: { + args: [ + { key: 'name', required: false }, + { + key: 'type', + parse: (val: string) => val.toUpperCase(), + required: false + } + ] + } +}) +export default class SetActivityCommand extends Command { + public async execute ( + interaction: CommandInteraction, + { name, type }: { name: string | null, type: Exclude | null } + ): Promise { + if (name === null || type === null) { + await this.client.startActivityCarousel() + + return await interaction.reply('Successfully set activity back to default.') + } else { + const options: { type: Exclude, url?: string } = { type } + if (type === 'STREAMING') { + const match = name.match(endUrlRegex) + if (match === null) { + return await interaction.reply('No URL specified.') + } + name = name.replace(endUrlRegex, '') + if (name === '') { + return await interaction.reply('Name cannot be empty.') + } + options.url = match[1] + } + + this.client.stopActivityCarousel() + await this.client.user?.setActivity(name, options) + + return await interaction.reply(`Successfully set activity to \`${type} ${name}\`.`) + } + } +} diff --git a/src/interactions/application-commands/slash-commands/settings/settings.ts b/src/interactions/application-commands/slash-commands/settings/settings.ts new file mode 100644 index 00000000..ffbf13c1 --- /dev/null +++ b/src/interactions/application-commands/slash-commands/settings/settings.ts @@ -0,0 +1,150 @@ +import { CategoryChannel, type CommandInteraction, GuildChannel, TextChannel } from 'discord.js' +import type { GuildContext, GuildUpdateOptions } from '../../../../structures' +import { argumentUtil, constants, util } from '../../../../utils' +import { inject, injectable, named } from 'inversify' +import { ApplyOptions } from '../../../../utils/decorators' +import type { GuildContextManager } from '../../../../managers' +import { SubCommandCommand } from '../base' +import type { SubCommandCommandOptions } from '..' +import { VerificationProvider } from '../../../../utils/constants' + +const GuildSetting = constants.GuildSetting +const { TYPES } = constants +const { getEnumValues } = util +const { guildSettingTransformer, parseEnum } = argumentUtil + +@injectable() +@ApplyOptions>({ + subCommands: { + get: { + args: [ + { + key: 'setting', + parse: parseEnum(GuildSetting, guildSettingTransformer) + } + ] + }, + set: { + args: [ + { + key: 'setting', + parse: parseEnum(GuildSetting, guildSettingTransformer) + }, + { + key: 'value', + type: 'category-channel|text-channel|integer|boolean|always', + required: false + } + ] + } + } +}) +export default class SettingsCommand extends SubCommandCommand { + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + public async get ( + interaction: CommandInteraction, + { setting }: { setting: keyof typeof GuildSetting } + ): Promise { + if (!interaction.inGuild()) { + return + } + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + + let settingName: string = setting + let result: GuildChannel | string | boolean | number | null + if (setting === 'primaryColor') { + const color = context.primaryColor?.toString(16) ?? '' + result = `0x${color}${'0'.repeat(6 - color.length)}` + } else if (setting.includes('Channel') || setting.includes('Category')) { + settingName = guildSettingTransformer(setting) + result = context[settingName as keyof GuildContext] as GuildChannel + } else if (setting.includes('Id')) { + result = context[setting] + settingName = guildSettingTransformer(setting) + } else { + result = context[setting] + } + + return await interaction.reply(`The ${settingName} is ${result instanceof GuildChannel ? result.toString() : `\`${String(result)}\``}.`) + } + + public async set ( + interaction: CommandInteraction, + { setting, value }: { + setting: keyof typeof GuildSetting + value: CategoryChannel | TextChannel | number | boolean | string | null + } + ): Promise { + if (!interaction.inGuild()) { + return + } + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + + const changes: GuildUpdateOptions = {} + if (value === null && !['robloxUsernamesInNicknames', 'verificationPreference'].includes(setting)) { + changes[ + guildSettingTransformer(setting) as keyof Omit< + GuildUpdateOptions, + 'robloxUsernamesInNicknames' | 'verificationPreference' | 'supportEnabled' + > + ] = null + } else { + if (setting === 'primaryColor') { + if (typeof value !== 'number') { + value = parseInt(String(value), 16) + if (isNaN(value)) { + return await interaction.reply('Invalid color.') + } + } else if (value < 0 || value > parseInt('0xffffff', 16)) { + return await interaction.reply('Color out of bounds.') + } + + changes.primaryColor = value + } else if (setting === 'robloxGroupId') { + if (typeof value !== 'number') { + return await interaction.reply('Invalid ID.') + } + + changes.robloxGroup = value + } else if (setting === 'robloxUsernamesInNicknames') { + if (typeof value !== 'boolean') { + return await interaction.reply('Invalid boolean.') + } + + changes.robloxUsernamesInNicknames = value + } else if (setting === 'verificationPreference') { + if (typeof value !== 'string' || !getEnumValues(VerificationProvider).includes(value.toLowerCase())) { + return await interaction.reply('Invalid verification provider.') + } + value = value.toLowerCase() + + changes.verificationPreference = value as VerificationProvider + } else if (setting.includes('Channel') || setting.includes('Category')) { + if (setting === 'ticketsCategoryId') { + if (!(value instanceof CategoryChannel)) { + return await interaction.reply('Invalid category channel.') + } + } else { + if (!(value instanceof TextChannel)) { + return await interaction.reply('Invalid channel.') + } + } + + changes[ + guildSettingTransformer(setting) as keyof Pick< + GuildUpdateOptions, + 'logsChannel' | 'ratingsChannel' | 'suggestionsChannel' | 'ticketArchivesChannel' | 'ticketsCategory' + > + ] = value.id + } + } + + await context.update(changes) + + // eslint-disable-next-line @typescript-eslint/no-base-to-string + return await interaction.reply(`Successfully set ${guildSettingTransformer(setting)} to ${value instanceof GuildChannel ? value.toString() : `\`${String(value)}\``}.`) + } +} diff --git a/src/interactions/application-commands/slash-commands/settings/tags.ts b/src/interactions/application-commands/slash-commands/settings/tags.ts new file mode 100644 index 00000000..250ac04a --- /dev/null +++ b/src/interactions/application-commands/slash-commands/settings/tags.ts @@ -0,0 +1,163 @@ +import { type CommandInteraction, Formatters } from 'discord.js' +import type { GuildContext, Tag, TagUpdateOptions } from '../../../../structures' +import { argumentUtil, constants } from '../../../../utils' +import { inject, injectable, named } from 'inversify' +import { ApplyOptions } from '../../../../utils/decorators' +import type { GuildContextManager } from '../../../../managers' +import { SubCommandCommand } from '../base' +import type { SubCommandCommandOptions } from '..' + +const { TYPES } = constants +const { validators, isObject, typeOf } = argumentUtil + +@injectable() +@ApplyOptions>({ + subCommands: { + create: { + args: [ + { key: 'name' }, + { + key: 'content', + type: 'json-object|always', + validate: validators([[isObject, typeOf('string')]]) + } + ] + }, + delete: { + args: [{ key: 'id', name: 'tag', type: 'tag' }] + }, + edit: { + args: [ + { key: 'id', name: 'tag', type: 'tag' }, + { + key: 'key', + parse: (val: string) => val.toLowerCase() + }, + { key: 'value', type: 'json-object|always' } + ] + }, + aliases: { + create: { + args: [ + { key: 'id', name: 'tag', type: 'tag' }, + { key: 'name' } + ] + }, + delete: { + args: [ + { key: 'id', name: 'tag', type: 'tag' }, + { key: 'name' } + ] + } + }, + raw: { + args: [{ key: 'id', name: 'tag', type: 'tag' }] + } + } +}) +export default class TagsCommand extends SubCommandCommand { + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + public async create ( + interaction: CommandInteraction, + { name, content }: { name: string, content: string | object } + ): Promise { + if (!interaction.inGuild()) { + return + } + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + + const tag = await context.tags.create(name, content) + + return await interaction.reply(`Successfully created tag \`${tag.names.cache.first()?.name ?? 'Unknown'}\`.`) + } + + public async delete ( + interaction: CommandInteraction<'raw' | 'cached'>, + { tag }: { tag: Tag } + ): Promise { + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + + await context.tags.delete(tag) + + return await interaction.reply('Successfully deleted tag.') + } + + public async edit ( + interaction: CommandInteraction<'raw' | 'cached'>, + { tag, key, value }: { + tag: Tag + key: string + value: string | object + } + ): Promise { + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + + const changes: TagUpdateOptions = {} + if (key === 'content') { + if ( + typeof value !== 'string' && Object.prototype.toString.call(JSON.parse(String(value))) !== '[object Object]' + ) { + return await interaction.reply({ content: '`value` must be a string or object.', ephemeral: true }) + } + + changes.content = value + } + + tag = await context.tags.update(tag, changes) + + return await interaction.reply(`Successfully edited tag \`${tag.names.cache.first()?.name ?? 'Unknown'}\`.`) + } + + public aliases ( + interaction: CommandInteraction<'raw' | 'cached'>, + subCommand: 'create', + { tag, name }: { tag: Tag, name: string } + ): Promise + public aliases ( + interaction: CommandInteraction<'raw' | 'cached'>, + subCommand: 'delete', + { name }: { name: string } + ): Promise + public async aliases ( + interaction: CommandInteraction<'raw' | 'cached'>, + subCommand: 'create' | 'delete', + { tag, name }: { tag?: Tag, name: string } + ): Promise { + switch (subCommand) { + case 'create': { + if (typeof tag === 'undefined') { + return + } + + const tagName = await tag.names.create(name) + + return await interaction.reply(`Successfully created alias \`${tagName.name}\` for tag \`${tag.names.cache.first()?.name ?? 'Unknown'}\`.`) + } + case 'delete': { + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + + const tag = context.tags.resolve(name) + if (tag === null) { + return await interaction.reply('Tag not found.') + } + + await tag.names.delete(name) + + return await interaction.reply(`Successfully deleted alias from tag \`${tag.names.cache.first()?.name ?? 'Unknown'}\`.`) + } + } + } + + public async raw ( + interaction: CommandInteraction<'raw' | 'cached'>, + { tag }: { tag: Tag } + ): Promise { + await interaction.reply({ + content: Formatters.codeBlock(tag._content), + allowedMentions: { users: [interaction.user.id] } + }) + } +} diff --git a/src/interactions/application-commands/slash-commands/settings/ticket-types.ts b/src/interactions/application-commands/slash-commands/settings/ticket-types.ts new file mode 100644 index 00000000..62d526cf --- /dev/null +++ b/src/interactions/application-commands/slash-commands/settings/ticket-types.ts @@ -0,0 +1,132 @@ +import { type CommandInteraction, type Message, MessageEmbed } from 'discord.js' +import type { GuildContext, TicketType } from '../../../../structures' +import { inject, injectable, named } from 'inversify' +import { ApplyOptions } from '../../../../utils/decorators' +import type { GuildContextManager } from '../../../../managers' +import { SubCommandCommand } from '../base' +import type { SubCommandCommandOptions } from '..' +import applicationConfig from '../../../../configs/application' +import { constants } from '../../../../utils' +import { discordService } from '../../../../services' + +const { TYPES } = constants + +@injectable() +@ApplyOptions>({ + subCommands: { + create: { + args: [{ key: 'name' }] + }, + delete: { + args: [{ key: 'id', name: 'ticketType', type: 'ticket-type' }] + }, + link: { + args: [ + { key: 'id', name: 'ticketType', type: 'ticket-type' }, + { key: 'emoji', type: 'custom-emoji|default-emoji' }, + { key: 'message', type: 'message' } + ] + }, + unlink: { + args: [{ key: 'id', name: 'ticketType', type: 'ticket-type' }] + }, + list: { + args: [ + { + key: 'id', + name: 'ticketType', + type: 'ticket-type', + required: false + } + ] + } + } +}) +export default class TicketTypesCommand extends SubCommandCommand { + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + public async create ( + interaction: CommandInteraction, + { name }: { name: string } + ): Promise { + if (!interaction.inGuild()) { + return + } + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + + const type = await context.ticketTypes.create(name) + + return await interaction.reply(`Successfully created ticket type \`${type.name}\`.`) + } + + public async delete ( + interaction: CommandInteraction<'raw' | 'cached'>, + { ticketType }: { ticketType: TicketType } + ): Promise { + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + + await context.ticketTypes.delete(ticketType) + + return await interaction.reply('Successfully deleted ticket type.') + } + + public async link ( + interaction: CommandInteraction<'raw' | 'cached'>, + { ticketType, emoji, message }: { + ticketType: TicketType + emoji: string + message: Message + } + ): Promise { + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + + ticketType = await context.ticketTypes.link(ticketType, message, emoji) + + return await interaction.reply(`Successfully linked emoji ${ticketType.emoji?.toString() ?? 'Unknown'} on message \`${ticketType.messageId ?? 'unknown'}\` to ticket type \`${ticketType.name}\`.`) + } + + public async unlink ( + interaction: CommandInteraction<'raw' | 'cached'>, + { ticketType }: { ticketType: TicketType } + ): Promise { + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + + ticketType = await context.ticketTypes.unlink(ticketType) + + return await interaction.reply(`Successfully unlinked message reaction from ticket type \`${ticketType.name}\`.`) + } + + public async list ( + interaction: CommandInteraction, + { ticketType }: { ticketType: TicketType | null } + ): Promise { + if (!interaction.inGuild()) { + return + } + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + + if (ticketType !== null) { + const embed = new MessageEmbed() + .addField(`Ticket Type ${ticketType.id}`, `Name: \`${ticketType.name}\``) + .setColor(context.primaryColor ?? applicationConfig.defaultColor) + return await interaction.reply({ embeds: [embed] }) + } else { + if (context.ticketTypes.cache.size === 0) { + return await interaction.reply('No ticket types found.') + } + + const embeds = discordService.getListEmbeds( + 'Ticket Types', + context.ticketTypes.cache.values(), + getTicketTypeRow + ) + await interaction.reply({ embeds }) + } + } +} + +function getTicketTypeRow (type: TicketType): string { + return `${type.id}. \`${type.name}\`` +} diff --git a/src/interactions/application-commands/slash-commands/settings/toggle-support.ts b/src/interactions/application-commands/slash-commands/settings/toggle-support.ts new file mode 100644 index 00000000..902dbf78 --- /dev/null +++ b/src/interactions/application-commands/slash-commands/settings/toggle-support.ts @@ -0,0 +1,30 @@ +import { type CommandInteraction, MessageEmbed } from 'discord.js' +import { inject, injectable, named } from 'inversify' +import { Command } from '../base' +import type { GuildContext } from '../../../../structures' +import type { GuildContextManager } from '../../../../managers' +import { constants } from '../../../../utils' + +const { TYPES } = constants + +@injectable() +export default class ToggleSupportCommand extends Command { + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + public async execute (interaction: CommandInteraction): Promise { + if (!interaction.inGuild()) { + return + } + const context = this.guildContexts.resolve(interaction.guildId) as GuildContext + + await context.update({ supportEnabled: !context.supportEnabled }) + + const embed = new MessageEmbed() + .setColor(context.supportEnabled ? 0x00ff00 : 0xff0000) + .setTitle('Successfully toggled support') + .setDescription(`Tickets System: **${context.supportEnabled ? 'online' : 'offline'}**`) + return await interaction.reply({ embeds: [embed] }) + } +} diff --git a/src/interactions/data/application-commands/index.ts b/src/interactions/data/application-commands/index.ts new file mode 100644 index 00000000..2ba28e26 --- /dev/null +++ b/src/interactions/data/application-commands/index.ts @@ -0,0 +1 @@ +export * from './slash-commands' diff --git a/src/interactions/data/application-commands/slash-commands/admin/bans.ts b/src/interactions/data/application-commands/slash-commands/admin/bans.ts new file mode 100644 index 00000000..440fa28a --- /dev/null +++ b/src/interactions/data/application-commands/slash-commands/admin/bans.ts @@ -0,0 +1,102 @@ +import { ApplicationCommandOptionType, type RESTPutAPIApplicationCommandsJSONBody } from 'discord-api-types/v10' + +const bansCommand: RESTPutAPIApplicationCommandsJSONBody[number] = { + name: 'bans', + description: 'Ban or unban a Roblox user', + default_member_permissions: '0', + dm_permission: false, + options: [{ + name: 'create', + description: 'Ban a Roblox user', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'username', + description: 'The username of the user to ban', + type: ApplicationCommandOptionType.String, + required: true + }, { + name: 'reason', + description: 'The reason for this ban', + type: ApplicationCommandOptionType.String, + required: true + }, { + name: 'duration', + description: 'The amount of days to ban this user', + type: ApplicationCommandOptionType.Integer, + min_value: 1, + max_value: 7 + }] + }, { + name: 'delete', + description: 'Unban a Roblox user', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'username', + description: 'The username of the user to unban', + type: ApplicationCommandOptionType.String, + required: true + }, { + name: 'reason', + description: 'The reason for this unban', + type: ApplicationCommandOptionType.String, + required: true + }] + }, { + name: 'edit', + description: 'Edit a ban', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'username', + description: 'The username of the ban to edit', + type: ApplicationCommandOptionType.String, + required: true + }, { + name: 'key', + description: 'The key of the ban to edit', + type: ApplicationCommandOptionType.String, + required: true, + choices: [ + { name: 'author', value: 'author' }, + { name: 'reason', value: 'reason' } + ] + }, { + name: 'value', + description: 'The value to change this key to', + type: ApplicationCommandOptionType.String, + required: true + }] + }, { + name: 'extend', + description: 'Extend a ban', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'username', + description: 'The username of the ban to extend', + type: ApplicationCommandOptionType.String, + required: true + }, { + name: 'duration', + description: 'The amount of days to extend this ban with', + type: ApplicationCommandOptionType.Integer, + required: true, + min_value: 1, + max_value: 7 + }, { + name: 'reason', + description: 'The reason for this extension', + type: ApplicationCommandOptionType.String, + required: true + }] + }, { + name: 'list', + description: 'List a specific ban or all bans', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'username', + description: 'The username of the ban to list', + type: ApplicationCommandOptionType.String + }] + }] +} + +export default bansCommand diff --git a/src/interactions/data/application-commands/slash-commands/admin/demote.ts b/src/interactions/data/application-commands/slash-commands/admin/demote.ts new file mode 100644 index 00000000..7e8c2c5c --- /dev/null +++ b/src/interactions/data/application-commands/slash-commands/admin/demote.ts @@ -0,0 +1,16 @@ +import { ApplicationCommandOptionType, type RESTPutAPIApplicationCommandsJSONBody } from 'discord-api-types/v10' + +const demoteCommand: RESTPutAPIApplicationCommandsJSONBody[number] = { + name: 'demote', + description: 'Demote a Roblox user in the group', + default_member_permissions: '0', + dm_permission: false, + options: [{ + name: 'username', + description: 'Username of the user to demote', + type: ApplicationCommandOptionType.String, + required: true + }] +} + +export default demoteCommand diff --git a/src/interactions/data/application-commands/slash-commands/admin/exiles.ts b/src/interactions/data/application-commands/slash-commands/admin/exiles.ts new file mode 100644 index 00000000..f7ce37fb --- /dev/null +++ b/src/interactions/data/application-commands/slash-commands/admin/exiles.ts @@ -0,0 +1,50 @@ +import { ApplicationCommandOptionType, type RESTPutAPIApplicationCommandsJSONBody } from 'discord-api-types/v10' + +const exilesCommand: RESTPutAPIApplicationCommandsJSONBody[number] = { + name: 'exiles', + description: 'Exile or unexile a Roblox user', + default_member_permissions: '0', + dm_permission: false, + options: [{ + name: 'create', + description: 'Exile a Roblox user', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'username', + description: 'The username of the user to exile', + type: ApplicationCommandOptionType.String, + required: true + }, { + name: 'reason', + description: 'The reason for this exile', + type: ApplicationCommandOptionType.String, + required: true + }] + }, { + name: 'delete', + description: 'Unexile a Roblox user', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'username', + description: 'The username of the user to unexile', + type: ApplicationCommandOptionType.String, + required: true + }, { + name: 'reason', + description: 'The reason for this unexile', + type: ApplicationCommandOptionType.String, + required: true + }] + }, { + name: 'list', + description: 'List a specific exile or all exiles', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'username', + description: 'The username of the exile to list', + type: ApplicationCommandOptionType.String + }] + }] +} + +export default exilesCommand diff --git a/src/interactions/data/application-commands/slash-commands/admin/index.ts b/src/interactions/data/application-commands/slash-commands/admin/index.ts new file mode 100644 index 00000000..28691732 --- /dev/null +++ b/src/interactions/data/application-commands/slash-commands/admin/index.ts @@ -0,0 +1,7 @@ +export { default as bansCommand } from './bans' +export { default as demoteCommand } from './demote' +export { default as exilesCommand } from './exiles' +export { default as persistentRolesCommand } from './persistent-roles' +export { default as promoteCommand } from './promote' +export { default as shoutCommand } from './shout' +export { default as trainingsCommand } from './trainings' diff --git a/src/interactions/data/application-commands/slash-commands/admin/persistent-roles.ts b/src/interactions/data/application-commands/slash-commands/admin/persistent-roles.ts new file mode 100644 index 00000000..215d258a --- /dev/null +++ b/src/interactions/data/application-commands/slash-commands/admin/persistent-roles.ts @@ -0,0 +1,51 @@ +import { ApplicationCommandOptionType, type RESTPutAPIApplicationCommandsJSONBody } from 'discord-api-types/v10' + +const persistentRolesCommand: RESTPutAPIApplicationCommandsJSONBody[number] = { + name: 'persistentroles', + description: 'Persist or unpersist roles on a member', + default_member_permissions: '0', + dm_permission: false, + options: [{ + name: 'persist', + description: 'Persist a role on a member', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'member', + description: 'The member to persist a role on', + type: ApplicationCommandOptionType.User, + required: true + }, { + name: 'role', + description: 'The role to persist on this member', + type: ApplicationCommandOptionType.Role, + required: true + }] + }, { + name: 'unpersist', + description: 'Unpersist a role on a member', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'member', + description: 'The member to remove a persistent role from', + type: ApplicationCommandOptionType.User, + required: true + }, { + name: 'role', + description: 'The persistent role to remove from this member', + type: ApplicationCommandOptionType.Role, + required: true + }] + }, { + name: 'list', + description: 'List a member\'s persistent roles', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'member', + description: 'The member to list the persistent roles of', + type: ApplicationCommandOptionType.User, + required: true + }] + }] +} + +export default persistentRolesCommand diff --git a/src/interactions/data/application-commands/slash-commands/admin/promote.ts b/src/interactions/data/application-commands/slash-commands/admin/promote.ts new file mode 100644 index 00000000..dee3eb9b --- /dev/null +++ b/src/interactions/data/application-commands/slash-commands/admin/promote.ts @@ -0,0 +1,16 @@ +import { ApplicationCommandOptionType, type RESTPutAPIApplicationCommandsJSONBody } from 'discord-api-types/v10' + +const promoteCommand: RESTPutAPIApplicationCommandsJSONBody[number] = { + name: 'promote', + description: 'Promote a Roblox user in the group', + default_member_permissions: '0', + dm_permission: false, + options: [{ + name: 'username', + description: 'Username of the user to promote', + type: ApplicationCommandOptionType.String, + required: true + }] +} + +export default promoteCommand diff --git a/src/interactions/data/application-commands/slash-commands/admin/shout.ts b/src/interactions/data/application-commands/slash-commands/admin/shout.ts new file mode 100644 index 00000000..ed8bab0a --- /dev/null +++ b/src/interactions/data/application-commands/slash-commands/admin/shout.ts @@ -0,0 +1,15 @@ +import { ApplicationCommandOptionType, type RESTPutAPIApplicationCommandsJSONBody } from 'discord-api-types/v10' + +const shoutCommand: RESTPutAPIApplicationCommandsJSONBody[number] = { + name: 'shout', + description: 'Post a message to the group shout', + default_member_permissions: '0', + dm_permission: false, + options: [{ + name: 'message', + description: 'The message to shout', + type: ApplicationCommandOptionType.String + }] +} + +export default shoutCommand diff --git a/src/interactions/data/application-commands/slash-commands/admin/trainings.ts b/src/interactions/data/application-commands/slash-commands/admin/trainings.ts new file mode 100644 index 00000000..a0c7e317 --- /dev/null +++ b/src/interactions/data/application-commands/slash-commands/admin/trainings.ts @@ -0,0 +1,85 @@ +import { ApplicationCommandOptionType, type RESTPutAPIApplicationCommandsJSONBody } from 'discord-api-types/v10' + +const trainingsCommand: RESTPutAPIApplicationCommandsJSONBody[number] = { + name: 'trainings', + description: 'Schedule or cancel a training', + default_member_permissions: '0', + dm_permission: false, + options: [{ + name: 'schedule', + description: 'Schedule a training', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'type', + description: 'The type of this training', + type: ApplicationCommandOptionType.String, + required: true + }, { + name: 'date', + description: 'The date to schedule this training at', + type: ApplicationCommandOptionType.String, + required: true + }, { + name: 'time', + description: 'The time to schedule this training at', + type: ApplicationCommandOptionType.String, + required: true + }, { + name: 'notes', + description: 'Additional notes for this training', + type: ApplicationCommandOptionType.String + }] + }, { + name: 'cancel', + description: 'Cancel a training', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'id', + description: 'The ID of the training to cancel', + type: ApplicationCommandOptionType.Integer, + required: true + }, { + name: 'reason', + description: 'The reason for this cancellation', + type: ApplicationCommandOptionType.String, + required: true + }] + }, { + name: 'edit', + description: 'Edit a training', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'id', + description: 'The ID of the training to edit', + type: ApplicationCommandOptionType.Integer, + required: true + }, { + name: 'key', + description: 'The key of the training to edit', + type: ApplicationCommandOptionType.String, + required: true, + choices: [ + { name: 'author', value: 'author' }, + { name: 'type', value: 'type' }, + { name: 'time', value: 'time' }, + { name: 'notes', value: 'notes' } + ] + }, { + name: 'value', + description: 'The value to change this key to', + type: ApplicationCommandOptionType.String, + required: true + }] + }, { + name: 'list', + description: 'List a specific training or all trainings', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'id', + description: 'The ID of the training to list', + type: ApplicationCommandOptionType.Integer + }] + }] +} + +export default trainingsCommand diff --git a/src/interactions/data/application-commands/slash-commands/bot/index.ts b/src/interactions/data/application-commands/slash-commands/bot/index.ts new file mode 100644 index 00000000..67ec7d1c --- /dev/null +++ b/src/interactions/data/application-commands/slash-commands/bot/index.ts @@ -0,0 +1,2 @@ +export { default as restartCommand } from './restart' +export { default as statusCommand } from './status' diff --git a/src/interactions/data/application-commands/slash-commands/bot/restart.ts b/src/interactions/data/application-commands/slash-commands/bot/restart.ts new file mode 100644 index 00000000..8fa5dde0 --- /dev/null +++ b/src/interactions/data/application-commands/slash-commands/bot/restart.ts @@ -0,0 +1,9 @@ +import type { RESTPutAPIApplicationCommandsJSONBody } from 'discord-api-types/v10' + +const restartCommand: RESTPutAPIApplicationCommandsJSONBody[number] = { + name: 'restart', + description: 'Restarts the bot', + default_member_permissions: '0' +} + +export default restartCommand diff --git a/src/interactions/data/application-commands/slash-commands/bot/status.ts b/src/interactions/data/application-commands/slash-commands/bot/status.ts new file mode 100644 index 00000000..adbb573e --- /dev/null +++ b/src/interactions/data/application-commands/slash-commands/bot/status.ts @@ -0,0 +1,8 @@ +import type { RESTPutAPIApplicationCommandsJSONBody } from 'discord-api-types/v10' + +const statusCommand: RESTPutAPIApplicationCommandsJSONBody[number] = { + name: 'status', + description: 'Posts the bot\'s system statuses' +} + +export default statusCommand diff --git a/src/interactions/data/application-commands/slash-commands/index.ts b/src/interactions/data/application-commands/slash-commands/index.ts new file mode 100644 index 00000000..d92703b0 --- /dev/null +++ b/src/interactions/data/application-commands/slash-commands/index.ts @@ -0,0 +1,4 @@ +export * from './admin' +export * from './bot' +export * from './main' +export * from './settings' diff --git a/src/interactions/data/application-commands/slash-commands/main/boost-info.ts b/src/interactions/data/application-commands/slash-commands/main/boost-info.ts new file mode 100644 index 00000000..50214f80 --- /dev/null +++ b/src/interactions/data/application-commands/slash-commands/main/boost-info.ts @@ -0,0 +1,15 @@ +import { ApplicationCommandOptionType, type RESTPutAPIApplicationCommandsJSONBody } from 'discord-api-types/v10' + +const boostInfoCommand: RESTPutAPIApplicationCommandsJSONBody[number] = { + name: 'boostinfo', + description: 'Get a member\'s boost information', + default_member_permissions: '0', + dm_permission: false, + options: [{ + name: 'member', + description: 'The member to get the boost information of', + type: ApplicationCommandOptionType.User + }] +} + +export default boostInfoCommand diff --git a/src/interactions/data/application-commands/slash-commands/main/delete-suggestion.ts b/src/interactions/data/application-commands/slash-commands/main/delete-suggestion.ts new file mode 100644 index 00000000..0fed1f0d --- /dev/null +++ b/src/interactions/data/application-commands/slash-commands/main/delete-suggestion.ts @@ -0,0 +1,10 @@ +import type { RESTPutAPIApplicationCommandsJSONBody } from 'discord-api-types/v10' + +const deleteSuggestionCommand: RESTPutAPIApplicationCommandsJSONBody[number] = { + name: 'deletesuggestion', + description: 'Delete your last suggestion', + default_member_permissions: '0', + dm_permission: false +} + +export default deleteSuggestionCommand diff --git a/src/interactions/data/application-commands/slash-commands/main/get-shout.ts b/src/interactions/data/application-commands/slash-commands/main/get-shout.ts new file mode 100644 index 00000000..60bead3c --- /dev/null +++ b/src/interactions/data/application-commands/slash-commands/main/get-shout.ts @@ -0,0 +1,10 @@ +import type { RESTPutAPIApplicationCommandsJSONBody } from 'discord-api-types/v10' + +const getShoutCommand: RESTPutAPIApplicationCommandsJSONBody[number] = { + name: 'getshout', + description: 'Get the group\'s shout', + default_member_permissions: '0', + dm_permission: false +} + +export default getShoutCommand diff --git a/src/interactions/data/application-commands/slash-commands/main/index.ts b/src/interactions/data/application-commands/slash-commands/main/index.ts new file mode 100644 index 00000000..e87605d9 --- /dev/null +++ b/src/interactions/data/application-commands/slash-commands/main/index.ts @@ -0,0 +1,8 @@ +export { default as boostInfoCommand } from './boost-info' +export { default as deleteSuggestionCommand } from './delete-suggestion' +export { default as getShoutCommand } from './get-shout' +export { default as memberCountCommand } from './member-count' +export { default as pollCommand } from './poll' +export { default as suggestCommand } from './suggest' +export { default as tagCommand } from './tag' +export { default as whoIsCommand } from './who-is' diff --git a/src/interactions/data/application-commands/slash-commands/main/member-count.ts b/src/interactions/data/application-commands/slash-commands/main/member-count.ts new file mode 100644 index 00000000..c9e86f95 --- /dev/null +++ b/src/interactions/data/application-commands/slash-commands/main/member-count.ts @@ -0,0 +1,15 @@ +import { ApplicationCommandOptionType, type RESTPutAPIApplicationCommandsJSONBody } from 'discord-api-types/v10' + +const memberCountCommand: RESTPutAPIApplicationCommandsJSONBody[number] = { + name: 'membercount', + description: 'Fetch a group\'s member count', + default_member_permissions: '0', + dm_permission: false, + options: [{ + name: 'id', + description: 'The ID of the group to fetch the member count of', + type: ApplicationCommandOptionType.Number + }] +} + +export default memberCountCommand diff --git a/src/interactions/data/application-commands/slash-commands/main/poll.ts b/src/interactions/data/application-commands/slash-commands/main/poll.ts new file mode 100644 index 00000000..57b04788 --- /dev/null +++ b/src/interactions/data/application-commands/slash-commands/main/poll.ts @@ -0,0 +1,16 @@ +import { ApplicationCommandOptionType, type RESTPutAPIApplicationCommandsJSONBody } from 'discord-api-types/v10' + +const pollCommand: RESTPutAPIApplicationCommandsJSONBody[number] = { + name: 'poll', + description: 'Create a poll', + default_member_permissions: '0', + dm_permission: false, + options: [{ + name: 'poll', + description: 'The question to poll', + type: ApplicationCommandOptionType.String, + required: true + }] +} + +export default pollCommand diff --git a/src/interactions/data/application-commands/slash-commands/main/suggest.ts b/src/interactions/data/application-commands/slash-commands/main/suggest.ts new file mode 100644 index 00000000..c94773dc --- /dev/null +++ b/src/interactions/data/application-commands/slash-commands/main/suggest.ts @@ -0,0 +1,20 @@ +import { ApplicationCommandOptionType, type RESTPutAPIApplicationCommandsJSONBody } from 'discord-api-types/v10' + +const suggestCommand: RESTPutAPIApplicationCommandsJSONBody[number] = { + name: 'suggest', + description: 'Make a suggestion', + default_member_permissions: '0', + dm_permission: false, + options: [{ + name: 'suggestion', + description: 'The suggestion to make', + type: ApplicationCommandOptionType.String, + required: true + }, { + name: 'attachment', + description: 'Image to attach to the suggestion', + type: ApplicationCommandOptionType.Attachment + }] +} + +export default suggestCommand diff --git a/src/interactions/data/application-commands/slash-commands/main/tag.ts b/src/interactions/data/application-commands/slash-commands/main/tag.ts new file mode 100644 index 00000000..a006c58e --- /dev/null +++ b/src/interactions/data/application-commands/slash-commands/main/tag.ts @@ -0,0 +1,19 @@ +import { ApplicationCommandOptionType, type RESTPutAPIApplicationCommandsJSONBody } from 'discord-api-types/v10' + +const tagCommand: RESTPutAPIApplicationCommandsJSONBody[number] = { + name: 'tag', + description: 'Post a tag', + default_member_permissions: '0', + dm_permission: false, + options: [{ + name: 'query', + description: 'The tag to post', + type: ApplicationCommandOptionType.String + }, { + name: 'who', + description: 'The member to post this tag for', + type: ApplicationCommandOptionType.User + }] +} + +export default tagCommand diff --git a/src/interactions/data/application-commands/slash-commands/main/who-is.ts b/src/interactions/data/application-commands/slash-commands/main/who-is.ts new file mode 100644 index 00000000..e3327bff --- /dev/null +++ b/src/interactions/data/application-commands/slash-commands/main/who-is.ts @@ -0,0 +1,15 @@ +import { ApplicationCommandOptionType, type RESTPutAPIApplicationCommandsJSONBody } from 'discord-api-types/v10' + +const whoIsCommand: RESTPutAPIApplicationCommandsJSONBody[number] = { + name: 'whois', + description: 'Get the Roblox information of a user', + default_member_permissions: '0', + dm_permission: false, + options: [{ + name: 'username', + description: 'The username of the user to get the information of', + type: ApplicationCommandOptionType.String + }] +} + +export default whoIsCommand diff --git a/src/interactions/data/application-commands/slash-commands/settings/channel-links.ts b/src/interactions/data/application-commands/slash-commands/settings/channel-links.ts new file mode 100644 index 00000000..9edfff2a --- /dev/null +++ b/src/interactions/data/application-commands/slash-commands/settings/channel-links.ts @@ -0,0 +1,60 @@ +import { + ApplicationCommandOptionType, + ChannelType, + type RESTPutAPIApplicationCommandsJSONBody +} from 'discord-api-types/v10' + +const channelLinksCommand: RESTPutAPIApplicationCommandsJSONBody[number] = { + name: 'channellinks', + description: 'Link or unlink a text channel from a voice channel', + default_member_permissions: '0', + dm_permission: false, + options: [{ + name: 'link', + description: 'Link a voice channel to a text channel', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'fromchannel', + description: 'The voice channel to link a text channel to', + type: ApplicationCommandOptionType.Channel, + channel_types: [ChannelType.GuildVoice], + required: true + }, { + name: 'tochannel', + description: 'The text channel to link to this voice channel', + type: ApplicationCommandOptionType.Channel, + channel_types: [ChannelType.GuildText], + required: true + }] + }, { + name: 'unlink', + description: 'Unlink a text channel from a voice channel', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'fromchannel', + description: 'The voice channel to unlink a text channel from', + type: ApplicationCommandOptionType.Channel, + channel_types: [ChannelType.GuildVoice], + required: true + }, { + name: 'tochannel', + description: 'The text channel to unlink from this voice channel', + type: ApplicationCommandOptionType.Channel, + channel_types: [ChannelType.GuildText], + required: true + }] + }, { + name: 'list', + description: 'List a voice channels links', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'channel', + description: 'The voice channel to list the links of', + type: ApplicationCommandOptionType.Channel, + channel_types: [ChannelType.GuildVoice], + required: true + }] + }] +} + +export default channelLinksCommand diff --git a/src/interactions/data/application-commands/slash-commands/settings/close-ticket.ts b/src/interactions/data/application-commands/slash-commands/settings/close-ticket.ts new file mode 100644 index 00000000..4998865f --- /dev/null +++ b/src/interactions/data/application-commands/slash-commands/settings/close-ticket.ts @@ -0,0 +1,10 @@ +import type { RESTPutAPIApplicationCommandsJSONBody } from 'discord-api-types/v10' + +const closeTicketCommand: RESTPutAPIApplicationCommandsJSONBody[number] = { + name: 'closeticket', + description: 'Close this ticket', + default_member_permissions: '0', + dm_permission: false +} + +export default closeTicketCommand diff --git a/src/interactions/data/application-commands/slash-commands/settings/groups.ts b/src/interactions/data/application-commands/slash-commands/settings/groups.ts new file mode 100644 index 00000000..78ce2ef9 --- /dev/null +++ b/src/interactions/data/application-commands/slash-commands/settings/groups.ts @@ -0,0 +1,125 @@ +import { + ApplicationCommandOptionType, + ChannelType, + type RESTPutAPIApplicationCommandsJSONBody +} from 'discord-api-types/v10' + +const groupsCommand: RESTPutAPIApplicationCommandsJSONBody[number] = { + name: 'groups', + description: 'Create, edit, delete or list a group', + default_member_permissions: '0', + dm_permission: false, + options: [{ + name: 'create', + description: 'Create a group', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'name', + description: 'The name for the new group', + type: ApplicationCommandOptionType.String, + required: true + }, { + name: 'type', + description: 'The type for the new group', + type: ApplicationCommandOptionType.String, + required: true, + choices: [ + { name: 'channel', value: 'channel' }, + { name: 'role', value: 'role' } + ] + }] + }, { + name: 'delete', + description: 'Delete a group', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'id', + description: 'The ID of the group to delete', + type: ApplicationCommandOptionType.String, + required: true + }] + }, { + name: 'list', + description: 'List a specific group or all groups', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'id', + description: 'The ID of the group to list', + type: ApplicationCommandOptionType.String + }] + }, { + name: 'channels', + description: 'Add or remove a channel from a group', + type: ApplicationCommandOptionType.SubcommandGroup, + options: [{ + name: 'add', + description: 'Add a channel to a group', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'id', + description: 'The ID of the group to add a channel to', + type: ApplicationCommandOptionType.String, + required: true + }, { + name: 'channel', + description: 'The channel to add to this group', + type: ApplicationCommandOptionType.Channel, + channel_types: [ChannelType.GuildText], + required: true + }] + }, { + name: 'remove', + description: 'Remove a channel from a group', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'id', + description: 'The ID of the group to remove a channel from', + type: ApplicationCommandOptionType.String, + required: true + }, { + name: 'channel', + description: 'The channel to remove from this group', + type: ApplicationCommandOptionType.Channel, + channel_types: [ChannelType.GuildText], + required: true + }] + }] + }, { + name: 'roles', + description: 'Add or remove a role from a group', + type: ApplicationCommandOptionType.SubcommandGroup, + options: [{ + name: 'add', + description: 'Add a role to a group', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'id', + description: 'The ID of the group to add a role to', + type: ApplicationCommandOptionType.String, + required: true + }, { + name: 'role', + description: 'The role to add to this group', + type: ApplicationCommandOptionType.Role, + required: true + }] + }, { + name: 'remove', + description: 'Remove a role from a group', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'id', + description: 'The ID of the group to remove a role from', + type: ApplicationCommandOptionType.String, + required: true + }, { + name: 'role', + description: 'The role to remove from this group', + type: ApplicationCommandOptionType.Channel, + required: true + }] + }] + }] +} + +export default groupsCommand diff --git a/src/interactions/data/application-commands/slash-commands/settings/index.ts b/src/interactions/data/application-commands/slash-commands/settings/index.ts new file mode 100644 index 00000000..87ad4101 --- /dev/null +++ b/src/interactions/data/application-commands/slash-commands/settings/index.ts @@ -0,0 +1,11 @@ +export { default as channelLinksCommand } from './channel-links' +export { default as closeTicketCommand } from './close-ticket' +export { default as groupsCommand } from './groups' +export { default as panelsCommand } from './panels' +export { default as roleBindingsCommand } from './role-bindings' +export { default as roleMessagesCommand } from './role-messages' +export { default as setActivityCommand } from './set-activity' +export { default as settingsCommand } from './settings' +export { default as tagsCommand } from './tags' +export { default as ticketTypesCommand } from './ticket-types' +export { default as toggleSupportCommand } from './toggle-support' diff --git a/src/interactions/data/application-commands/slash-commands/settings/panels.ts b/src/interactions/data/application-commands/slash-commands/settings/panels.ts new file mode 100644 index 00000000..1e23557b --- /dev/null +++ b/src/interactions/data/application-commands/slash-commands/settings/panels.ts @@ -0,0 +1,98 @@ +import { + ApplicationCommandOptionType, + ChannelType, + type RESTPutAPIApplicationCommandsJSONBody +} from 'discord-api-types/v10' + +const panelsCommand: RESTPutAPIApplicationCommandsJSONBody[number] = { + name: 'panels', + description: 'Create, edit, post, list or delete a panel', + default_member_permissions: '0', + dm_permission: false, + options: [{ + name: 'create', + description: 'Create a panel', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'name', + description: 'The name for the new panel', + type: ApplicationCommandOptionType.String, + required: true + }, { + name: 'content', + description: 'The content for the new panel', + type: ApplicationCommandOptionType.String, + required: true + }] + }, { + name: 'delete', + description: 'Delete a panel', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'id', + description: 'The ID of the panel to delete', + type: ApplicationCommandOptionType.Integer, + required: true + }] + }, { + name: 'edit', + description: 'Edit a panel', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'id', + description: 'The ID of the panel to edit', + type: ApplicationCommandOptionType.Integer, + required: true + }, { + name: 'key', + description: 'The key of the panel to edit', + type: ApplicationCommandOptionType.String, + required: true, + choices: [ + { name: 'content', value: 'content' }, + { name: 'message', value: 'message' } + ] + }, { + name: 'value', + description: 'The value to change this key to', + type: ApplicationCommandOptionType.String, + required: true + }] + }, { + name: 'post', + description: 'Post a panel', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'id', + description: 'The ID of the panel to post', + type: ApplicationCommandOptionType.Integer, + required: true + }, { + name: 'channel', + description: 'The channel to post the panel in', + type: ApplicationCommandOptionType.Channel, + channel_types: [ChannelType.GuildText] + }] + }, { + name: 'list', + description: 'List a specific panel or all panels', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'id', + description: 'The ID of the panel to list', + type: ApplicationCommandOptionType.Integer + }] + }, { + name: 'raw', + description: 'Get the raw content of a panel', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'id', + description: 'The ID of the panel to get the raw content of', + type: ApplicationCommandOptionType.Integer, + required: true + }] + }] +} + +export default panelsCommand diff --git a/src/interactions/data/application-commands/slash-commands/settings/role-bindings.ts b/src/interactions/data/application-commands/slash-commands/settings/role-bindings.ts new file mode 100644 index 00000000..d30ed50b --- /dev/null +++ b/src/interactions/data/application-commands/slash-commands/settings/role-bindings.ts @@ -0,0 +1,49 @@ +import { ApplicationCommandOptionType, type RESTPutAPIApplicationCommandsJSONBody } from 'discord-api-types/v10' + +const roleBindingsCommand: RESTPutAPIApplicationCommandsJSONBody[number] = { + name: 'rolebindings', + description: 'Create, delete or list a role binding', + default_member_permissions: '0', + dm_permission: false, + options: [{ + name: 'create', + description: 'Create a role binding', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'role', + description: 'The role for the new role binding', + type: ApplicationCommandOptionType.Role, + required: true + }, { + name: 'min', + description: 'The (minimum) rank for the new role binding', + type: ApplicationCommandOptionType.Integer, + required: true + }, { + name: 'max', + description: 'The maximum rank for the new role binding', + type: ApplicationCommandOptionType.Integer + }] + }, { + name: 'delete', + description: 'Delete a role binding', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'id', + description: 'The ID of the role binding to delete', + type: ApplicationCommandOptionType.String, + required: true + }] + }, { + name: 'list', + description: 'List a specific role binding or all role bindings', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'id', + description: 'The ID of the role binding to list', + type: ApplicationCommandOptionType.String + }] + }] +} + +export default roleBindingsCommand diff --git a/src/interactions/data/application-commands/slash-commands/settings/role-messages.ts b/src/interactions/data/application-commands/slash-commands/settings/role-messages.ts new file mode 100644 index 00000000..eb558ae9 --- /dev/null +++ b/src/interactions/data/application-commands/slash-commands/settings/role-messages.ts @@ -0,0 +1,50 @@ +import { ApplicationCommandOptionType, type RESTPutAPIApplicationCommandsJSONBody } from 'discord-api-types/v10' + +const roleMessagesCommand: RESTPutAPIApplicationCommandsJSONBody[number] = { + name: 'rolemessages', + description: 'Create, delete or list a role message', + default_member_permissions: '0', + dm_permission: false, + options: [{ + name: 'create', + description: 'Create a role message', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'role', + description: 'The role for the new role message', + type: ApplicationCommandOptionType.Role, + required: true + }, { + name: 'message', + description: 'The message for the new role message', + type: ApplicationCommandOptionType.String, + required: true + }, { + name: 'emoji', + description: 'The emoji for the new role message', + type: ApplicationCommandOptionType.String, + required: true + }] + }, { + name: 'delete', + description: 'Delete a role message', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'id', + description: 'The ID of the role message to delete', + type: ApplicationCommandOptionType.String, + required: true + }] + }, { + name: 'list', + description: 'List a specfic role message or all role messages', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'id', + description: 'The ID of the role message to list', + type: ApplicationCommandOptionType.String + }] + }] +} + +export default roleMessagesCommand diff --git a/src/interactions/data/application-commands/slash-commands/settings/set-activity.ts b/src/interactions/data/application-commands/slash-commands/settings/set-activity.ts new file mode 100644 index 00000000..64cf094c --- /dev/null +++ b/src/interactions/data/application-commands/slash-commands/settings/set-activity.ts @@ -0,0 +1,32 @@ +import { + ActivityType, + ApplicationCommandOptionType, + type RESTPutAPIApplicationCommandsJSONBody +} from 'discord-api-types/v10' +import { util } from '../../../../../utils' + +const { getEnumKeys } = util + +const choices = getEnumKeys(ActivityType) + .filter(type => type !== 'CUSTOM_STATUS') + .map(type => type.toLowerCase()) + .map(type => ({ name: type, value: type })) + +const setActivityCommand: RESTPutAPIApplicationCommandsJSONBody[number] = { + name: 'setactivity', + description: 'Set the bot\'s activity', + default_member_permissions: '0', + dm_permission: false, + options: [{ + name: 'name', + description: 'The name for the new activity', + type: ApplicationCommandOptionType.String + }, { + name: 'type', + description: 'The type for the new activity', + type: ApplicationCommandOptionType.String, + choices + }] +} + +export default setActivityCommand diff --git a/src/interactions/data/application-commands/slash-commands/settings/settings.ts b/src/interactions/data/application-commands/slash-commands/settings/settings.ts new file mode 100644 index 00000000..81f8028c --- /dev/null +++ b/src/interactions/data/application-commands/slash-commands/settings/settings.ts @@ -0,0 +1,47 @@ +import { ApplicationCommandOptionType, type RESTPutAPIApplicationCommandsJSONBody } from 'discord-api-types/v10' +import { argumentUtil, constants, util } from '../../../../../utils' + +const { GuildSetting } = constants +const { guildSettingTransformer } = argumentUtil +const { getEnumKeys } = util + +const choices = getEnumKeys(GuildSetting) + .map(guildSettingTransformer) + .map(attribute => attribute.toLowerCase()) + .map(attribute => ({ name: attribute, value: attribute })) + +const settingsCommand: RESTPutAPIApplicationCommandsJSONBody[number] = { + name: 'settings', + description: 'Get or set a guild setting', + default_member_permissions: '0', + dm_permission: false, + options: [{ + name: 'get', + description: 'Get a guild setting', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'setting', + description: 'The setting to get', + type: ApplicationCommandOptionType.String, + required: true, + choices + }] + }, { + name: 'set', + description: 'Set a guild setting', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'setting', + description: 'The setting to set', + type: ApplicationCommandOptionType.String, + required: true, + choices + }, { + name: 'value', + description: 'The value to change this setting to', + type: ApplicationCommandOptionType.String + }] + }] +} + +export default settingsCommand diff --git a/src/interactions/data/application-commands/slash-commands/settings/tags.ts b/src/interactions/data/application-commands/slash-commands/settings/tags.ts new file mode 100644 index 00000000..d26ee8db --- /dev/null +++ b/src/interactions/data/application-commands/slash-commands/settings/tags.ts @@ -0,0 +1,97 @@ +import { ApplicationCommandOptionType, type RESTPutAPIApplicationCommandsJSONBody } from 'discord-api-types/v10' + +const tagsCommand: RESTPutAPIApplicationCommandsJSONBody[number] = { + name: 'tags', + description: 'Create, edit or delete a tag', + default_member_permissions: '0', + dm_permission: false, + options: [{ + name: 'create', + description: 'Create a tag', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'name', + description: 'The name for the new tag', + type: ApplicationCommandOptionType.String, + required: true + }, { + name: 'content', + description: 'The content for the new tag', + type: ApplicationCommandOptionType.String, + required: true + }] + }, { + name: 'delete', + description: 'Delete a tag', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'id', + description: 'The ID of the tag to delete', + type: ApplicationCommandOptionType.String, + required: true + }] + }, { + name: 'edit', + description: 'Edit a tag', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'id', + description: 'The ID of the tag to edit', + type: ApplicationCommandOptionType.String, + required: true + }, { + name: 'key', + description: 'The key of the tag to edit', + type: ApplicationCommandOptionType.String, + required: true, + choices: [{ name: 'content', value: 'content' }] + }, { + name: 'value', + description: 'The value to change this key to', + type: ApplicationCommandOptionType.String, + required: true + }] + }, { + name: 'aliases', + description: 'Create or delete an alias from a tag', + type: ApplicationCommandOptionType.SubcommandGroup, + options: [{ + name: 'create', + description: 'Create an alias for a tag', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'id', + description: 'The ID of the tag to create an alias for', + type: ApplicationCommandOptionType.String, + required: true + }, { + name: 'name', + description: 'The name for the new alias', + type: ApplicationCommandOptionType.String, + required: true + }] + }, { + name: 'delete', + description: 'Delete an alias from a tag', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'name', + description: 'The name of the alias to delete', + type: ApplicationCommandOptionType.String, + required: true + }] + }] + }, { + name: 'raw', + description: 'Get the raw content of a tag', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'id', + description: 'The ID of the tag to get the raw content of', + type: ApplicationCommandOptionType.String, + required: true + }] + }] +} + +export default tagsCommand diff --git a/src/interactions/data/application-commands/slash-commands/settings/ticket-types.ts b/src/interactions/data/application-commands/slash-commands/settings/ticket-types.ts new file mode 100644 index 00000000..803cc42e --- /dev/null +++ b/src/interactions/data/application-commands/slash-commands/settings/ticket-types.ts @@ -0,0 +1,70 @@ +import { ApplicationCommandOptionType, type RESTPutAPIApplicationCommandsJSONBody } from 'discord-api-types/v10' + +const ticketTypesCommand: RESTPutAPIApplicationCommandsJSONBody[number] = { + name: 'tickettypes', + description: 'Create, link, delete or list a ticket type', + default_member_permissions: '0', + dm_permission: false, + options: [{ + name: 'create', + description: 'Create a ticket type', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'name', + description: 'The name for the new ticket type', + type: ApplicationCommandOptionType.String, + required: true + }] + }, { + name: 'delete', + description: 'Delete a ticket type', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'id', + description: 'The ID of the ticket type to delete', + type: ApplicationCommandOptionType.String, + required: true + }] + }, { + name: 'link', + description: 'Link a ticket type', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'id', + description: 'The ID of the ticket type to link', + type: ApplicationCommandOptionType.String, + required: true + }, { + name: 'emoji', + description: 'The emoji to link to this ticket type', + type: ApplicationCommandOptionType.String, + required: true + }, { + name: 'message', + description: 'The message to link this ticket type on', + type: ApplicationCommandOptionType.String, + required: true + }] + }, { + name: 'unlink', + description: 'Unlink a ticket type', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'id', + description: 'The ID of the ticket type to unlink', + type: ApplicationCommandOptionType.String, + required: true + }] + }, { + name: 'list', + description: 'List a specific ticket type or all ticket types', + type: ApplicationCommandOptionType.Subcommand, + options: [{ + name: 'id', + description: 'The ID of the ticket type to list', + type: ApplicationCommandOptionType.String + }] + }] +} + +export default ticketTypesCommand diff --git a/src/interactions/data/application-commands/slash-commands/settings/toggle-support.ts b/src/interactions/data/application-commands/slash-commands/settings/toggle-support.ts new file mode 100644 index 00000000..a784748a --- /dev/null +++ b/src/interactions/data/application-commands/slash-commands/settings/toggle-support.ts @@ -0,0 +1,10 @@ +import type { RESTPutAPIApplicationCommandsJSONBody } from 'discord-api-types/v10' + +const toggleSupportCommand: RESTPutAPIApplicationCommandsJSONBody[number] = { + name: 'togglesupport', + description: 'Enable or disable the Tickets System', + default_member_permissions: '0', + dm_permission: false +} + +export default toggleSupportCommand diff --git a/src/interactions/data/index.ts b/src/interactions/data/index.ts new file mode 100644 index 00000000..d85a01fe --- /dev/null +++ b/src/interactions/data/index.ts @@ -0,0 +1 @@ +export * as applicationCommands from './application-commands' diff --git a/src/interactions/index.ts b/src/interactions/index.ts new file mode 100644 index 00000000..89de9feb --- /dev/null +++ b/src/interactions/index.ts @@ -0,0 +1,2 @@ +export * as applicationCommands from './application-commands' +export * as data from './data' diff --git a/src/jobs/announce-trainings.ts b/src/jobs/announce-trainings.ts index 7a81ccb1..c7290402 100644 --- a/src/jobs/announce-trainings.ts +++ b/src/jobs/announce-trainings.ts @@ -1,39 +1,40 @@ -import { type Guild, MessageEmbed } from 'discord.js' import { groupService, userService } from '../services' import type BaseJob from './base' import type { GetUsers } from '../services/user' +import type { GuildContext } from '../structures' +import { MessageEmbed } from 'discord.js' import type { Training } from '../services/group' import { applicationAdapter } from '../adapters' import applicationConfig from '../configs/application' import { injectable } from 'inversify' import lodash from 'lodash' import pluralize from 'pluralize' -import { timeUtil } from '../util' +import { timeUtil } from '../utils' const { getDate, getTime, getTimeZoneAbbreviation } = timeUtil @injectable() export default class AnnounceTrainingsJob implements BaseJob { - public async run (guild: Guild): Promise { - if (guild.robloxGroupId === null) { + public async run (context: GuildContext): Promise { + if (context.robloxGroupId === null) { return } - const trainingsInfoPanel = guild.panels.resolve('trainingsInfoPanel') - const trainingsPanel = guild.panels.resolve('trainingsPanel') + const trainingsInfoPanel = context.panels.resolve('trainingsInfoPanel') + const trainingsPanel = context.panels.resolve('trainingsPanel') if (trainingsInfoPanel?.message == null && trainingsPanel?.message == null) { return } const trainings: Training[] = (await applicationAdapter( 'GET', - `v1/groups/${guild.robloxGroupId}/trainings?sort=date` + `v1/groups/${context.robloxGroupId}/trainings?sort=date` )).data const authorIds = [...new Set(trainings.map(training => training.authorId))] const authors = await userService.getUsers(authorIds) // Trainings Info Panel if (trainingsInfoPanel?.message != null) { - const embed = trainingsInfoPanel.embed.setColor(guild.primaryColor ?? applicationConfig.defaultColor) + const embed = trainingsInfoPanel.embed.setColor(context.primaryColor ?? applicationConfig.defaultColor) const now = new Date() if (embed.description !== null) { @@ -48,16 +49,16 @@ export default class AnnounceTrainingsJob implements BaseJob { : ':x: There are currently no scheduled trainings.' ) - await trainingsInfoPanel.message.edit(embed) + await trainingsInfoPanel.message.edit({ embeds: [embed] }) } // Trainings Panel if (trainingsPanel?.message != null) { - const embed = await getTrainingsEmbed(guild.robloxGroupId, trainings, authors) + const embed = await getTrainingsEmbed(context.robloxGroupId, trainings, authors) - embed.setColor(guild.primaryColor ?? applicationConfig.defaultColor) + embed.setColor(context.primaryColor ?? applicationConfig.defaultColor) - await trainingsPanel.message.edit(embed) + await trainingsPanel.message.edit({ embeds: [embed] }) } } } @@ -73,7 +74,7 @@ async function getTrainingsEmbed (groupId: number, trainings: Training[], author const types = Object.keys(groupedTrainings) const embed = new MessageEmbed() - .setFooter('Updated at') + .setFooter({ text: 'Updated at' }) .setTimestamp() for (let i = 0; i < types.length; i++) { diff --git a/src/jobs/base.ts b/src/jobs/base.ts index 5e440d85..c96488fc 100644 --- a/src/jobs/base.ts +++ b/src/jobs/base.ts @@ -1,3 +1,3 @@ export default interface BaseJob { - run: (...args: any[]) => void | Promise + run (...args: any[]): void | Promise } diff --git a/src/jobs/premium-members-report.ts b/src/jobs/premium-members-report.ts index fceb2851..4098921e 100644 --- a/src/jobs/premium-members-report.ts +++ b/src/jobs/premium-members-report.ts @@ -1,8 +1,9 @@ -import { type Guild, type GuildMember, MessageEmbed } from 'discord.js' -import type BaseJob from './base' +import { type GuildMember, MessageEmbed } from 'discord.js' +import type { BaseJob } from '.' +import type { GuildContext } from '../structures' import { injectable } from 'inversify' import pluralize from 'pluralize' -import { timeUtil } from '../util' +import { timeUtil } from '../utils' interface PremiumGuildMember extends GuildMember { premiumSince: Date @@ -12,14 +13,16 @@ const { diffDays } = timeUtil @injectable() export default class PremiumMembersReportJob implements BaseJob { - public async run (guild: Guild): Promise { - const serverBoosterReportChannelsGroup = guild.groups.resolve('serverBoosterReportChannels') - if (serverBoosterReportChannelsGroup === null || !serverBoosterReportChannelsGroup.isChannelGroup() || - serverBoosterReportChannelsGroup.channels.cache.size === 0) { + public async run (context: GuildContext): Promise { + const serverBoosterReportChannelsGroup = context.groups.resolve('serverBoosterReportChannels') + if ( + serverBoosterReportChannelsGroup === null || !serverBoosterReportChannelsGroup.isChannelGroup() || + serverBoosterReportChannelsGroup.channels.cache.size === 0 + ) { return } - const members = await guild.members.fetch() + const members = await context.guild.members.fetch() const premiumMembers: PremiumGuildMember[] = [] for (const member of members.values()) { if (member.premiumSince !== null) { @@ -44,13 +47,15 @@ export default class PremiumMembersReportJob implements BaseJob { const embed = new MessageEmbed() .setTitle('Server Booster Report') .setColor(0xff73fa) - const emoji = guild.emojis.cache.find(emoji => emoji.name.toLowerCase() === 'boost') + const emoji = context.guild.emojis.cache.find(emoji => emoji.name?.toLowerCase() === 'boost') for (const { member, months } of monthlyPremiumMembers) { embed.addField(`${member.user.tag} ${emoji?.toString() ?? ''}`, `Has been boosting this server for **${pluralize('month', months, true)}**!`) } - await Promise.all(serverBoosterReportChannelsGroup.channels.cache.map(async channel => await channel.send(embed))) + await Promise.all(serverBoosterReportChannelsGroup.channels.cache.map(async channel => ( + await channel.send({ embeds: [embed] })) + )) } } } diff --git a/src/loaders/index.ts b/src/loaders/index.ts index 82de08de..c1f53740 100644 --- a/src/loaders/index.ts +++ b/src/loaders/index.ts @@ -1,8 +1,8 @@ import * as Sentry from '@sentry/node' -import { AroraClient } from '../client' +import type { AroraClient } from '../client' import type { BaseJob } from '../jobs' import { RewriteFrames } from '@sentry/integrations' -import { constants } from '../util' +import { constants } from '../utils' import container from '../configs/container' import { createConnection } from 'typeorm' import cron from 'node-cron' @@ -31,11 +31,12 @@ export async function init (): Promise { const healthCheckJob = jobFactory(healthCheckJobConfig.name) cron.schedule( healthCheckJobConfig.expression, - // eslint-disable-next-line @typescript-eslint/no-misused-promises - healthCheckJob.run.bind(healthCheckJob.run, 'main') + () => { + Promise.resolve(healthCheckJob.run('main')).catch(console.error) + } ) - const client = new AroraClient({ commandEditableDuration: 0 }) + const client = container.get(TYPES.Client) await client.login(process.env.DISCORD_TOKEN) return client diff --git a/src/managers/base.ts b/src/managers/base.ts index 422b89a1..e84bdaa0 100644 --- a/src/managers/base.ts +++ b/src/managers/base.ts @@ -1,24 +1,79 @@ -import { BaseManager as DiscordBaseManager } from 'discord.js' -import type { IdentifiableStructure } from '../types/base' +import { type Constructor, type Tail, constants } from '../utils' +import { inject, injectable, type interfaces } from 'inversify' +import { Collection } from 'discord.js' +import type { IdentifiableEntity } from '../entities' +import type { IdentifiableStructure } from '../structures' -export default class BaseManager extends DiscordBaseManager { - public override resolve (resolvable: R): Holds | null { +const { TYPES } = constants + +@injectable() +export default abstract class BaseManager { + public abstract readonly cache: Collection + + public readonly holds: Constructor + + public constructor (holds: Constructor) { + this.holds = holds + } + + public setOptions? (...args: any[]): void + + public resolve (resolvable: Holds): Holds + public resolve (resolvable: R): Holds | null + public resolve (resolvable: Holds | R): Holds | null { if (resolvable instanceof this.holds) { return resolvable } - if (typeof resolvable === 'number') { - return this.cache.get(resolvable) ?? null + const other = this.cache.first() + if (typeof other !== 'undefined') { + if (typeof resolvable === typeof other.id) { + return this.cache.get(resolvable as unknown as K) ?? null + } } return null } - public override resolveID (resolvable: R): number | null { + public resolveId (resolvable: K | Holds): K + public resolveId (resolvable: R): K | null + public resolveId (resolvable: K | Holds | R): K | null { if (resolvable instanceof this.holds) { return resolvable.id } - if (typeof resolvable === 'number') { - return resolvable + const other = this.cache.first() + if (typeof other !== 'undefined') { + if (typeof resolvable === typeof other.id) { + return resolvable as K + } } return null } } + +export class DataManager< + K extends number | string, + Holds extends IdentifiableStructure, + R, + U extends IdentifiableEntity +> extends BaseManager { + @inject(TYPES.StructureFactory) + private readonly structureFactory!: interfaces.MultiFactory> + + public readonly cache: Collection = new Collection() + + public add ( + data: Parameters[0], + options?: { id?: K, extras: Tail> } + ): Holds { + const existing = this.cache.get(options?.id ?? data.id as K) + if (typeof existing !== 'undefined') { + existing.setup(data) + return existing + } + + const entry = this.structureFactory(this.holds.name)( + ...[data, ...(options?.extras ?? [])] as unknown as Parameters + ) + this.cache.set(options?.id ?? entry.id, entry) + return entry + } +} diff --git a/src/managers/group-role.ts b/src/managers/group-role.ts index aadcf348..8cf8be5c 100644 --- a/src/managers/group-role.ts +++ b/src/managers/group-role.ts @@ -1,32 +1,32 @@ -import type { Collection, Guild, Role, RoleResolvable, Snowflake } from 'discord.js' +import type { Collection, Role, RoleResolvable, Snowflake } from 'discord.js' import type { Group as GroupEntity, Role as RoleEntity } from '../entities' -import { Repository } from 'typeorm' -import type { RoleGroup } from '../structures' -import { constants } from '../util' -import container from '../configs/container' -import getDecorators from 'inversify-inject-decorators' +import type { GuildContext, RoleGroup } from '../structures' +import { inject, injectable } from 'inversify' +import BaseManager from './base' +import type { Repository } from 'typeorm' +import { constants } from '../utils' const { TYPES } = constants -const { lazyInject } = getDecorators(container) -export default class GroupRoleManager { - @lazyInject(TYPES.GroupRepository) +@injectable() +export default class GroupRoleManager extends BaseManager { + @inject(TYPES.GroupRepository) private readonly groupRepository!: Repository - public readonly group: RoleGroup - public readonly guild: Guild + public group!: RoleGroup + public context!: GuildContext - public constructor (group: RoleGroup) { + public override setOptions (group: RoleGroup): void { this.group = group - this.guild = group.guild + this.context = group.context } public get cache (): Collection { - return this.guild.roles.cache.filter(role => this.group._roles.includes(role.id)) + return this.context.guild.roles.cache.filter(role => this.group._roles.includes(role.id)) } public async add (roleResolvable: RoleResolvable): Promise { - const role = this.guild.roles.resolve(roleResolvable) + const role = this.context.guild.roles.resolve(roleResolvable) if (role === null) { throw new Error('Invalid role.') } @@ -38,7 +38,7 @@ export default class GroupRoleManager { this.group.id, { relations: ['channels', 'roles'] } ) as GroupEntity & { roles: RoleEntity[] } - group.roles.push({ id: role.id, guildId: this.guild.id }) + group.roles.push({ id: role.id, guildId: this.context.id }) await this.groupRepository.save(group) this.group.setup(group) @@ -46,7 +46,7 @@ export default class GroupRoleManager { } public async remove (role: RoleResolvable): Promise { - const id = this.guild.roles.resolveID(role) + const id = this.context.guild.roles.resolveId(role) if (id === null) { throw new Error('Invalid role.') } diff --git a/src/managers/group-text-channel.ts b/src/managers/group-text-channel.ts index 14deab08..102ed258 100644 --- a/src/managers/group-text-channel.ts +++ b/src/managers/group-text-channel.ts @@ -1,34 +1,33 @@ import type { Channel as ChannelEntity, Group as GroupEntity } from '../entities' -import type { Collection, Guild, Snowflake, TextChannel } from 'discord.js' -import type { ChannelGroup } from '../structures' -import { Repository } from 'typeorm' -import type { TextChannelResolvable } from './guild-ticket' -import { constants } from '../util' -import container from '../configs/container' -import getDecorators from 'inversify-inject-decorators' +import type { ChannelGroup, GuildContext } from '../structures' +import type { Collection, Snowflake, TextChannel, TextChannelResolvable } from 'discord.js' +import { inject, injectable } from 'inversify' +import BaseManager from './base' +import type { Repository } from 'typeorm' +import { constants } from '../utils' const { TYPES } = constants -const { lazyInject } = getDecorators(container) -export default class GroupTextChannelManager { - @lazyInject(TYPES.GroupRepository) +@injectable() +export default class GroupTextChannelManager extends BaseManager { + @inject(TYPES.GroupRepository) private readonly groupRepository!: Repository - public readonly group: ChannelGroup - public readonly guild: Guild + public group!: ChannelGroup + public context!: GuildContext - public constructor (group: ChannelGroup) { + public override setOptions (group: ChannelGroup): void { this.group = group - this.guild = group.guild + this.context = group.context } public get cache (): Collection { - return this.guild.channels.cache.filter(channel => this.group._channels.includes(channel.id)) as + return this.context.guild.channels.cache.filter(channel => this.group._channels.includes(channel.id)) as Collection } public async add (channelResolvable: TextChannelResolvable): Promise { - const channel = this.guild.channels.resolve(channelResolvable) + const channel = this.context.guild.channels.resolve(channelResolvable) if (channel === null || !channel.isText()) { throw new Error('Invalid channel.') } @@ -40,7 +39,7 @@ export default class GroupTextChannelManager { this.group.id, { relations: ['channels', 'roles'] } ) as GroupEntity & { channels: ChannelEntity[] } - group.channels.push({ id: channel.id, guildId: this.guild.id }) + group.channels.push({ id: channel.id, guildId: this.context.id }) await this.groupRepository.save(group) this.group.setup(group) @@ -48,7 +47,7 @@ export default class GroupTextChannelManager { } public async remove (channel: TextChannelResolvable): Promise { - const id = this.guild.channels.resolveID(channel) + const id = this.context.guild.channels.resolveId(channel) if (id === null) { throw new Error('Invalid channel.') } diff --git a/src/managers/guild-context.ts b/src/managers/guild-context.ts new file mode 100644 index 00000000..0df03523 --- /dev/null +++ b/src/managers/guild-context.ts @@ -0,0 +1,134 @@ +import { CategoryChannel, Guild, TextChannel } from 'discord.js' +import { GuildContext, type GuildUpdateOptions } from '../structures' +import { inject, injectable } from 'inversify' +import { DataManager } from './base' +import type { Guild as GuildEntity } from '../entities' +import type { Repository } from 'typeorm' +import { constants } from '../utils' + +const { TYPES } = constants + +export type GuildContextResolvable = GuildContext | Guild | string + +@injectable() +export default class GuildContextManager extends DataManager< +string, +GuildContext, +GuildContextResolvable, +GuildEntity +> { + @inject(TYPES.GuildRepository) + private readonly guildRepository!: Repository + + public constructor () { + super(GuildContext) + } + + public async update ( + contextResolvable: GuildContextResolvable, + data: GuildUpdateOptions + ): Promise { + const context = this.resolve(contextResolvable) + if (context === null) { + throw new Error('Invalid guild context.') + } + if (!this.cache.has(context.id)) { + throw new Error('Guild context not found.') + } + + const changes: Partial = {} + if (typeof data.logsChannel !== 'undefined') { + let channel + if (data.logsChannel !== null) { + channel = context.guild.channels.resolve(data.logsChannel) + if (channel === null || !(channel instanceof TextChannel)) { + throw new Error('Invalid channel.') + } + } + changes.logsChannelId = channel?.id ?? null + } + if (typeof data.primaryColor !== 'undefined') { + changes.primaryColor = data.primaryColor + } + if (typeof data.ratingsChannel !== 'undefined') { + let channel + if (data.ratingsChannel !== null) { + channel = context.guild.channels.resolve(data.ratingsChannel) + if (channel === null || !(channel instanceof TextChannel)) { + throw new Error('Invalid channel.') + } + } + changes.ratingsChannelId = channel?.id ?? null + } + if (typeof data.robloxGroup !== 'undefined') { + changes.robloxGroupId = data.robloxGroup + } + if (typeof data.robloxUsernamesInNicknames !== 'undefined') { + changes.robloxUsernamesInNicknames = data.robloxUsernamesInNicknames + } + if (typeof data.suggestionsChannel !== 'undefined') { + let channel + if (data.suggestionsChannel !== null) { + channel = context.guild.channels.resolve(data.suggestionsChannel) + if (channel === null || !(channel instanceof TextChannel)) { + throw new Error('Invalid channel.') + } + } + changes.suggestionsChannelId = channel?.id ?? null + } + if (typeof data.supportEnabled !== 'undefined') { + changes.supportEnabled = data.supportEnabled + } + if (typeof data.ticketArchivesChannel !== 'undefined') { + let channel + if (data.ticketArchivesChannel !== null) { + channel = context.guild.channels.resolve(data.ticketArchivesChannel) + if (channel === null || !(channel instanceof TextChannel)) { + throw new Error('Invalid channel.') + } + } + changes.ticketArchivesChannelId = channel?.id ?? null + } + if (typeof data.ticketsCategory !== 'undefined') { + let channel + if (data.ticketsCategory !== null) { + channel = context.guild.channels.resolve(data.ticketsCategory) + if (channel === null || !(channel instanceof CategoryChannel)) { + throw new Error('Invalid channel.') + } + } + changes.ticketsCategoryId = channel?.id ?? null + } + if (typeof data.verificationPreference !== 'undefined') { + changes.verificationPreference = data.verificationPreference + } + + await this.guildRepository.save(this.guildRepository.create({ + ...changes, + id: context.id + })) + const newData = await this.guildRepository.findOne(context.id) as GuildEntity + + const _context = this.cache.get(context.id) + _context?.setup(newData) + return _context ?? this.add(newData) + } + + public override resolve (guildContext: GuildContext): GuildContext + public override resolve (guildContext: GuildContextResolvable): GuildContext | null + public override resolve (guildContext: GuildContextResolvable): GuildContext | null { + if (guildContext instanceof Guild) { + return this.cache.find(otherGuildContext => otherGuildContext.id === guildContext.id) ?? null + } + return super.resolve(guildContext) + } + + public override resolveId (guildContext: string): string + public override resolveId (guildContext: GuildContextResolvable): string | null + public override resolveId (guildContext: GuildContextResolvable): string | null { + if (guildContext instanceof Guild) { + return guildContext.id + } + return super.resolveId(guildContext) + } +} diff --git a/src/managers/guild-group.ts b/src/managers/guild-group.ts index 2d8e3fe5..22311a1e 100644 --- a/src/managers/guild-group.ts +++ b/src/managers/guild-group.ts @@ -1,41 +1,47 @@ -import { Group, type GroupUpdateOptions } from '../structures' -import BaseManager from './base' +import { type ChannelGroup, Group, type GroupUpdateOptions, type GuildContext, type RoleGroup } from '../structures' +import { inject, injectable, type interfaces } from 'inversify' +import { DataManager } from './base' import type { Group as GroupEntity } from '../entities' -import type { GroupType } from '../util/constants' -import type { Guild } from 'discord.js' -import { Repository } from 'typeorm' -import { constants } from '../util' -import container from '../configs/container' -import getDecorators from 'inversify-inject-decorators' +import { GroupType } from '../utils/constants' +import type { Repository } from 'typeorm' +import { constants } from '../utils' + +const { TYPES } = constants export type GroupResolvable = string | Group | number -const { TYPES } = constants -const { lazyInject } = getDecorators(container) +@injectable() +export default class GuildGroupManager extends DataManager { + @inject(TYPES.StructureFactory) + private readonly groupFactory!: interfaces.MultiFactory< + ChannelGroup | RoleGroup, + [`${keyof typeof GroupType}Group`], + Parameters + > -export default class GuildGroupManager extends BaseManager { - @lazyInject(TYPES.GroupRepository) + @inject(TYPES.GroupRepository) private readonly groupRepository!: Repository - public readonly guild: Guild + public context!: GuildContext - public constructor (guild: Guild, iterable?: Iterable) { - // @ts-expect-error - super(guild.client, iterable, Group) + public constructor () { + super(Group) + } - this.guild = guild + public override setOptions (context: GuildContext): void { + this.context = context } - public override add (data: GroupEntity, cache = true): Group { + public override add (data: GroupEntity): Group { const existing = this.cache.get(data.id) if (typeof existing !== 'undefined') { return existing } - const group = Group.create(this.client, data, this.guild) - if (cache) { - this.cache.set(group.id, group) - } + const group = this.groupFactory(data.type === GroupType.Channel ? 'ChannelGroup' : 'RoleGroup')( + data, this.context + ) + this.cache.set(group.id, group) return group } @@ -45,7 +51,7 @@ export default class GuildGroupManager extends BaseManager { - const id = this.resolveID(group) + const id = this.resolveId(group) if (id === null) { throw new Error('Invalid group.') } @@ -90,15 +96,17 @@ export default class GuildGroupManager extends BaseManager otherGroup.name.toLowerCase() === group)?.id ?? null + return this.resolve(group)?.id ?? null } - return super.resolveID(group) + return super.resolveId(group) } } diff --git a/src/managers/guild-member.ts b/src/managers/guild-member.ts deleted file mode 100644 index 81531493..00000000 --- a/src/managers/guild-member.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { - Collection, - type FetchMemberOptions, - type FetchMembersOptions, - type GuildMember, - GuildMemberManager, - type Snowflake, - type UserResolvable -} from 'discord.js' -import { userService } from '../services' - -const memberNameRegex = (name: string): RegExp => new RegExp(`^(${name})$|\\s*[(](${name})[)]\\s*`) - -export default class AroraGuildMemberManager extends GuildMemberManager { - public override fetch (options: number): Promise> - public override fetch ( - options: UserResolvable | FetchMemberOptions | (FetchMembersOptions & { user: UserResolvable }) - ): Promise - public override fetch (options?: FetchMembersOptions): Promise> - public override async fetch ( - options?: number | UserResolvable | FetchMemberOptions | (FetchMembersOptions & { user: UserResolvable }) | - FetchMembersOptions - ): Promise> { - if (typeof options === 'number') { - if (this.guild.robloxUsernamesInNicknames) { - const username = (await userService.getUser(options)).name - const regex = memberNameRegex(username) - return (await this.fetch()).filter(member => regex.test(member.displayName)) - } else { - return this.cache.filter(member => member.robloxId === options) - } - } else if (typeof options === 'string') { - if (!/^[0-9]+$/.test(options)) { - if (this.guild.robloxUsernamesInNicknames) { - const regex = memberNameRegex(options) - return (await this.fetch()).filter(member => regex.test(member.displayName)) - } else { - return new Collection() - } - } - } - // @ts-expect-error - return await super.fetch(options) - } -} diff --git a/src/managers/guild-panel.ts b/src/managers/guild-panel.ts index c473add4..f4634fd6 100644 --- a/src/managers/guild-panel.ts +++ b/src/managers/guild-panel.ts @@ -1,34 +1,37 @@ -import { type Guild, MessageEmbed } from 'discord.js' -import { Panel, type PanelUpdateOptions } from '../structures' -import BaseManager from './base' +import { type GuildContext, Panel, type PanelUpdateOptions } from '../structures' +import { MessageEmbed, type TextChannelResolvable } from 'discord.js' +import { inject, injectable } from 'inversify' +import type { AroraClient } from '../client' +import { DataManager } from './base' import type { Panel as PanelEntity } from '../entities' -import { Repository } from 'typeorm' -import type { TextChannelResolvable } from './guild-ticket' -import { constants } from '../util' -import container from '../configs/container' +import type { Repository } from 'typeorm' +import { constants } from '../utils' import { discordService } from '../services' -import getDecorators from 'inversify-inject-decorators' + +const { TYPES } = constants export type PanelResolvable = string | Panel | number -const { TYPES } = constants -const { lazyInject } = getDecorators(container) +@injectable() +export default class GuildPanelManager extends DataManager { + @inject(TYPES.Client) + private readonly client!: AroraClient -export default class GuildPanelManager extends BaseManager { - @lazyInject(TYPES.PanelRepository) + @inject(TYPES.PanelRepository) private readonly panelRepository!: Repository - public readonly guild: Guild + public context!: GuildContext - public constructor (guild: Guild, iterable?: Iterable) { - // @ts-expect-error - super(guild.client, iterable, Panel) + public constructor () { + super(Panel) + } - this.guild = guild + public override setOptions (context: GuildContext): void { + this.context = context } - public override add (data: PanelEntity, cache = true): Panel { - return super.add(data, cache, { id: data.id, extras: [this.guild] }) + public override add (data: PanelEntity): Panel { + return super.add(data, { id: data.id, extras: [this.context] }) } public async create (name: string, content: object): Promise { @@ -43,7 +46,7 @@ export default class GuildPanelManager extends BaseManager { - const id = this.resolveID(panel) + const id = this.resolveId(panel) if (id === null) { throw new Error('Invalid panel.') } @@ -95,7 +98,7 @@ export default class GuildPanelManager extends BaseManager otherPanel.messageId === message.id)) { throw new Error('Another panel is already posted in that message.') } changes.messageId = message.id options.channelId = message.channel.id - options.guildId = this.guild.id + options.guildId = this.context.id - await message.edit('', panel.embed) + await message.edit({ content: null, embeds: [panel.embed] }) } await this.panelRepository.save(this.panelRepository.create({ - id: panel.id, - ...changes + ...changes, + id: panel.id }), { data: options }) @@ -133,7 +136,7 @@ export default class GuildPanelManager extends BaseManager { @@ -146,7 +149,7 @@ export default class GuildPanelManager extends BaseManager otherPanel.name.toLowerCase() === panel)?.id ?? null + return this.resolve(panel)?.id ?? null } - return super.resolveID(panel) + return super.resolveId(panel) } } diff --git a/src/managers/guild-role-binding.ts b/src/managers/guild-role-binding.ts index 7cdfa2e0..02d8119d 100644 --- a/src/managers/guild-role-binding.ts +++ b/src/managers/guild-role-binding.ts @@ -1,32 +1,37 @@ -import type { Collection, Guild, RoleResolvable } from 'discord.js' -import BaseManager from './base' -import { Repository } from 'typeorm' -import { RoleBinding } from '../structures' +import type { Collection, RoleResolvable } from 'discord.js' +import { type GuildContext, RoleBinding } from '../structures' +import { inject, injectable } from 'inversify' +import { DataManager } from './base' +import type { Repository } from 'typeorm' import type { RoleBinding as RoleBindingEntity } from '../entities' -import { constants } from '../util' -import container from '../configs/container' -import getDecorators from 'inversify-inject-decorators' - -export type RoleBindingResolvable = RoleBinding | number +import { constants } from '../utils' const { TYPES } = constants -const { lazyInject } = getDecorators(container) -export default class GuildRoleBindingManager extends BaseManager { - @lazyInject(TYPES.RoleBindingRepository) +export type RoleBindingResolvable = RoleBinding | number + +@injectable() +export default class GuildRoleBindingManager extends DataManager< +number, +RoleBinding, +RoleBindingResolvable, +RoleBindingEntity +> { + @inject(TYPES.RoleBindingRepository) private readonly roleBindingRepository!: Repository - public readonly guild: Guild + public context!: GuildContext - public constructor (guild: Guild, iterable?: Iterable) { - // @ts-expect-error - super(guild.client, iterable, RoleBinding) + public constructor () { + super(RoleBinding) + } - this.guild = guild + public override setOptions (context: GuildContext): void { + this.context = context } - public override add (data: RoleBindingEntity, cache = true): RoleBinding { - return super.add(data, cache, { id: data.id, extras: [this.guild] }) + public override add (data: RoleBindingEntity): RoleBinding { + return super.add(data, { id: data.id, extras: [this.context] }) } public async create ({ role: roleResolvable, min, max }: { @@ -34,10 +39,10 @@ export default class GuildRoleBindingManager extends BaseManager { - if (this.guild.robloxGroupId === null) { + if (this.context.robloxGroupId === null) { throw new Error('This server is not bound to a Roblox group yet.') } - const role = this.guild.roles.resolve(roleResolvable) + const role = this.context.guild.roles.resolve(roleResolvable) if (role === null) { throw new Error('Invalid role.') } @@ -46,15 +51,15 @@ export default class GuildRoleBindingManager extends BaseManager ( - roleBinding.roleId === role.id && roleBinding.robloxGroupId === this.guild.robloxGroupId && + roleBinding.roleId === role.id && roleBinding.robloxGroupId === this.context.robloxGroupId && roleBinding.min === min && (typeof max === 'undefined' || roleBinding.max === max) ))) { throw new Error('A role binding for that role and range already exists.') } const newData = await this.roleBindingRepository.save(this.roleBindingRepository.create({ - robloxGroupId: this.guild.robloxGroupId, - guildId: this.guild.id, + robloxGroupId: this.context.robloxGroupId, + guildId: this.context.id, roleId: role.id, max: max ?? null, min @@ -64,7 +69,7 @@ export default class GuildRoleBindingManager extends BaseManager { - const id = this.resolveID(roleBinding) + const id = this.resolveId(roleBinding) if (id === null) { throw new Error('Invalid role binding.') } @@ -78,7 +83,7 @@ export default class GuildRoleBindingManager extends BaseManager> { - const data = await this.roleBindingRepository.find({ guildId: this.guild.id }) + const data = await this.roleBindingRepository.find({ guildId: this.context.id }) this.cache.clear() for (const rawRoleBinding of data) { this.add(rawRoleBinding) diff --git a/src/managers/guild-role-message.ts b/src/managers/guild-role-message.ts index 940d8f59..79fb2a2a 100644 --- a/src/managers/guild-role-message.ts +++ b/src/managers/guild-role-message.ts @@ -1,39 +1,42 @@ -import { - type EmojiResolvable, - type Guild, - GuildEmoji, - type Message, - type RoleResolvable -} from 'discord.js' -import BaseManager from './base' -import type { CommandoClient } from 'discord.js-commando' -import { Repository } from 'typeorm' -import { RoleMessage } from '../structures' +import { type EmojiResolvable, GuildEmoji, type Message, type RoleResolvable } from 'discord.js' +import { type GuildContext, RoleMessage } from '../structures' +import { inject, injectable } from 'inversify' +import type { AroraClient } from '../client' +import { DataManager } from './base' +import type { Repository } from 'typeorm' import type { RoleMessage as RoleMessageEntity } from '../entities' -import { constants } from '../util' -import container from '../configs/container' -import getDecorators from 'inversify-inject-decorators' +import { constants } from '../utils' +import emojiRegex from 'emoji-regex' + +const { TYPES } = constants export type RoleMessageResolvable = RoleMessage | number -const { TYPES } = constants -const { lazyInject } = getDecorators(container) +@injectable() +export default class GuildRoleMessageManager extends DataManager< +number, +RoleMessage, +RoleMessageResolvable, +RoleMessageEntity +> { + @inject(TYPES.Client) + private readonly client!: AroraClient -export default class GuildRoleMessageManager extends BaseManager { - @lazyInject(TYPES.RoleMessageRepository) + @inject(TYPES.RoleMessageRepository) private readonly roleMessageRepository!: Repository - public readonly guild: Guild + public context!: GuildContext - public constructor (guild: Guild, iterable?: Iterable) { - // @ts-expect-error - super(guild.client, iterable, RoleMessage) + public constructor () { + super(RoleMessage) + } - this.guild = guild + public override setOptions (context: GuildContext): void { + this.context = context } - public override add (data: RoleMessageEntity, cache = true): RoleMessage { - return super.add(data, cache, { id: data.id, extras: [this.guild] }) + public override add (data: RoleMessageEntity): RoleMessage { + return super.add(data, { id: data.id, extras: [this.context] }) } public async create ({ role: roleResolvable, message, emoji: emojiResolvable }: { @@ -41,7 +44,7 @@ export default class GuildRoleMessageManager extends BaseManager { - const role = this.guild.roles.resolve(roleResolvable) + const role = this.context.guild.roles.resolve(roleResolvable) if (role === null) { throw new Error('Invalid role.') } @@ -54,12 +57,10 @@ export default class GuildRoleMessageManager extends BaseManager { - @lazyInject(TYPES.TagRepository) +export type TagResolvable = TagNameResolvable | Tag | number + +@injectable() +export default class GuildTagManager extends DataManager { + @inject(TYPES.TagRepository) private readonly tagRepository!: Repository - public readonly guild: Guild + public context!: GuildContext - public constructor (guild: Guild, iterable?: Iterable) { - // @ts-expect-error - super(guild.client, iterable, Tag) + public constructor () { + super(Tag) + } - this.guild = guild + public override setOptions (context: GuildContext): void { + this.context = context } - public override add (data: TagEntity, cache = true): Tag { - return super.add(data, cache, { id: data.id, extras: [this.guild] }) + public override add (data: TagEntity): Tag { + return super.add(data, { id: data.id, extras: [this.context] }) } public async create (name: string, content: string | object): Promise { @@ -37,12 +36,6 @@ export default class GuildTagManager extends BaseManager { if (this.resolve(name) !== null) { throw new Error('A tag with that name already exists.') } - const first = name.split(/ +/)[0] - if (name === 'all' || - (this.client as CommandoClient).registry.commands.some(command => command.name === first || - command.aliases.includes(first))) { - throw new Error('Not allowed, name is reserved.') - } if (typeof content !== 'string') { const embed = new MessageEmbed(content) const valid = discordService.validateEmbed(embed) @@ -60,7 +53,7 @@ export default class GuildTagManager extends BaseManager { } const newData = await this.tagRepository.save(this.tagRepository.create({ - guildId: this.guild.id, + guildId: this.context.id, content, names: [{ name }] })) @@ -69,7 +62,7 @@ export default class GuildTagManager extends BaseManager { } public async delete (tag: TagResolvable): Promise { - const id = this.resolveID(tag) + const id = this.resolveId(tag) if (id === null) { throw new Error('Invalid tag.') } @@ -85,7 +78,7 @@ export default class GuildTagManager extends BaseManager { tag: TagResolvable, data: TagUpdateOptions ): Promise { - const id = this.resolveID(tag) + const id = this.resolveId(tag) if (id === null) { throw new Error('Invalid tag.') } @@ -111,15 +104,17 @@ export default class GuildTagManager extends BaseManager { } const newData = await this.tagRepository.save(this.tagRepository.create({ - id, - ...changes + ...changes, + id })) const _tag = this.cache.get(id) _tag?.setup(newData) - return _tag ?? this.add(newData, false) + return _tag ?? this.add(newData) } + public override resolve (tag: Tag): Tag + public override resolve (tag: TagResolvable): Tag | null public override resolve (tag: TagResolvable): Tag | null { if (typeof tag === 'string') { return this.cache.find(otherTag => otherTag.names.resolve(tag) !== null) ?? null @@ -127,10 +122,12 @@ export default class GuildTagManager extends BaseManager { return super.resolve(tag) } - public override resolveID (tag: TagResolvable): number | null { + public override resolveId (tag: number): number + public override resolveId (tag: TagResolvable): number | null + public override resolveId (tag: TagResolvable): number | null { if (typeof tag === 'string') { - return this.cache.find(otherTag => otherTag.names.resolve(tag) !== null)?.id ?? null + return this.resolve(tag)?.id ?? null } - return super.resolveID(tag) + return super.resolveId(tag) } } diff --git a/src/managers/guild-ticket-type.ts b/src/managers/guild-ticket-type.ts index 09f82e59..b4ad77ad 100644 --- a/src/managers/guild-ticket-type.ts +++ b/src/managers/guild-ticket-type.ts @@ -1,38 +1,42 @@ -import { - type EmojiResolvable, - type Guild, - GuildEmoji, - type Message -} from 'discord.js' -import { TicketType, type TicketTypeUpdateOptions } from '../structures' -import BaseManager from './base' -import type { CommandoClient } from 'discord.js-commando' -import { Repository } from 'typeorm' +import { type EmojiResolvable, GuildEmoji, type Message } from 'discord.js' +import { type GuildContext, TicketType, type TicketTypeUpdateOptions } from '../structures' +import { inject, injectable } from 'inversify' +import type { AroraClient } from '../client' +import { DataManager } from './base' +import type { Repository } from 'typeorm' import type { TicketType as TicketTypeEntity } from '../entities' -import { constants } from '../util' -import container from '../configs/container' -import getDecorators from 'inversify-inject-decorators' - -export type TicketTypeResolvable = TicketType | string +import { constants } from '../utils' +import emojiRegex from 'emoji-regex' const { TYPES } = constants -const { lazyInject } = getDecorators(container) -export default class GuildTicketTypeManager extends BaseManager { - @lazyInject(TYPES.TicketTypeRepository) +export type TicketTypeResolvable = TicketType | string + +@injectable() +export default class GuildTicketTypeManager extends DataManager< +number, +TicketType, +TicketTypeResolvable, +TicketTypeEntity +> { + @inject(TYPES.Client) + private readonly client!: AroraClient + + @inject(TYPES.TicketTypeRepository) private readonly ticketTypeRepository!: Repository - public readonly guild: Guild + public context!: GuildContext - public constructor (guild: Guild, iterable?: Iterable) { - // @ts-expect-error - super(guild.client, iterable, TicketType) + public constructor () { + super(TicketType) + } - this.guild = guild + public override setOptions (context: GuildContext): void { + this.context = context } - public override add (data: TicketTypeEntity, cache = true): TicketType { - return super.add(data, cache, { id: data.id, extras: [this.guild] }) + public override add (data: TicketTypeEntity): TicketType { + return super.add(data, { id: data.id, extras: [this.context] }) } public async create (name: string): Promise { @@ -43,7 +47,7 @@ export default class GuildTicketTypeManager extends BaseManager { - const id = this.resolveID(ticketTypeResolvable) + const id = this.resolveId(ticketTypeResolvable) if (id === null) { throw new Error('Invalid ticket type.') } @@ -90,8 +94,8 @@ export default class GuildTicketTypeManager extends BaseManager { @@ -176,7 +178,7 @@ export default class GuildTicketTypeManager extends BaseManager ( - otherType.name.toLowerCase().replace(/\s/g, '') === type + otherType.name.toLowerCase().replace(/\s/g, '') === ticketType )) ?? null } - return super.resolve(type) + return super.resolve(ticketType) } - public override resolveID (type: TicketTypeResolvable): number | null { - if (typeof type === 'string') { - type = type.toLowerCase().replace(/\s/g, '') - return this.cache.find(otherType => ( - otherType.name.toLowerCase().replace(/\s/g, '') === type - ))?.id ?? null + public override resolveId (ticketType: number): number + public override resolveId (ticketType: TicketTypeResolvable): number | null + public override resolveId (ticketType: number | TicketTypeResolvable): number | null { + if (typeof ticketType === 'string') { + return this.resolve(ticketType)?.id ?? null } - return super.resolveID(type) + return super.resolveId(ticketType) } } diff --git a/src/managers/guild-ticket.ts b/src/managers/guild-ticket.ts index d227fe67..109aa8cc 100644 --- a/src/managers/guild-ticket.ts +++ b/src/managers/guild-ticket.ts @@ -1,60 +1,63 @@ +import { type GuildContext, Ticket, type TicketUpdateOptions } from '../structures' import { - type Guild, GuildEmoji, type GuildMemberResolvable, MessageEmbed, type MessageReaction, - type Snowflake, TextChannel, + type TextChannelResolvable, type User } from 'discord.js' -import { Ticket, type TicketUpdateOptions } from '../structures' -import BaseManager from './base' -import { Repository } from 'typeorm' +import { inject, injectable } from 'inversify' +import type { AroraClient } from '../client' +import { DataManager } from './base' +import type { Repository } from 'typeorm' import type { Ticket as TicketEntity } from '../entities' import type { TicketTypeResolvable } from './guild-ticket-type' -import { constants } from '../util' -import container from '../configs/container' -import getDecorators from 'inversify-inject-decorators' - -export type TextChannelResolvable = TextChannel | Snowflake -export type TicketResolvable = TextChannelResolvable | GuildMemberResolvable | Ticket | number +import { constants } from '../utils' const { TYPES } = constants -const { lazyInject } = getDecorators(container) -const TICKETS_INTERVAL = 60 * 1000 -const SUBMISSION_TIME = 30 * 60 * 1000 +const TICKETS_INTERVAL = 60_000 +const SUBMISSION_TIME = 3_600_000 + +export type TicketResolvable = TextChannelResolvable | GuildMemberResolvable | Ticket | number + +@injectable() +export default class GuildTicketManager extends DataManager { + @inject(TYPES.Client) + private readonly client!: AroraClient -export default class GuildTicketManager extends BaseManager { - @lazyInject(TYPES.TicketRepository) + @inject(TYPES.TicketRepository) private readonly ticketRepository!: Repository - public readonly guild: Guild - public readonly debounces: Map + public context!: GuildContext - public constructor (guild: Guild, iterable?: Iterable) { - // @ts-expect-error - super(guild.client, iterable, Ticket) + public readonly debounces: Map - this.guild = guild + public constructor () { + super(Ticket) this.debounces = new Map() } - public override add (data: TicketEntity, cache = true): Ticket { - return super.add(data, cache, { id: data.id, extras: [this.guild] }) + public override setOptions (context: GuildContext): void { + this.context = context + } + + public override add (data: TicketEntity): Ticket { + return super.add(data, { id: data.id, extras: [this.context] }) } public async create ({ author: authorResolvable, ticketType: ticketTypeResolvable }: { author: GuildMemberResolvable ticketType: TicketTypeResolvable }): Promise { - const author = this.guild.members.resolve(authorResolvable) + const author = this.context.guild.members.resolve(authorResolvable) if (author === null) { throw new Error('Invalid author.') } - const ticketType = this.guild.ticketTypes.resolve(ticketTypeResolvable) + const ticketType = this.context.ticketTypes.resolve(ticketTypeResolvable) if (ticketType === null) { throw new Error('Invalid ticket type.') } @@ -62,15 +65,18 @@ export default class GuildTicketManager extends BaseManager { - const id = this.resolveID(ticket) + const id = this.resolveId(ticket) if (id === null) { throw new Error('Invalid ticket.') } @@ -109,7 +115,7 @@ export default class GuildTicketManager extends BaseManager { - const id = this.resolveID(ticket) + const id = this.resolveId(ticket) if (id === null) { throw new Error('Invalid ticket.') } @@ -119,7 +125,7 @@ export default class GuildTicketManager extends BaseManager = {} if (typeof data.channel !== 'undefined') { - const channel = this.guild.channels.resolve(data.channel) + const channel = this.context.guild.channels.resolve(data.channel) if (channel === null || !(channel instanceof TextChannel)) { throw new Error('Invalid channel.') } @@ -127,8 +133,8 @@ export default class GuildTicketManager extends BaseManager { - const ticketType = this.guild.ticketTypes.cache.find(ticketType => ( + const ticketType = this.context.ticketTypes.cache.find(ticketType => ( ticketType.message?.id === reaction.message.id && ticketType.emoji !== null && (reaction.emoji instanceof GuildEmoji ? ticketType.emoji instanceof GuildEmoji && reaction.emoji.id === ticketType.emojiId @@ -152,71 +158,63 @@ export default class GuildTicketManager extends BaseManager { + ticket.close('Timeout: ticket closed', false).catch(console.error) + }, SUBMISSION_TIME).unref() } else { const embed = new MessageEmbed() .setColor(0xff0000) .setTitle('Couldn\'t make ticket') .setDescription('You already have an open ticket.') - await this.client.send(user, embed) + await this.client.send(user, { embeds: [embed] }) } } } } - public override resolve (ticketResolvable: TicketResolvable): Ticket | null { - if (typeof ticketResolvable === 'number' || ticketResolvable instanceof Ticket) { - return super.resolve(ticketResolvable) + public override resolve (ticket: Ticket): Ticket + public override resolve (ticket: TicketResolvable): Ticket | null + public override resolve (ticket: TicketResolvable): Ticket | null { + if (typeof ticket === 'number' || ticket instanceof Ticket) { + return super.resolve(ticket) } - if (typeof ticketResolvable === 'string' || ticketResolvable instanceof TextChannel) { - const channel = this.guild.channels.resolve(ticketResolvable) + if (typeof ticket === 'string' || ticket instanceof TextChannel) { + const channel = this.context.guild.channels.resolve(ticket) if (channel !== null) { - return this.cache.find(ticket => ticket.channelId === channel.id) ?? null + return this.cache.find(otherTicket => otherTicket.channelId === channel.id) ?? null } - if (ticketResolvable instanceof TextChannel) { + if (ticket instanceof TextChannel) { return null } } - const member = this.guild.members.resolve(ticketResolvable) + const member = this.context.guild.members.resolve(ticket) if (member !== null) { - return this.cache.find(ticket => ticket.authorId === member.id) ?? null + return this.cache.find(otherTicket => otherTicket.authorId === member.id) ?? null } return null } - public override resolveID (ticketResolvable: TicketResolvable): number | null { - if (typeof ticketResolvable === 'number' || ticketResolvable instanceof Ticket) { - return super.resolve(ticketResolvable)?.id ?? null + public override resolveId (ticket: number): number + public override resolveId (ticket: TicketResolvable): number | null + public override resolveId (ticket: TicketResolvable): number | null { + if (!(typeof ticket === 'number' || ticket instanceof Ticket)) { + return this.resolve(ticket)?.id ?? null } - if (typeof ticketResolvable === 'string' || ticketResolvable instanceof TextChannel) { - const channel = this.guild.channels.resolve(ticketResolvable) - if (channel !== null) { - return this.cache.find(ticket => ticket.channelId === channel.id)?.id ?? null - } - if (ticketResolvable instanceof TextChannel) { - return null - } - } - const member = this.guild.members.resolve(ticketResolvable) - if (member !== null) { - return this.cache.find(ticket => ticket.authorId === member.id)?.id ?? null - } - return null + return super.resolveId(ticket) } } diff --git a/src/managers/index.ts b/src/managers/index.ts index 010fc8bd..13962010 100644 --- a/src/managers/index.ts +++ b/src/managers/index.ts @@ -1,17 +1,14 @@ -export * from './guild-ticket' +export * from './base' export { default as BaseManager } from './base' export { default as GroupRoleManager } from './group-role' export { default as GroupTextChannelManager } from './group-text-channel' +export { default as GuildContextManager } from './guild-context' export { default as GuildGroupManager } from './guild-group' -export { default as GuildMemberManager } from './guild-member' export { default as GuildPanelManager } from './guild-panel' export { default as GuildRoleBindingManager } from './guild-role-binding' export { default as GuildRoleMessageManager } from './guild-role-message' export { default as GuildTagManager } from './guild-tag' export { default as GuildTicketManager } from './guild-ticket' export { default as GuildTicketTypeManager } from './guild-ticket-type' -export { default as PermissionManager } from './permission' -export { default as RoleGroupManager } from './role-group' export { default as TagTagNameManager } from './tag-tag-name' -export { default as TextChannelGroupManager } from './text-channel-group' export { default as TicketGuildMemberManager } from './ticket-guild-member' diff --git a/src/managers/permission.ts b/src/managers/permission.ts deleted file mode 100644 index 7e90d232..00000000 --- a/src/managers/permission.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { Command, CommandGroup, type CommandoClient } from 'discord.js-commando' -import { type Guild, Role } from 'discord.js' -import type { PermissibleType, PermissionUpdateOptions } from '../structures' -import BaseManager from './base' -import Permission from '../structures/permission' -import type { Permission as PermissionEntity } from '../entities' -import type { Repository } from 'typeorm' -import { constants } from '../util' -import container from '../configs/container' -import getDecorators from 'inversify-inject-decorators' - -export type CommandOrCommandGroupResolvable = Command | CommandGroup | string -export type PermissionResolvable = CommandOrCommandGroupResolvable | Permission | number - -const { TYPES } = constants -const { lazyInject } = getDecorators(container) - -export default class PermissionManager extends BaseManager { - @lazyInject(TYPES.PermissionRepository) - private readonly permissionRepository!: Repository - - public readonly permissible: PermissibleType - public readonly guild: Guild - - public constructor (permissible: PermissibleType, iterable?: Iterable) { - // @ts-expect-error - super(permissible.client, iterable, Permission) - - this.permissible = permissible - this.guild = permissible.guild - } - - public override add (data: PermissionEntity, cache = true): Permission { - return super.add(data, cache, { id: data.id, extras: [this.permissible] }) - } - - public async create (commandOrCommandGroup: CommandOrCommandGroupResolvable, allow: boolean): Promise { - if (typeof commandOrCommandGroup === 'string') { - try { - commandOrCommandGroup = (this.client as CommandoClient).registry.resolveCommand(commandOrCommandGroup) - } catch { - try { - commandOrCommandGroup = (this.client as CommandoClient).registry.resolveGroup(commandOrCommandGroup as string) - } catch { - throw new Error('Invalid command or command group.') - } - } - } - if (commandOrCommandGroup.guarded || - (commandOrCommandGroup instanceof Command && commandOrCommandGroup.group.guarded)) { - throw new Error('Cannot create permissions for guarded commands or command groups.') - } - if (commandOrCommandGroup instanceof Command - ? commandOrCommandGroup.groupID === 'util' - : commandOrCommandGroup.id === 'util') { - throw new Error('Cannot create permissions for `Utility` command group or commands in it.') - } - if (this.resolve(commandOrCommandGroup) !== null) { - throw new Error('A permission for that command or command group already exists.') - } - const commandId = commandOrCommandGroup.aroraId - - const permission = await this.permissionRepository.save(this.permissionRepository.create({ - roleId: this.permissible instanceof Role ? this.permissible.id : null, - groupId: !(this.permissible instanceof Role) ? this.permissible.id : null, - commandId, - allow - }), { - data: { guildId: this.guild.id } - }) - - return this.add(permission) - } - - public async delete (permission: PermissionResolvable): Promise { - const id = this.resolveID(permission) - if (id === null) { - throw new Error('Invalid permission.') - } - if (!this.cache.has(id)) { - throw new Error('Permission not found.') - } - - await this.permissionRepository.delete(id) - this.cache.delete(id) - } - - public async update ( - permission: PermissionResolvable, - data: PermissionUpdateOptions - ): Promise { - const id = this.resolveID(permission) - if (id === null) { - throw new Error('Invalid permission.') - } - if (!this.cache.has(id)) { - throw new Error('Permission not found.') - } - - const changes: Partial = {} - if (typeof data.allow !== 'undefined') { - changes.allow = data.allow - } - - const newData = await this.permissionRepository.save(this.permissionRepository.create({ - id, - ...changes - })) - - const _permission = this.cache.get(id) - _permission?.setup(newData) - return _permission ?? this.add(newData, false) - } - - public override resolve (permissionResolvable: PermissionResolvable): Permission | null { - const permission = super.resolve(permissionResolvable) - if (permission !== null) { - return permission - } - let commandId: number | undefined - if (permissionResolvable instanceof Command || typeof permissionResolvable === 'string') { - try { - commandId = (this.client as CommandoClient).registry.resolveCommand(permissionResolvable).aroraId - } catch {} - } - if (typeof commandId === 'undefined' && - (permissionResolvable instanceof CommandGroup || typeof permissionResolvable === 'string')) { - try { - commandId = (this.client as CommandoClient).registry.resolveGroup(permissionResolvable).aroraId - } catch {} - } - if (typeof commandId !== 'undefined') { - return this.cache.find(otherPermission => otherPermission.commandId === commandId) ?? null - } - return null - } - - public override resolveID (permissionResolvable: PermissionResolvable): number | null { - const permission = super.resolve(permissionResolvable) - if (permission !== null) { - return permission.id - } - let commandId: number | undefined - if (permissionResolvable instanceof Command || typeof permissionResolvable === 'string') { - try { - commandId = (this.client as CommandoClient).registry.resolveCommand(permissionResolvable).aroraId - } catch {} - } - if (typeof commandId === 'undefined' && - (permissionResolvable instanceof CommandGroup || typeof permissionResolvable === 'string')) { - try { - commandId = (this.client as CommandoClient).registry.resolveGroup(permissionResolvable).aroraId - } catch {} - } - if (typeof commandId !== 'undefined') { - return this.cache.find(otherPermission => otherPermission.commandId === commandId)?.id ?? null - } - return null - } -} diff --git a/src/managers/role-group.ts b/src/managers/role-group.ts deleted file mode 100644 index 80d7ff64..00000000 --- a/src/managers/role-group.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Collection, Guild, Role } from 'discord.js' -import type { RoleGroup } from '../structures' - -export default class RoleGroupManager { - public readonly role: Role - public readonly guild: Guild - - public constructor (role: Role) { - this.role = role - this.guild = role.guild - } - - public get cache (): Collection { - return this.guild.groups.cache.filter(group => { - return group.isRoleGroup() && group.roles.cache.has(this.role.id) - }) as Collection - } -} diff --git a/src/managers/tag-tag-name.ts b/src/managers/tag-tag-name.ts index 3f496685..bce714ba 100644 --- a/src/managers/tag-tag-name.ts +++ b/src/managers/tag-tag-name.ts @@ -1,49 +1,43 @@ -import { BaseManager as DiscordBaseManager, type Guild } from 'discord.js' +import { type GuildContext, type Tag, TagName } from '../structures' import type { Tag as TagEntity, TagName as TagNameEntity } from '../entities' -import type { CommandoClient } from 'discord.js-commando' -import { Repository } from 'typeorm' -import type { Tag } from '../structures' -import TagName from '../structures/tag-name' -import { constants } from '../util' -import container from '../configs/container' -import getDecorators from 'inversify-inject-decorators' - -export type TagNameResolvable = TagName | string +import { inject, injectable } from 'inversify' +import { DataManager } from './base' +import type { Repository } from 'typeorm' +import { constants } from '../utils' const { TYPES } = constants -const { lazyInject } = getDecorators(container) -export default class TagTagNameManager extends DiscordBaseManager { - @lazyInject(TYPES.TagRepository) - private readonly tagRepository!: Repository +export type TagNameResolvable = TagName | string - @lazyInject(TYPES.TagNameRepository) +@injectable() +export default class TagTagNameManager extends DataManager { + @inject(TYPES.TagNameRepository) private readonly tagNameRepository!: Repository - public readonly tag: Tag - public readonly guild: Guild + @inject(TYPES.TagRepository) + private readonly tagRepository!: Repository + + public tag!: Tag + public context!: GuildContext - public constructor (tag: Tag, iterable?: Iterable) { - // @ts-expect-error - super(tag.guild.client, iterable, TagName) + public constructor () { + super(TagName) + } + public override setOptions (tag: Tag): void { this.tag = tag - this.guild = tag.guild + this.context = tag.context } - public override add (data: TagNameEntity, cache = true): TagName { - return super.add(data, cache, { id: data.name, extras: [this.tag] }) + public override add (data: TagNameEntity): TagName { + return super.add(data, { id: data.name, extras: [this.tag] }) } public async create (name: string): Promise { name = name.toLowerCase() - if (this.guild.tags.resolve(name) !== null) { + if (this.context.tags.resolve(name) !== null) { throw new Error('A tag with that name already exists.') } - if (name === 'all' || (this.client as CommandoClient).registry.commands.some(command => command.name === name || - command.aliases.includes(name))) { - throw new Error('Not allowed, name is reserved.') - } const tagNameData = await this.tagNameRepository.save(this.tagNameRepository.create({ name, tagId: this.tag.id })) const tagData = await this.tagRepository.findOne(this.tag.id, { relations: ['names'] }) as TagEntity @@ -57,7 +51,7 @@ export default class TagTagNameManager extends DiscordBaseManager { - const id = this.resolveID(tagNameResolvable) + const id = this.resolveId(tagNameResolvable) if (id === null) { throw new Error('Invalid name.') } @@ -72,22 +66,24 @@ export default class TagTagNameManager extends DiscordBaseManager otherTagName.name.toLowerCase() === tagNameResolvable) ?? null + public override resolve (tagName: TagName): TagName + public override resolve (tagName: TagNameResolvable): TagName | null + public override resolve (tagName: TagNameResolvable): TagName | null { + if (typeof tagName === 'string') { + tagName = tagName.toLowerCase() + return this.cache.find(otherTagName => otherTagName.name.toLowerCase() === tagName) ?? null } - return super.resolve(tagNameResolvable) + return super.resolve(tagName) } - public override resolveID (tagNameResolvable: TagNameResolvable): string | null { - if (tagNameResolvable instanceof this.holds) { - return tagNameResolvable.name + public override resolveId (tagName: string): string + public override resolveId (tagName: TagNameResolvable): string | null + public override resolveId (tagName: TagNameResolvable): string | null { + if (tagName instanceof this.holds) { + return tagName.name } - if (typeof tagNameResolvable === 'string') { - tagNameResolvable = tagNameResolvable.toLowerCase() - return this.cache.find(otherTagName => otherTagName.name.toLowerCase() === tagNameResolvable)?.name ?? - tagNameResolvable + if (typeof tagName === 'string') { + return this.resolve(tagName)?.name ?? tagName } return null } diff --git a/src/managers/text-channel-group.ts b/src/managers/text-channel-group.ts deleted file mode 100644 index b18e0523..00000000 --- a/src/managers/text-channel-group.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Collection, Guild, TextChannel } from 'discord.js' -import type { ChannelGroup } from '../structures' - -export default class TextChannelGroupManager { - public readonly channel: TextChannel - public readonly guild: Guild - - public constructor (channel: TextChannel) { - this.channel = channel - this.guild = channel.guild - } - - public get cache (): Collection { - return this.guild.groups.cache.filter(group => { - return group.isChannelGroup() && group.channels.cache.has(this.channel.id) - }) as Collection - } -} diff --git a/src/managers/ticket-guild-member.ts b/src/managers/ticket-guild-member.ts index a483b323..ba51d32a 100644 --- a/src/managers/ticket-guild-member.ts +++ b/src/managers/ticket-guild-member.ts @@ -1,46 +1,41 @@ -import { - type Client, - Collection, - Constants, - type Guild, - type GuildMember, - type GuildMemberResolvable, - type Snowflake -} from 'discord.js' +import { Collection, Constants, type GuildMember, type GuildMemberResolvable, type Snowflake } from 'discord.js' +import type { GuildContext, Ticket } from '../structures' import type { Member as MemberEntity, Ticket as TicketEntity } from '../entities' -import { Repository } from 'typeorm' -import type { Ticket } from '../structures' -import { constants } from '../util' -import container from '../configs/container' -import getDecorators from 'inversify-inject-decorators' +import { inject, injectable } from 'inversify' +import type { AroraClient } from '../client' +import BaseManager from './base' +import type { Repository } from 'typeorm' +import { constants } from '../utils' const { PartialTypes } = Constants const { TYPES } = constants -const { lazyInject } = getDecorators(container) -export default class TicketGuildMemberManager { - @lazyInject(TYPES.MemberRepository) +@injectable() +export default class TicketGuildMemberManager extends BaseManager { + @inject(TYPES.Client) + private readonly client!: AroraClient + + @inject(TYPES.MemberRepository) private readonly memberRepository!: Repository - @lazyInject(TYPES.TicketRepository) + @inject(TYPES.TicketRepository) private readonly ticketRepository!: Repository - public readonly ticket: Ticket - public readonly client: Client - public readonly guild: Guild + public ticket!: Ticket + public context!: GuildContext - public constructor (ticket: Ticket) { + public override setOptions (ticket: Ticket): void { this.ticket = ticket - this.client = ticket.client - this.guild = ticket.guild + this.context = ticket.context } public get cache (): Collection { const cache: Collection = new Collection() for (const moderatorId of this.ticket._moderators) { - const member = this.guild.members.resolve(moderatorId) ?? + const member = this.context.guild.members.resolve(moderatorId) ?? (this.client.options.partials?.includes(PartialTypes.GUILD_MEMBER) === true - ? this.guild.members.add({ user: { id: moderatorId } }) + // @ts-expect-error + ? this.context.guild.members._add({ user: { id: moderatorId } }) : null) if (member !== null) { cache.set(moderatorId, member) @@ -50,7 +45,7 @@ export default class TicketGuildMemberManager { } public async add (memberResolvable: GuildMemberResolvable): Promise { - const member = this.guild.members.resolve(memberResolvable) + const member = this.context.guild.members.resolve(memberResolvable) if (member === null) { throw new Error('Invalid member.') } @@ -58,7 +53,7 @@ export default class TicketGuildMemberManager { throw new Error('Ticket already contains moderator.') } - const memberFields = { userId: member.id, guildId: this.guild.id } + const memberFields = { userId: member.id, guildId: this.context.id } const memberData = await this.memberRepository.findOne( memberFields, { relations: ['moderatingTickets', 'roles'] } diff --git a/src/migrations/1652726153527-remove-permissions-and-commands.ts b/src/migrations/1652726153527-remove-permissions-and-commands.ts new file mode 100644 index 00000000..0670edfa --- /dev/null +++ b/src/migrations/1652726153527-remove-permissions-and-commands.ts @@ -0,0 +1,135 @@ +import { type MigrationInterface, type QueryRunner, Table, TableCheck } from 'typeorm' + +export class removePermissionsAndCommands1652726153527 implements MigrationInterface { + public async up (queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('permissions') + await queryRunner.dropTable('guilds_commands') + await queryRunner.dropTable('commands') + } + + public async down (queryRunner: QueryRunner): Promise { + const driver = queryRunner.connection.driver + + await queryRunner.createTable(new Table({ + name: 'commands', + columns: [{ + name: 'id', + type: 'int', + isPrimary: true, + isGenerated: true + }, { + name: 'name', + type: 'varchar(255)' + }, { + name: 'type', + type: 'enum', + enum: ['command', 'group'] + }], + indices: [{ + columnNames: ['name', 'type'], + isUnique: true + }] + })) + + await queryRunner.createTable(new Table({ + name: 'guilds_commands', + columns: [{ + name: 'guild_id', + type: 'bigint', + isPrimary: true + }, { + name: 'command_id', + type: 'int', + isPrimary: true + }, { + name: 'enabled', + type: 'bool' + }], + foreignKeys: [{ + columnNames: ['guild_id'], + referencedColumnNames: ['id'], + referencedTableName: 'guilds', + onDelete: 'CASCADE' + }, { + columnNames: ['command_id'], + referencedColumnNames: ['id'], + referencedTableName: 'commands', + onDelete: 'CASCADE' + }] + })) + + await queryRunner.createTable(new Table({ + name: 'permissions', + columns: [{ + name: 'id', + type: 'int', + isPrimary: true, + isGenerated: true + }, { + name: 'allow', + type: 'bool' + }, { + name: 'command_id', + type: 'int' + }, { + name: 'role_id', + type: 'bigint', + isNullable: true + }, { + name: 'group_id', + type: 'int', + isNullable: true + }], + foreignKeys: [{ + columnNames: ['command_id'], + referencedColumnNames: ['id'], + referencedTableName: 'commands', + onDelete: 'CASCADE' + }, { + columnNames: ['role_id'], + referencedColumnNames: ['id'], + referencedTableName: 'roles', + onDelete: 'CASCADE' + }, { + columnNames: ['group_id'], + referencedColumnNames: ['id'], + referencedTableName: 'groups', + onDelete: 'CASCADE' + }], + indices: [{ + columnNames: ['command_id', 'group_id', 'role_id'], + isUnique: true + }, { + columnNames: ['command_id', 'group_id'], + isUnique: true, + where: `${driver.escape('role_id')} IS NULL` + }, { + columnNames: ['command_id', 'role_id'], + isUnique: true, + where: `${driver.escape('group_id')} IS NULL` + }] + })) + + await createExclusiveArcConstraint(queryRunner, 'permissions', ['role_id', 'group_id']) + } +} + +async function createExclusiveArcConstraint ( + queryRunner: QueryRunner, + tableName: string, + columns: string[] +): Promise { + return await createCardinalityConstraint(queryRunner, tableName, columns, '= 1') +} + +async function createCardinalityConstraint ( + queryRunner: QueryRunner, + tableName: string, + columns: string[], + condition: string +): Promise { + const driver = queryRunner.connection.driver + return await queryRunner.createCheckConstraint(tableName, new TableCheck({ + expression: `(${columns.map(column => `(${driver.escape(column)} IS NOT NULL)::INTEGER`).join(' +\n')}) ${condition}` + })) +} diff --git a/src/services/channel-link.ts b/src/services/channel-link.ts new file mode 100644 index 00000000..1d9a90ff --- /dev/null +++ b/src/services/channel-link.ts @@ -0,0 +1,56 @@ +import type { Collection, GuildChannel, TextChannel, VoiceBasedChannel } from 'discord.js' +import { inject, injectable } from 'inversify' +import type { Channel as ChannelEntity } from '../entities' +import type { Repository } from 'typeorm' +import { constants } from '../utils' + +const { TYPES } = constants + +@injectable() +export default class ChannelLinkService { + @inject(TYPES.ChannelRepository) + private readonly channelRepository!: Repository + + public async fetchToLinks (channel: VoiceBasedChannel): Promise> { + const data = await this.getData(channel) + + return channel.guild.channels.cache.filter(otherChannel => ( + otherChannel.isText() && data?.toLinks.some(link => link.id === otherChannel.id)) === true + ) as Collection + } + + public async linkChannel (voiceChannel: VoiceBasedChannel, textChannel: TextChannel): Promise { + const data = await this.getData(voiceChannel) ?? await this.channelRepository.save(this.channelRepository.create({ + id: voiceChannel.id, + guildId: voiceChannel.guild.id + })) + if (typeof data.toLinks === 'undefined') { + data.toLinks = [] + } + + if (data.toLinks.some(link => link.id === textChannel.id)) { + throw new Error('Voice channel does already have linked text channel.') + } else { + data.toLinks.push({ id: textChannel.id, guildId: voiceChannel.guild.id }) + await this.channelRepository.save(data) + } + } + + public async unlinkChannel (voiceChannel: VoiceBasedChannel, textChannel: TextChannel): Promise { + const data = await this.getData(voiceChannel) + + if (typeof data === 'undefined' || !data?.toLinks.some(link => link.id === textChannel.id)) { + throw new Error('Voice channel does not have linked text channel.') + } else { + data.toLinks = data.toLinks.filter(link => link.id !== textChannel.id) + await this.channelRepository.save(data) + } + } + + private async getData (channel: GuildChannel): Promise<(ChannelEntity & { toLinks: ChannelEntity[] }) | undefined> { + return await this.channelRepository.findOne( + { id: channel.id, guildId: channel.guild.id }, + { relations: ['toLinks'] } + ) as (ChannelEntity & { toLinks: ChannelEntity[] }) | undefined + } +} diff --git a/src/services/discord.ts b/src/services/discord.ts index 18b7df42..d10435a5 100644 --- a/src/services/discord.ts +++ b/src/services/discord.ts @@ -15,15 +15,15 @@ export async function prompt ( message: Message, options: Array ): Promise { - const userId = message.client.users.resolveID(user) + const userId = message.client.users.resolveId(user) if (userId === null) { throw new Error('Invalid user.') } const filter = (reaction: MessageReaction, user: User): boolean => ( - options.includes(reaction.emoji.name) && user.id === userId + reaction.emoji.name !== null && options.includes(reaction.emoji.name) && user.id === userId ) - const collector = message.createReactionCollector(filter, { time: REACTION_COLLECTOR_TIME }) + const collector = message.createReactionCollector({ filter, time: REACTION_COLLECTOR_TIME }) const promise: Promise = new Promise(resolve => { collector.on('end', collected => { const reaction = collected.first() @@ -71,7 +71,7 @@ export function getListEmbeds ( return embeds } -export function validateEmbed (embed: MessageEmbed): boolean | string { +export function validateEmbed (embed: MessageEmbed): string | true { if (embed.length > 6000) { return 'Embed length is too big.' } else if ((embed.title?.length ?? 0) > 256) { diff --git a/src/services/group.ts b/src/services/group.ts index fd9d17eb..17d52074 100644 --- a/src/services/group.ts +++ b/src/services/group.ts @@ -2,7 +2,7 @@ import * as discordService from './discord' import * as userService from '../services/user' import type { GetGroup, GetGroupRoles } from '@guidojw/bloxy/dist/client/apis/GroupsAPI' import { applicationAdapter, robloxAdapter } from '../adapters' -import { timeUtil, util } from '../util' +import { timeUtil, util } from '../utils' import type { GetUsers } from './user' import type { MessageEmbed } from 'discord.js' import pluralize from 'pluralize' @@ -95,9 +95,9 @@ export function getBanRow (ban: Ban, { users, roles }: { users: GetUsers, roles: let durationString = '' if (ban.duration !== null) { - const days = ban.duration / (24 * 60 * 60 * 1000) + const days = ban.duration / 86_400_000 const extensionDays = ban.extensions.reduce((result, extension) => result + extension.duration, 0) / - (24 * 60 * 60 * 1000) + 86_400_000 const extensionString = extensionDays !== 0 ? ` (${Math.sign(extensionDays) === 1 ? '+' : ''}${extensionDays})` : '' diff --git a/src/services/index.ts b/src/services/index.ts index 1072345e..0b8d1552 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -1,3 +1,6 @@ export * as discordService from './discord' export * as groupService from './group' export * as userService from './user' +export * as verificationService from './verification' +export { default as ChannelLinkService } from './channel-link' +export { default as PersistentRoleService } from './persistent-role' diff --git a/src/services/persistent-role.ts b/src/services/persistent-role.ts new file mode 100644 index 00000000..0325998e --- /dev/null +++ b/src/services/persistent-role.ts @@ -0,0 +1,58 @@ +import type { Collection, GuildMember, Role } from 'discord.js' +import type { Member as MemberEntity, Role as RoleEntity } from '../entities' +import { inject, injectable } from 'inversify' +import type { Repository } from 'typeorm' +import { constants } from '../utils' + +const { TYPES } = constants + +@injectable() +export default class PersistentRoleService { + @inject(TYPES.MemberRepository) + private readonly memberRepository!: Repository + + public async fetchPersistentRoles (member: GuildMember): Promise> { + const data = await this.getData(member) + + return member.guild.roles.cache.filter(role => ( + data?.roles.some(persistentRole => persistentRole.id === role.id) === true + )) + } + + public async persistRole (member: GuildMember, role: Role): Promise { + await member.roles.add(role) + const data = await this.getData(member) ?? await this.memberRepository.save(this.memberRepository.create({ + userId: member.id, + guildId: member.guild.id + })) + if (typeof data.roles === 'undefined') { + data.roles = [] + } + + if (data.roles.some(otherRole => otherRole.id === role.id)) { + throw new Error('Member does already have role.') + } else { + data.roles.push({ id: role.id, guildId: member.guild.id }) + await this.memberRepository.save(data) + } + } + + public async unpersistRole (member: GuildMember, role: Role): Promise { + const data = await this.getData(member) + + if (typeof data === 'undefined' || !data?.roles.some(otherRole => otherRole.id === role.id)) { + throw new Error('Member does not have role.') + } else { + data.roles = data.roles.filter(otherRole => otherRole.id !== role.id) + await this.memberRepository.save(data) + await member.roles.remove(role) + } + } + + private async getData (member: GuildMember): Promise<(MemberEntity & { roles: RoleEntity[] }) | undefined> { + return await this.memberRepository.findOne( + { userId: member.id, guildId: member.guild.id }, + { relations: ['moderatingTickets', 'roles'] } + ) as (MemberEntity & { roles: RoleEntity[] }) | undefined + } +} diff --git a/src/services/user.ts b/src/services/user.ts index 27753c0b..6798fcbd 100644 --- a/src/services/user.ts +++ b/src/services/user.ts @@ -2,7 +2,7 @@ import type { GetUserById, GetUsersByUserIds, GetUsersByUsernames } from '@guido import type { GetUserOutfits as BloxyGetUserOutfits } from '@guidojw/bloxy/dist/client/apis/AvatarAPI' import type { GetUserGroups } from '@guidojw/bloxy/dist/client/apis/GroupsAPI' import { robloxAdapter } from '../adapters' -import { util } from '../util' +import { util } from '../utils' export type GetUsers = GetUsersByUserIds['data'] export type GetGroupsRoles = GetUserGroups['data'] diff --git a/src/services/verification.ts b/src/services/verification.ts new file mode 100644 index 00000000..a5ca23ab --- /dev/null +++ b/src/services/verification.ts @@ -0,0 +1,78 @@ +import { bloxlinkAdapter, roVerAdapter } from '../adapters' +import { VerificationProvider } from '../utils/constants' + +export interface VerificationData { + provider: VerificationProvider + robloxId: number + robloxUsername: string | null +} + +export async function fetchVerificationData ( + userId: string, + guildId?: string, + verificationPreference = VerificationProvider.RoVer +): Promise { + let data = null + let error + try { + const fetch = verificationPreference === VerificationProvider.RoVer ? fetchRoVerData : fetchBloxlinkData + data = await fetch(userId, guildId) + } catch (err) { + error = err + } + if ((data ?? false) === false) { + try { + const fetch = verificationPreference === VerificationProvider.RoVer ? fetchBloxlinkData : fetchRoVerData + data = await fetch(userId, guildId) + } catch (err) { + throw error ?? err + } + } + if (typeof data === 'number') { + data = { + provider: VerificationProvider.Bloxlink, + robloxId: data, + robloxUsername: null + } + } else if (data !== null) { + data = { + provider: VerificationProvider.RoVer, + ...data + } + } + return data +} + +async function fetchRoVerData (userId: string): Promise<{ robloxUsername: string, robloxId: number } | null> { + let response: { robloxUsername: string, robloxId: number } + try { + response = (await roVerAdapter('GET', `user/${userId}`)).data + } catch (err: any) { + if (err.response?.data?.errorCode === 404) { + return null + } + throw err.response?.data?.error ?? err + } + + return { + robloxUsername: response.robloxUsername, + robloxId: response.robloxId + } +} + +async function fetchBloxlinkData (userId: string, guildId?: string): Promise { + const response = (await bloxlinkAdapter( + 'GET', + `user/${userId}${typeof guildId !== 'undefined' ? `?guild=${guildId}` : ''}` + )).data + if (response.status === 'error') { + if ((response.error as string).includes('not linked')) { + return null + } + return response.status + } + + return (response.matchingAccount !== null || response.primaryAccount !== null) + ? parseInt(response.matchingAccount ?? response.primaryAccount) + : null +} diff --git a/src/structures/base.ts b/src/structures/base.ts index b891b00e..cb501440 100644 --- a/src/structures/base.ts +++ b/src/structures/base.ts @@ -1,11 +1,17 @@ -import type { Client } from 'discord.js' +import type { IdentifiableEntity } from '../entities' +import { injectable } from 'inversify' -export default abstract class BaseStructure { - public readonly client: Client +export interface IdentifiableStructure< + T extends number | string, + U extends IdentifiableEntity + > extends BaseStructure { + id: T + toString (): string +} - public constructor (client: Client) { - this.client = client - } +@injectable() +export default abstract class BaseStructure { + public abstract setOptions (data: T, ...extras: unknown[]): void public abstract setup (data: any): void } diff --git a/src/structures/channel-group.ts b/src/structures/channel-group.ts index fc045f7d..c9b6e75a 100644 --- a/src/structures/channel-group.ts +++ b/src/structures/channel-group.ts @@ -1,15 +1,28 @@ -import type { Client, Guild } from 'discord.js' +import { type ManagerFactory, constants } from '../utils' +import { inject, injectable } from 'inversify' import Group from './group' import type { Group as GroupEntity } from '../entities' -import GroupTextChannelManager from '../managers/group-text-channel' +import type { GroupTextChannelManager } from '../managers' +import type { GuildContext } from '.' +import type { TextChannel } from 'discord.js' +const { TYPES } = constants + +@injectable() export default class ChannelGroup extends Group { + @inject(TYPES.ManagerFactory) + private readonly managerFactory!: ManagerFactory + public _channels: string[] - public constructor (client: Client, data: GroupEntity, guild: Guild) { - super(client, data, guild) + public constructor () { + super() this._channels = [] + } + + public override setOptions (data: GroupEntity, context: GuildContext): void { + super.setOptions(data, context) this.setup(data) } @@ -23,6 +36,6 @@ export default class ChannelGroup extends Group { } public get channels (): GroupTextChannelManager { - return new GroupTextChannelManager(this) + return this.managerFactory('GroupTextChannelManager')(this) } } diff --git a/src/structures/group.ts b/src/structures/group.ts index b860156e..ac5e5304 100644 --- a/src/structures/group.ts +++ b/src/structures/group.ts @@ -1,24 +1,23 @@ -import type { Client, Guild } from 'discord.js' +import type { ChannelGroup, GuildContext, RoleGroup } from '.' import BaseStructure from './base' -import type ChannelGroup from './channel-group' import type { Group as GroupEntity } from '../entities' -import { GroupType } from '../util/constants' -import type RoleGroup from './role-group' +import type { GroupType } from '../utils/constants' +import { injectable } from 'inversify' export interface GroupUpdateOptions { name?: string } -export default class Group extends BaseStructure { - public readonly type: GroupType - public readonly guild: Guild +@injectable() +export default class Group extends BaseStructure { + public context!: GuildContext + + public type!: GroupType public id!: number public name!: string public guarded!: boolean - public constructor (client: Client, data: GroupEntity, guild: Guild) { - super(client) - + public setOptions (data: GroupEntity, context: GuildContext): void { this.type = data.type - this.guild = guild + this.context = context } public setup (data: GroupEntity): void { @@ -28,30 +27,11 @@ export default class Group extends BaseStructure { } public async update (data: GroupUpdateOptions): Promise { - return await this.guild.groups.update(this, data) + return await this.context.groups.update(this, data) } public async delete (): Promise { - return await this.guild.groups.delete(this) - } - - public static create (client: Client, data: GroupEntity, guild: Guild): Group { - let group - switch (data.type) { - case GroupType.Channel: { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const ChannelGroup = require('./channel-group').default - group = new ChannelGroup(client, data, guild) - break - } - case GroupType.Role: { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const RoleGroup = require('./role-group').default - group = new RoleGroup(client, data, guild) - break - } - } - return group + return await this.context.groups.delete(this) } public isChannelGroup (): this is ChannelGroup { diff --git a/src/structures/guild-context.ts b/src/structures/guild-context.ts new file mode 100644 index 00000000..8a799b9b --- /dev/null +++ b/src/structures/guild-context.ts @@ -0,0 +1,263 @@ +import { + type CategoryChannel, + type CategoryChannelResolvable, + Collection, + type ColorResolvable, + type Guild, + GuildEmoji, + type GuildMember, + type Message, + MessageEmbed, + type MessageReaction, + type Snowflake, + type TextChannel, + type TextChannelResolvable, + type User +} from 'discord.js' +import type { Group, Panel, RoleBinding, RoleMessage, Tag, Ticket, TicketType } from '.' +import type { + GuildContextManager, + GuildGroupManager, + GuildPanelManager, + GuildRoleBindingManager, + GuildRoleMessageManager, + GuildTagManager, + GuildTicketManager, + GuildTicketTypeManager +} from '../managers' +import { type ManagerFactory, constants } from '../utils' +import { inject, injectable, type interfaces, named } from 'inversify' +import type { BaseJob } from '../jobs' +import BaseStructure from './base' +import type { Guild as GuildEntity } from '../entities' +import type { VerificationProvider } from '../utils/constants' +import applicationConfig from '../configs/application' +import cron from 'node-cron' +import cronConfig from '../configs/cron' + +const { TYPES } = constants + +const memberNameRegex = (name: string): RegExp => new RegExp(`^(${name})$|\\s*[(](${name})[)]\\s*`) + +export interface GuildUpdateOptions { + logsChannel?: TextChannelResolvable | null + primaryColor?: number | null + ratingsChannel?: TextChannelResolvable | null + robloxGroup?: number | null + robloxUsernamesInNicknames?: boolean + suggestionsChannel?: TextChannelResolvable | null + supportEnabled?: boolean + ticketArchivesChannel?: TextChannelResolvable | null + ticketsCategory?: CategoryChannelResolvable | null + verificationPreference?: VerificationProvider +} + +@injectable() +export default class GuildContext extends BaseStructure { + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + @inject(TYPES.JobFactory) + public readonly jobFactory!: interfaces.AutoNamedFactory + + public readonly groups: GuildGroupManager + public readonly panels: GuildPanelManager + public readonly roleBindings: GuildRoleBindingManager + public readonly roleMessages: GuildRoleMessageManager + public readonly tags: GuildTagManager + public readonly tickets: GuildTicketManager + public readonly ticketTypes: GuildTicketTypeManager + + public guild!: Guild + + public logsChannelId!: Snowflake | null + public primaryColor!: number | null + public ratingsChannelId!: Snowflake | null + public robloxGroupId!: number | null + public robloxUsernamesInNicknames!: boolean + public suggestionsChannelId!: Snowflake | null + public supportEnabled!: boolean + public ticketArchivesChannelId!: Snowflake | null + public ticketsCategoryId!: Snowflake | null + public verificationPreference!: VerificationProvider + + public constructor (@inject(TYPES.ManagerFactory) managerFactory: ManagerFactory) { + super() + + this.groups = managerFactory('GuildGroupManager')(this) + this.panels = managerFactory('GuildPanelManager')(this) + this.roleBindings = managerFactory('GuildRoleBindingManager')(this) + this.roleMessages = managerFactory('GuildRoleMessageManager')(this) + this.tags = managerFactory('GuildTagManager')(this) + this.tickets = managerFactory('GuildTicketManager')(this) + this.ticketTypes = managerFactory('GuildTicketTypeManager')(this) + } + + public setOptions (data: GuildEntity, guild: Guild): void { + this.guild = guild + + this.setup(data) + } + + public setup (data: GuildEntity): void { + this.logsChannelId = data.logsChannelId ?? null + this.primaryColor = data.primaryColor ?? null + this.ratingsChannelId = data.ratingsChannelId ?? null + this.robloxGroupId = data.robloxGroupId ?? null + this.robloxUsernamesInNicknames = data.robloxUsernamesInNicknames + this.suggestionsChannelId = data.suggestionsChannelId ?? null + this.supportEnabled = data.supportEnabled + this.ticketArchivesChannelId = data.ticketArchivesChannelId ?? null + this.ticketsCategoryId = data.ticketsCategoryId ?? null + this.verificationPreference = data.verificationPreference + + if (typeof data.groups !== 'undefined') { + for (const rawGroup of data.groups) { + this.groups.add(rawGroup) + } + } + + if (typeof data.panels !== 'undefined') { + for (const rawPanel of data.panels) { + this.panels.add(rawPanel) + } + } + + if (typeof data.roleBindings !== 'undefined') { + for (const rawRoleBinding of data.roleBindings) { + this.roleBindings.add(rawRoleBinding) + } + } + + if (typeof data.roleMessages !== 'undefined') { + for (const rawRoleMessage of data.roleMessages) { + this.roleMessages.add(rawRoleMessage) + } + } + + if (typeof data.tags !== 'undefined') { + for (const rawTag of data.tags) { + this.tags.add(rawTag) + } + } + + if (typeof data.tickets !== 'undefined') { + for (const rawTicket of data.tickets) { + this.tickets.add(rawTicket) + } + } + + if (typeof data.ticketTypes !== 'undefined') { + for (const rawTicketType of data.ticketTypes) { + this.ticketTypes.add(rawTicketType) + } + } + } + + public init (): void { + if (applicationConfig.apiEnabled === true) { + const announceTrainingsJobConfig = cronConfig.announceTrainingsJob + const announceTrainingsJob = this.jobFactory(announceTrainingsJobConfig.name) + cron.schedule(announceTrainingsJobConfig.expression, () => { + Promise.resolve(announceTrainingsJob.run(this)).catch(console.error) + }) + } + + const premiumMembersReportJobConfig = cronConfig.premiumMembersReportJob + const premiumMembersReportJob = this.jobFactory(premiumMembersReportJobConfig.name) + cron.schedule(premiumMembersReportJobConfig.expression, () => { + Promise.resolve(premiumMembersReportJob.run(this)).catch(console.error) + }) + } + + public get id (): string { + return this.guild.id + } + + public get logsChannel (): TextChannel | null { + return this.logsChannelId !== null + ? (this.guild.channels.cache.get(this.logsChannelId) as TextChannel | undefined) ?? null + : null + } + + public get ratingsChannel (): TextChannel | null { + return this.ratingsChannelId !== null + ? (this.guild.channels.cache.get(this.ratingsChannelId) as TextChannel | undefined) ?? null + : null + } + + public get suggestionsChannel (): TextChannel | null { + return this.suggestionsChannelId !== null + ? (this.guild.channels.cache.get(this.suggestionsChannelId) as TextChannel | undefined) ?? null + : null + } + + public get ticketArchivesChannel (): TextChannel | null { + return this.ticketArchivesChannelId !== null + ? (this.guild.channels.cache.get(this.ticketArchivesChannelId) as TextChannel | undefined) ?? null + : null + } + + public get ticketsCategory (): CategoryChannel | null { + return this.ticketsCategoryId !== null + ? (this.guild.channels.cache.get(this.ticketsCategoryId) as CategoryChannel | undefined) ?? null + : null + } + + public async fetchMembersByRobloxUsername (username: string): Promise> { + if (this.robloxUsernamesInNicknames) { + const regex = memberNameRegex(username) + return (await this.guild.members.fetch()).filter(member => regex.test(member.displayName)) + } else { + return new Collection() + } + } + + public async handleRoleMessage ( + type: 'add' | 'remove', + reaction: MessageReaction, + user: User + ): Promise { + const member = await this.guild.members.fetch(user) + for (const roleMessage of this.roleMessages.cache.values()) { + if ( + reaction.message.id === roleMessage.messageId && (reaction.emoji instanceof GuildEmoji + ? roleMessage.emoji instanceof GuildEmoji && reaction.emoji.id === roleMessage.emojiId + : !(roleMessage.emoji instanceof GuildEmoji) && reaction.emoji.name === roleMessage.emojiId) + ) { + await member.roles[type](roleMessage.roleId) + } + } + } + + public async log ( + author: User, + content: string, + options: { color?: ColorResolvable, footer?: string } = {} + ): Promise { + if (this.logsChannel !== null) { + if (author.partial) { + await author.fetch() + } + + const embed = new MessageEmbed() + .setAuthor({ name: author.tag, iconURL: author.displayAvatarURL() }) + .setColor(this.primaryColor ?? applicationConfig.defaultColor) + .setDescription(content) + if (typeof options.color !== 'undefined') { + embed.setColor(options.color) + } + if (typeof options.footer !== 'undefined') { + embed.setFooter({ text: options.footer }) + } + + return await this.logsChannel.send({ embeds: [embed] }) + } + return null + } + + public async update (data: GuildUpdateOptions): Promise { + return await this.guildContexts.update(this, data) + } +} diff --git a/src/structures/index.ts b/src/structures/index.ts index 6831e8b9..1650ec8b 100644 --- a/src/structures/index.ts +++ b/src/structures/index.ts @@ -1,15 +1,16 @@ +export * from './base' export * from './group' +export * from './guild-context' export * from './mixins' export * from './panel' -export * from './permission' export * from './tag' export * from './ticket' export * from './ticket-type' export { default as BaseStructure } from './base' export { default as ChannelGroup } from './channel-group' export { default as Group } from './group' +export { default as GuildContext } from './guild-context' export { default as Panel } from './panel' -export { default as Permission } from './permission' export { default as RoleBinding } from './role-binding' export { default as RoleGroup } from './role-group' export { default as RoleMessage } from './role-message' diff --git a/src/structures/mixins/index.ts b/src/structures/mixins/index.ts index da1fad01..4a330f31 100644 --- a/src/structures/mixins/index.ts +++ b/src/structures/mixins/index.ts @@ -1,4 +1,2 @@ -export * from './permissible' export * from './postable' -export { default as Permissible } from './permissible' export { default as Postable } from './postable' diff --git a/src/structures/mixins/permissible.ts b/src/structures/mixins/permissible.ts deleted file mode 100644 index 51705c68..00000000 --- a/src/structures/mixins/permissible.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Command, type CommandGroup } from 'discord.js-commando' -import type { Constructor, Mixin } from '../../util/util' -import type Group from '../group' -import PermissionManager from '../../managers/permission' -import type { Role } from 'discord.js' - -export type PermissibleType = Mixin - -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export default function Permissible | Constructor> ( - base: T -) { - class Permissible extends base { - public readonly aroraPermissions: PermissionManager - - private constructor (...args: any[]) { - super(...args) - - this.aroraPermissions = new PermissionManager(this as unknown as PermissibleType) - } - - public permissionFor (commandOrGroup: Command | CommandGroup, bypassGroup: boolean = false): boolean | null { - const commandPermission = this.aroraPermissions.resolve(commandOrGroup)?.allow ?? null - const groupPermission = commandOrGroup instanceof Command - ? this.aroraPermissions.resolve(commandOrGroup.group)?.allow ?? null - : null - - return (bypassGroup || commandPermission === groupPermission) - ? commandPermission - : commandPermission !== false && groupPermission !== false - } - } - - return Permissible -} diff --git a/src/structures/mixins/postable.ts b/src/structures/mixins/postable.ts index 47161d69..e4df7574 100644 --- a/src/structures/mixins/postable.ts +++ b/src/structures/mixins/postable.ts @@ -1,32 +1,50 @@ -import type { AbstractConstructor, Constructor, Mixin } from '../../util/util' -import { Constants, type Base as DiscordBaseStructure, type Guild, type Message, type TextChannel } from 'discord.js' -import type BaseStructure from '../base' +import { type AbstractConstructor, type Mixin, constants } from '../../utils' +import type { BaseStructure, GuildContext } from '..' +import { Constants, type Message, type TextChannel } from 'discord.js' +import { inject, injectable } from 'inversify' +import type { AroraClient } from '../../client' +import type { IdentifiableEntity } from '../../entities' const { PartialTypes } = Constants +const { TYPES } = constants export type PostableType = Mixin -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export default function Postable | Constructor> ( +export abstract class PostableProperties { + public abstract readonly context: GuildContext + + public abstract messageId: string | null + public abstract channelId: string | null + + public readonly channel!: TextChannel | null + public readonly message!: Message | null +} + +export default function Postable>, U extends IdentifiableEntity> ( base: T -) { +): AbstractConstructor & T { + @injectable() abstract class Postable extends base { - public abstract readonly guild: Guild + @inject(TYPES.Client) + private readonly client!: AroraClient + + public abstract readonly context: GuildContext + public abstract messageId: string | null public abstract channelId: string | null public get channel (): TextChannel | null { return this.channelId !== null - ? (this.guild.channels.cache.get(this.channelId) as TextChannel | undefined) ?? null + ? (this.context.guild.channels.cache.get(this.channelId) as TextChannel | undefined) ?? null : null } public get message (): Message | null { return this.messageId !== null && this.channel !== null ? this.channel.messages.cache.get(this.messageId) ?? - // @ts-expect-error (this.client.options.partials?.includes(PartialTypes.MESSAGE) === true - ? this.channel.messages.add({ id: this.messageId }) + // @ts-expect-error + ? this.channel.messages._add({ id: this.messageId, channel_id: this.channelId }) : null) : null } diff --git a/src/structures/panel.ts b/src/structures/panel.ts index 1fb7d45c..62ce9023 100644 --- a/src/structures/panel.ts +++ b/src/structures/panel.ts @@ -1,22 +1,32 @@ -import { type Client, type Guild, type Message, MessageEmbed, type TextChannel } from 'discord.js' +import { type Message, MessageEmbed, type TextChannel } from 'discord.js' +import type { AbstractConstructor } from '../utils' import BaseStructure from './base' +import type { GuildContext } from '.' import type { Panel as PanelEntity } from '../entities' -import Postable from './mixins/postable' +import { Postable } from './mixins' +import { injectable } from 'inversify' export interface PanelUpdateOptions { name?: string, content?: object, message?: Message } -export default class Panel extends Postable(BaseStructure) { - public readonly guild: Guild +@injectable() +export default class Panel extends Postable< +AbstractConstructor>, +PanelEntity +>(BaseStructure) { + public context!: GuildContext + public id!: number public name!: string public content!: string public messageId!: string | null public channelId!: string | null - public constructor (client: Client, data: PanelEntity, guild: Guild) { - super(client) + public constructor () { + super() + } - this.guild = guild + public setOptions (data: PanelEntity, context: GuildContext): void { + this.context = context this.setup(data) } @@ -34,15 +44,15 @@ export default class Panel extends Postable(BaseStructure) { } public async update (data: PanelUpdateOptions): Promise { - return await this.guild.panels.update(this, data) + return await this.context.panels.update(this, data) } public async delete (): Promise { - return await this.guild.panels.delete(this) + return await this.context.panels.delete(this) } public async post (channel: TextChannel): Promise { - return await this.guild.panels.post(this, channel) + return await this.context.panels.post(this, channel) } public override toString (): string { diff --git a/src/structures/permission.ts b/src/structures/permission.ts deleted file mode 100644 index 0a47c598..00000000 --- a/src/structures/permission.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { Client, Role } from 'discord.js' -import BaseStructure from './base' -import type { Permission as PermissionEntity } from '../entities' -import type RoleGroup from './role-group' - -export interface PermissionUpdateOptions { allow?: boolean } - -export default class Permission extends BaseStructure { - public readonly permissible: Role | RoleGroup - public id!: number - public allow!: boolean - public commandId!: number - - public constructor (client: Client, data: PermissionEntity, permissible: Role | RoleGroup) { - super(client) - - this.permissible = permissible - - this.setup(data) - } - - public setup (data: PermissionEntity): void { - this.id = data.id - this.allow = data.allow - this.commandId = data.commandId - } - - public async update (data: PermissionUpdateOptions): Promise { - return await this.permissible.aroraPermissions.update(this, data) - } - - public async delete (): Promise { - return await this.permissible.aroraPermissions.delete(this) - } -} diff --git a/src/structures/role-binding.ts b/src/structures/role-binding.ts index 7042839d..ac0ffaa2 100644 --- a/src/structures/role-binding.ts +++ b/src/structures/role-binding.ts @@ -1,19 +1,21 @@ -import type { Client, Guild, Role } from 'discord.js' import BaseStructure from './base' +import type GuildContext from './guild-context' +import type { Role } from 'discord.js' import type { RoleBinding as RoleBindingEntity } from '../entities' +import { injectable } from 'inversify' + +@injectable() +export default class RoleBinding extends BaseStructure { + public context!: GuildContext -export default class RoleBinding extends BaseStructure { - public readonly guild: Guild public id!: number public roleId!: string public robloxGroupId!: number public min!: number public max!: number | null - public constructor (client: Client, data: RoleBindingEntity, guild: Guild) { - super(client) - - this.guild = guild + public setOptions (data: RoleBindingEntity, context: GuildContext): void { + this.context = context this.setup(data) } @@ -27,10 +29,10 @@ export default class RoleBinding extends BaseStructure { } public get role (): Role | null { - return this.guild.roles.cache.get(this.roleId) ?? null + return this.context.guild.roles.cache.get(this.roleId) ?? null } public async delete (): Promise { - return await this.guild.roleBindings.delete(this) + return await this.context.roleBindings.delete(this) } } diff --git a/src/structures/role-group.ts b/src/structures/role-group.ts index ed6baaa8..fa7563c6 100644 --- a/src/structures/role-group.ts +++ b/src/structures/role-group.ts @@ -1,16 +1,28 @@ -import type { Client, Guild } from 'discord.js' +import { type ManagerFactory, constants } from '../utils' +import { inject, injectable } from 'inversify' import Group from './group' import type { Group as GroupEntity } from '../entities' -import GroupRoleManager from '../managers/group-role' -import Permissible from './mixins/permissible' +import type { GroupRoleManager } from '../managers' +import type { GuildContext } from '.' +import type { Role } from 'discord.js' + +const { TYPES } = constants + +@injectable() +export default class RoleGroup extends Group { + @inject(TYPES.ManagerFactory) + private readonly managerFactory!: ManagerFactory -export default class RoleGroup extends Permissible(Group) { public _roles: string[] - public constructor (client: Client, data: GroupEntity, guild: Guild) { - super(client, data, guild) + public constructor () { + super() this._roles = [] + } + + public override setOptions (data: GroupEntity, context: GuildContext): void { + super.setOptions(data, context) this.setup(data) } @@ -18,18 +30,12 @@ export default class RoleGroup extends Permissible(Group) { public override setup (data: GroupEntity): void { super.setup(data) - if (typeof data.permissions !== 'undefined') { - for (const rawPermission of data.permissions) { - this.aroraPermissions.add(rawPermission) - } - } - if (typeof data.roles !== 'undefined') { this._roles = data.roles.map(role => role.id) } } public get roles (): GroupRoleManager { - return new GroupRoleManager(this) + return this.managerFactory('GroupRoleManager')(this) } } diff --git a/src/structures/role-message.ts b/src/structures/role-message.ts index 46c1ec5c..5fe35132 100644 --- a/src/structures/role-message.ts +++ b/src/structures/role-message.ts @@ -1,10 +1,18 @@ -import type { Client, Guild, GuildEmoji, Role } from 'discord.js' +import type { GuildEmoji, Role } from 'discord.js' +import type { AbstractConstructor } from '../utils' import BaseStructure from './base' -import Postable from './mixins/postable' +import type { GuildContext } from '.' +import { Postable } from './mixins' import type { RoleMessage as RoleMessageEntity } from '../entities' +import { injectable } from 'inversify' + +@injectable() +export default class RoleMessage extends Postable< +AbstractConstructor>, +RoleMessageEntity +>(BaseStructure) { + public context!: GuildContext -export default class RoleMessage extends Postable(BaseStructure) { - public readonly guild: Guild public id!: number public roleId!: string public messageId!: string | null @@ -13,10 +21,8 @@ export default class RoleMessage extends Postable(BaseStructure) { private _emoji!: string | null private _emojiId!: string | null - public constructor (client: Client, data: RoleMessageEntity, guild: Guild) { - super(client) - - this.guild = guild + public setOptions (data: RoleMessageEntity, context: GuildContext): void { + this.context = context this.setup(data) } @@ -32,7 +38,7 @@ export default class RoleMessage extends Postable(BaseStructure) { public get emoji (): GuildEmoji | string | null { return this._emojiId !== null - ? this.guild.emojis.cache.get(this._emojiId) ?? null + ? this.context.guild.emojis.cache.get(this._emojiId) ?? null : this._emoji } @@ -41,10 +47,10 @@ export default class RoleMessage extends Postable(BaseStructure) { } public get role (): Role | null { - return this.guild.roles.cache.get(this.roleId) ?? null + return this.context.guild.roles.cache.get(this.roleId) ?? null } public async delete (): Promise { - return await this.guild.roleMessages.delete(this) + return await this.context.roleMessages.delete(this) } } diff --git a/src/structures/tag-name.ts b/src/structures/tag-name.ts index eb13ebcf..6b484713 100644 --- a/src/structures/tag-name.ts +++ b/src/structures/tag-name.ts @@ -1,14 +1,15 @@ import BaseStructure from './base' -import type { Client } from 'discord.js' -import type Tag from './tag' +import type { Tag } from '.' import type { TagName as TagNameEntity } from '../entities' +import { injectable } from 'inversify' + +@injectable() +export default class TagName extends BaseStructure { + public tag!: Tag -export default class TagName extends BaseStructure { - public readonly tag: Tag public name!: string - public constructor (client: Client, data: TagNameEntity, tag: Tag) { - super(client) + public setOptions (data: TagNameEntity, tag: Tag): void { this.tag = tag this.setup(data) @@ -18,6 +19,10 @@ export default class TagName extends BaseStructure { this.name = data.name } + public get id (): string { + return this.name + } + public async delete (): Promise { return await this.tag.names.delete(this) } diff --git a/src/structures/tag.ts b/src/structures/tag.ts index 74dfb74f..74bad800 100644 --- a/src/structures/tag.ts +++ b/src/structures/tag.ts @@ -1,21 +1,32 @@ -import { type Client, type Guild, MessageEmbed } from 'discord.js' +import type { GuildContext, TagName } from '.' +import { type ManagerFactory, constants } from '../utils' +import { inject, injectable } from 'inversify' import BaseStructure from './base' +import { MessageEmbed } from 'discord.js' import type { Tag as TagEntity } from '../entities' -import TagTagNameManager from '../managers/tag-tag-name' +import type { TagTagNameManager } from '../managers' + +const { TYPES } = constants export interface TagUpdateOptions { content?: string | object } -export default class Tag extends BaseStructure { - public readonly guild: Guild +@injectable() +export default class Tag extends BaseStructure { + public context!: GuildContext + public readonly names: TagTagNameManager + public id!: number public _content!: string - public constructor (client: Client, data: TagEntity, guild: Guild) { - super(client) + public constructor (@inject(TYPES.ManagerFactory) managerFactory: ManagerFactory) { + super() + + this.names = managerFactory('TagTagNameManager')(this) + } - this.guild = guild - this.names = new TagTagNameManager(this) + public setOptions (data: TagEntity, context: GuildContext): void { + this.context = context this.setup(data) } @@ -40,11 +51,11 @@ export default class Tag extends BaseStructure { } public async update (data: TagUpdateOptions): Promise { - return await this.guild.tags.update(this, data) + return await this.context.tags.update(this, data) } public async delete (): Promise { - return await this.guild.tags.delete(this) + return await this.context.tags.delete(this) } public override toString (): string { diff --git a/src/structures/ticket-type.ts b/src/structures/ticket-type.ts index a192bfb6..4667e4c6 100644 --- a/src/structures/ticket-type.ts +++ b/src/structures/ticket-type.ts @@ -1,12 +1,20 @@ -import type { Client, Guild, GuildEmoji, Message } from 'discord.js' +import type { GuildEmoji, Message } from 'discord.js' +import type { AbstractConstructor } from '../utils' import BaseStructure from './base' -import Postable from './mixins/postable' +import type { GuildContext } from '.' +import { Postable } from './mixins' import type { TicketType as TicketTypeEntity } from '../entities' +import { injectable } from 'inversify' export interface TicketTypeUpdateOptions { name?: string } -export default class TicketType extends Postable(BaseStructure) { - public guild: Guild +@injectable() +export default class TicketType extends Postable< +AbstractConstructor>, +TicketTypeEntity +>(BaseStructure) { + public context!: GuildContext + public id!: number public name!: string public messageId!: string | null @@ -15,10 +23,8 @@ export default class TicketType extends Postable(BaseStructure) { private _emoji!: string | null private _emojiId!: string | null - public constructor (client: Client, data: TicketTypeEntity, guild: Guild) { - super(client) - - this.guild = guild + public setOptions (data: TicketTypeEntity, context: GuildContext): void { + this.context = context this.setup(data) } @@ -34,7 +40,7 @@ export default class TicketType extends Postable(BaseStructure) { public get emoji (): GuildEmoji | string | null { return this._emojiId !== null - ? this.guild.emojis.cache.get(this._emojiId) ?? null + ? this.context.guild.emojis.cache.get(this._emojiId) ?? null : this._emoji } @@ -43,15 +49,15 @@ export default class TicketType extends Postable(BaseStructure) { } public async update (data: TicketTypeUpdateOptions): Promise { - return await this.guild.ticketTypes.update(this, data) + return await this.context.ticketTypes.update(this, data) } public async delete (): Promise { - return await this.guild.ticketTypes.delete(this) + return await this.context.ticketTypes.delete(this) } public async link (message: Message, emoji: GuildEmoji): Promise { - return await this.guild.ticketTypes.link(this, message, emoji) + return await this.context.ticketTypes.link(this, message, emoji) } public override toString (): string { diff --git a/src/structures/ticket.ts b/src/structures/ticket.ts index 7919468f..f92ba909 100644 --- a/src/structures/ticket.ts +++ b/src/structures/ticket.ts @@ -1,26 +1,31 @@ import { - type Client, Collection, Constants, - type Guild, type GuildMember, type Message, MessageAttachment, MessageEmbed, type PartialGuildMember, - type TextChannel + type TextChannel, + type TextChannelResolvable } from 'discord.js' -import { discordService, userService } from '../services' -import { timeUtil, util } from '../util' +import type { GuildContext, TicketType } from '.' +import { type ManagerFactory, constants, timeUtil, util } from '../utils' +import { discordService, userService, verificationService } from '../services' +import { inject, injectable } from 'inversify' +import type { AroraClient } from '../client' import BaseStructure from './base' -import type { TextChannelResolvable } from '../managers' import type { Ticket as TicketEntity } from '../entities' -import TicketGuildMemberManager from '../managers/ticket-guild-member' -import type TicketType from './ticket-type' +import type { TicketGuildMemberManager } from '../managers' import applicationConfig from '../configs/application' import pluralize from 'pluralize' import { stripIndents } from 'common-tags' +const { PartialTypes } = Constants +const { TYPES } = constants +const { getDate, getTime } = timeUtil +const { makeCommaSeparatedString } = util + export interface NewTicket extends Ticket { authorId: string typeId: number @@ -30,12 +35,16 @@ export interface NewTicket extends Ticket { export interface TicketUpdateOptions { channel?: TextChannelResolvable } -const { PartialTypes } = Constants -const { getDate, getTime } = timeUtil -const { makeCommaSeparatedString } = util +@injectable() +export default class Ticket extends BaseStructure { + @inject(TYPES.Client) + private readonly client!: AroraClient + + @inject(TYPES.ManagerFactory) + private readonly managerFactory!: ManagerFactory + + public context!: GuildContext -export default class Ticket extends BaseStructure { - public readonly guild: Guild public id!: number public channelId!: string public guildId!: string @@ -44,13 +53,16 @@ export default class Ticket extends BaseStructure { public _moderators: string[] public timeout: NodeJS.Timeout | null - public constructor (client: Client, data: TicketEntity, guild: Guild) { - super(client) + public constructor () { + super() - this.guild = guild this._moderators = [] this.timeout = null + } + + public setOptions (data: TicketEntity, context: GuildContext): void { + this.context = context this.setup(data) } @@ -69,23 +81,24 @@ export default class Ticket extends BaseStructure { public get author (): GuildMember | PartialGuildMember | null { return this.authorId !== null - ? this.guild.members.cache.get(this.authorId) ?? + ? this.context.guild.members.cache.get(this.authorId) ?? (this.client.options.partials?.includes(PartialTypes.GUILD_MEMBER) === true - ? this.guild.members.add({ user: { id: this.authorId } }) + // @ts-expect-error + ? this.context.guild.members._add({ user: { id: this.authorId } }) : null) : null } public get channel (): TextChannel { - return this.guild.channels.cache.get(this.channelId) as TextChannel + return this.context.guild.channels.cache.get(this.channelId) as TextChannel } public get type (): TicketType | null { - return this.typeId !== null ? this.guild.ticketTypes.cache.get(this.typeId) ?? null : null + return this.typeId !== null ? this.context.ticketTypes.cache.get(this.typeId) ?? null : null } public get moderators (): TicketGuildMemberManager { - return new TicketGuildMemberManager(this) + return this.managerFactory('TicketGuildMemberManager')(this) } public async populateChannel (): Promise { @@ -98,52 +111,52 @@ export default class Ticket extends BaseStructure { const readableDate = getDate(date) const readableTime = getTime(date) const ticketInfoEmbed = new MessageEmbed() - .setColor(this.guild.primaryColor ?? applicationConfig.defaultColor) + .setColor(this.context.primaryColor ?? applicationConfig.defaultColor) .setTitle('Ticket Information') .setDescription(stripIndents` Username: \`${robloxUsername ?? 'unknown'}\` User ID: \`${robloxId ?? 'unknown'}\` Start time: \`${readableDate} ${readableTime}\` `) - .setFooter(`Ticket ID: ${this.id} | ${this.type.name}`) - await this.channel?.send(this.author.toString(), ticketInfoEmbed) + .setFooter({ text: `Ticket ID: ${this.id} | ${this.type.name}` }) + await this.channel?.send({ content: this.author.toString(), embeds: [ticketInfoEmbed] }) - const additionalInfoPanel = this.guild.panels.resolve('additionalTicketInfoPanel') + const additionalInfoPanel = this.context.panels.resolve('additionalTicketInfoPanel') if (additionalInfoPanel !== null) { - await this.channel?.send(additionalInfoPanel.embed) + await this.channel?.send({ embeds: [additionalInfoPanel.embed] }) } } public async close (message: string, success: boolean, color?: number): Promise { - if (this.guild.ticketArchivesChannel !== null) { - await this.guild.ticketArchivesChannel.send(await this.fetchArchiveAttachment()) + if (this.context.ticketArchivesChannel !== null) { + await this.context.ticketArchivesChannel.send({ files: [await this.fetchArchiveAttachment()] }) } await this.channel.delete() if (this.author !== null) { const embed = new MessageEmbed() .setColor(color ?? (success ? 0x00ff00 : 0xff0000)) - .setAuthor(this.client.user?.username, this.client.user?.displayAvatarURL()) + .setAuthor({ name: this.client.user.username, iconURL: this.client.user.displayAvatarURL() }) .setTitle(message) - const sent = await this.client.send(this.author, embed) !== null + const sent = await this.client.send(this.author, { embeds: [embed] }) !== null - if (sent && success && this.guild.ratingsChannel !== null) { + if (sent && success && this.context.ratingsChannel !== null) { const rating = await this.requestRating() if (rating !== null) { await this.logRating(rating) const embed = new MessageEmbed() - .setColor(this.guild.primaryColor ?? applicationConfig.defaultColor) - .setAuthor(this.client.user?.username, this.client.user?.displayAvatarURL()) + .setColor(this.context.primaryColor ?? applicationConfig.defaultColor) + .setAuthor({ name: this.client.user.username, iconURL: this.client.user.displayAvatarURL() }) .setTitle('Rating submitted') .setDescription('Thank you!') - await this.client.send(this.author, embed) + await this.client.send(this.author, { embeds: [embed] }) } else { const embed = new MessageEmbed() - .setColor(this.guild.primaryColor ?? applicationConfig.defaultColor) - .setAuthor(this.client.user?.username, this.client.user?.displayAvatarURL()) + .setColor(this.context.primaryColor ?? applicationConfig.defaultColor) + .setAuthor({ name: this.client.user.username, iconURL: this.client.user.displayAvatarURL() }) .setTitle('No rating submitted') - await this.client.send(this.author, embed) + await this.client.send(this.author, { embeds: [embed] }) } } } @@ -157,10 +170,10 @@ export default class Ticket extends BaseStructure { } const embed = new MessageEmbed() - .setColor(this.guild.primaryColor ?? applicationConfig.defaultColor) - .setAuthor(this.client.user?.username, this.client.user?.displayAvatarURL()) + .setColor(this.context.primaryColor ?? applicationConfig.defaultColor) + .setAuthor({ name: this.client.user.username, iconURL: this.client.user.displayAvatarURL() }) .setTitle('How would you rate the support you received?') - const message = await this.client.send(this.author, embed) as Message | null + const message = await this.client.send(this.author, { embeds: [embed] }) if (message !== null) { const options = [] @@ -169,13 +182,13 @@ export default class Ticket extends BaseStructure { } const rating = await discordService.prompt(this.author as GuildMember, message, options) - return rating?.name.substring(0, 1) ?? null + return rating?.name?.substring(0, 1) ?? null } return null } public async logRating (rating: string): Promise { - if (this.guild.ratingsChannel === null) { + if (this.context.ratingsChannel === null) { return null } @@ -191,15 +204,18 @@ export default class Ticket extends BaseStructure { } const embed = new MessageEmbed() - .setColor(this.guild.primaryColor ?? applicationConfig.defaultColor) - .setAuthor(this.author?.user?.tag ?? this.authorId ?? 'unknown', this.author?.user?.displayAvatarURL()) + .setColor(this.context.primaryColor ?? applicationConfig.defaultColor) + .setAuthor({ + name: this.author?.user?.tag ?? this.authorId ?? 'unknown', + iconURL: this.author?.user?.displayAvatarURL() + }) .setTitle('Ticket Rating') .setDescription(stripIndents` ${pluralize('Moderator', this.moderators.cache.size)}: ${moderatorsString} Rating: **${rating}** `) - .setFooter(`Ticket ID: ${this.id}`) - return await this.guild.ratingsChannel.send(embed) + .setFooter({ text: `Ticket ID: ${this.id}` }) + return await this.context.ratingsChannel.send({ embeds: [embed] }) } public async fetchArchiveAttachment (): Promise { @@ -224,8 +240,10 @@ export default class Ticket extends BaseStructure { const messages = await this.fetchMessages() const firstMessage = messages.first() - if (typeof firstMessage !== 'undefined' && - (firstMessage.author.id !== this.client.user?.id || firstMessage.content !== this.author?.toString())) { + if ( + typeof firstMessage !== 'undefined' && + (firstMessage.author.id !== this.client.user.id || firstMessage.content !== this.author?.toString()) + ) { output += '...\n\n' output += '='.repeat(100) + '\n\n' } @@ -254,12 +272,17 @@ export default class Ticket extends BaseStructure { public async fetchAuthorData (): Promise<{ robloxId: number | null, robloxUsername: string | null }> { let robloxId = null let robloxUsername = null - try { - robloxId = this.author?.robloxId ?? (await this.author?.fetchVerificationData())?.robloxId ?? null - robloxUsername = this.author?.robloxUsername ?? (robloxId !== null - ? (await userService.getUser(robloxId)).name - : null) - } catch {} + if (this.author !== null) { + try { + const verificationData = await verificationService.fetchVerificationData(this.author.id) + if (verificationData !== null) { + robloxId = verificationData.robloxId + robloxUsername = robloxId !== null + ? (await userService.getUser(robloxId)).name + : null + } + } catch {} + } return { robloxId, robloxUsername } } @@ -275,11 +298,11 @@ export default class Ticket extends BaseStructure { } public async update (data: TicketUpdateOptions): Promise { - return await this.guild.tickets.update(this, data) + return await this.context.tickets.update(this, data) } public async delete (): Promise { - return await this.guild.tickets.delete(this) + return await this.context.tickets.delete(this) } public async onMessage (message: Message): Promise { @@ -288,7 +311,7 @@ export default class Ticket extends BaseStructure { } if (message.member.id === this.authorId) { if (this.timeout !== null) { - this.client.clearTimeout(this.timeout) + clearTimeout(this.timeout) this.timeout = null } } else { diff --git a/src/subscribers/permission.ts b/src/subscribers/permission.ts deleted file mode 100644 index e95200f1..00000000 --- a/src/subscribers/permission.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { type EntitySubscriberInterface, EventSubscriber, type InsertEvent } from 'typeorm' -import { Permission, Role } from '../entities' - -@EventSubscriber() -export class PermissionSubscriber implements EntitySubscriberInterface { - public listenTo (): Function { - return Permission - } - - public async beforeInsert (event: InsertEvent): Promise { - const roleRepository = event.manager.getRepository(Role) - if (event.entity.roleId != null) { - const entity = roleRepository.create({ id: event.entity.roleId, guildId: event.queryRunner.data.guildId }) - if (typeof await roleRepository.findOne(entity) === 'undefined') { - await roleRepository.save(entity) - } - } - } -} diff --git a/src/types/base.ts b/src/types/base.ts deleted file mode 100644 index 8fba59c5..00000000 --- a/src/types/base.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { type Argument, ArgumentType, type CommandoClient, type CommandoMessage, util } from 'discord.js-commando' -import { type Collection, Util } from 'discord.js' -import type { BaseStructure } from '../structures' -import type { Constructor } from '../util/util' -import lodash from 'lodash' -import pluralize from 'pluralize' - -const { disambiguation } = util -const { escapeMarkdown } = Util - -export interface IdentifiableStructure extends BaseStructure { - id: number - toString: () => string -} - -export abstract class FilterableArgumentType { - public abstract filterExact: (search: string) => (structure: T) => boolean - public abstract filterInexact: (search: string) => (structure: T) => boolean -} - -export default class BaseArgumentType extends ArgumentType implements -FilterableArgumentType { - protected readonly holds: Constructor - - private readonly managerName: string - private readonly label: string - - public constructor (client: CommandoClient, holds: Constructor, managerName?: string) { - let id = lodash.kebabCase(holds.name) - if (client.registry.types.has(id)) { - id = `arora-${id}` - } - super(client, id) - - this.holds = holds - this.managerName = typeof managerName === 'undefined' - ? pluralize(lodash.camelCase(holds.name)) - : managerName - - this.label = this.id.replace(/-/g, ' ') - } - - public validate (val: string, msg: CommandoMessage, arg: Argument): boolean | string | Promise { - if (typeof msg.guild === 'undefined') { - return false - } - const id = parseInt(val) - if (!isNaN(id)) { - // @ts-expect-error - const structure = msg.guild[this.managerName].cache.get(id) - if (typeof structure === 'undefined') { - return false - } - return arg.oneOf?.includes(structure.id) ?? true - } - const search = val.toLowerCase() - // @ts-expect-error - let structures: Collection = msg.guild[this.managerName].cache - .filter(this.filterInexact(search)) - if (structures.size === 0) { - return false - } - if (structures.size === 1) { - return arg.oneOf?.includes(String((structures.first() as T).id)) ?? true - } - const exactStructures = structures.filter(this.filterExact(search)) - if (exactStructures.size === 1) { - return arg.oneOf?.includes(String((exactStructures.first() as T).id)) ?? true - } - if (exactStructures.size > 0) { - structures = exactStructures - } - return structures.size <= 15 - ? `${disambiguation(structures.map(structure => escapeMarkdown(structure.toString())), pluralize(this.label), undefined)}\n` - : `Multiple ${pluralize(this.label)} found. Please be more specific.` - } - - public parse (val: string, msg: CommandoMessage): T | null | false { - if (typeof msg.guild === 'undefined') { - return false - } - const id = parseInt(val) - if (!isNaN(id)) { - // @ts-expect-error - return msg.guild[this.managerName].cache.get(id) ?? null - } - const search = val.toLowerCase() - // @ts-expect-error - const structures = msg.guild[this.managerName].cache.filter(this.filterInexact(search)) - if (structures.size === 0) { - return null - } - if (structures.size === 1) { - return structures.first() - } - const exactStructures = structures.filter(this.filterExact(search)) - if (exactStructures.size === 1) { - return exactStructures.first() - } - return null - } - - public filterExact (search: string): (structure: T) => boolean { - return structure => structure instanceof this.holds && structure.toString().toLowerCase() === search - } - - public filterInexact (search: string): (structure: T) => boolean { - return structure => structure instanceof this.holds && structure.toString().toLowerCase().includes(search) - } -} diff --git a/src/types/channel-group.ts b/src/types/channel-group.ts deleted file mode 100644 index 1aaf901d..00000000 --- a/src/types/channel-group.ts +++ /dev/null @@ -1,9 +0,0 @@ -import BaseArgumentType from './base' -import { ChannelGroup } from '../structures' -import type { CommandoClient } from 'discord.js-commando' - -export default class ChannelGroupArgumentType extends BaseArgumentType { - public constructor (client: CommandoClient) { - super(client, ChannelGroup, 'groups') - } -} diff --git a/src/types/group.ts b/src/types/group.ts deleted file mode 100644 index 50f8cf47..00000000 --- a/src/types/group.ts +++ /dev/null @@ -1,9 +0,0 @@ -import BaseArgumentType from './base' -import type { CommandoClient } from 'discord.js-commando' -import { Group } from '../structures' - -export default class GroupArgumentType extends BaseArgumentType { - public constructor (client: CommandoClient) { - super(client, Group) - } -} diff --git a/src/types/json-object.ts b/src/types/json-object.ts deleted file mode 100644 index 4e50e00f..00000000 --- a/src/types/json-object.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { type Argument, ArgumentType, type CommandoClient, type CommandoMessage } from 'discord.js-commando' - -export default class JsonObjectArgumentType extends ArgumentType { - public constructor (client: CommandoClient) { - super(client, 'json-object') - } - - public validate (val: string, _msg: CommandoMessage, arg: Argument): boolean | string { - try { - JSON.parse(val) - } catch (err) { - return false - } - if (arg.min != null && val.length < arg.min) { - return `Please keep the ${arg.label} above or exactly ${arg.min} characters.` - } - if (arg.max != null && val.length > arg.max) { - return `Please keep the ${arg.label} below or exactly ${arg.max} characters.` - } - return true - } - - public parse (val: string): any { - return JSON.parse(val) - } -} diff --git a/src/types/message.ts b/src/types/message.ts deleted file mode 100644 index 2e615406..00000000 --- a/src/types/message.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { ArgumentType, type CommandoClient, type CommandoMessage } from 'discord.js-commando' -import type { Message } from 'discord.js' - -const messageUrlRegex = /^https:\/\/discord.com\/channels\/([0-9]+|@me)\/[0-9]+\/[0-9]+$/ -const endpointUrl = 'https://discord.com/channels/' - -export default class MessageArgumentType extends ArgumentType { - public constructor (client: CommandoClient) { - super(client, 'message') - } - - public async validate (val: string, msg: CommandoMessage): Promise { - const match = val.match(messageUrlRegex) - if (match === null) { - return false - } - const [guildId, channelId, messageId] = match[0] - .replace(endpointUrl, '') - .split('/') - const channel = guildId === msg.guild.id - ? msg.guild.channels.cache.get(channelId) - : guildId === '@me' - ? msg.channel - : undefined - if (typeof channel === 'undefined') { - return false - } - try { - return channel.isText() && typeof await channel.messages.fetch(messageId) !== 'undefined' - } catch { - return false - } - } - - public parse (val: string, msg: CommandoMessage): Message | null { - const match = val.match(messageUrlRegex) - if (match === null) { - return null - } - const [, channelId, messageId] = match[0] - .replace(endpointUrl, '') - .split('/') - const channel = typeof msg.guild !== 'undefined' ? msg.guild.channels.cache.get(channelId) : msg.channel - return typeof channel !== 'undefined' && channel.isText() - ? channel.messages.cache.get(messageId) ?? null - : null - } -} diff --git a/src/types/panel.ts b/src/types/panel.ts deleted file mode 100644 index 8c670672..00000000 --- a/src/types/panel.ts +++ /dev/null @@ -1,9 +0,0 @@ -import BaseArgumentType from './base' -import type { CommandoClient } from 'discord.js-commando' -import { Panel } from '../structures' - -export default class PanelArgumentType extends BaseArgumentType { - public constructor (client: CommandoClient) { - super(client, Panel) - } -} diff --git a/src/types/roblox-user.ts b/src/types/roblox-user.ts deleted file mode 100644 index a4d72e98..00000000 --- a/src/types/roblox-user.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { type Argument, ArgumentType, type CommandoClient, type CommandoMessage } from 'discord.js-commando' -import type { GuildMember } from 'discord.js' -import { userService } from '../services' - -export interface RobloxUser { id: number, username: string | null } - -export default class RobloxUserArgumentType extends ArgumentType { - protected readonly cache: Map - - public constructor (client: CommandoClient) { - super(client, 'roblox-user') - - this.cache = new Map() - } - - public async validate (val: string, msg: CommandoMessage, arg: Argument): Promise { - const key = `${msg.guild.id}_${val}` - - // val is undefined when the argument's default is set to "self" and no - // argument input is given (see isEmpty). This patch was done in order to - // allow validation of the default value; the message author's Roblox user. - if (typeof val === 'undefined') { - const verificationData = await (msg.member as GuildMember).fetchVerificationData() - if (verificationData !== null) { - return this.validateAndSet(arg, key, verificationData.robloxId, verificationData.robloxUsername) - } - return false - } - - const match = val.match(/^(?:<@!?)?([0-9]+)>?$/) - if (match !== null) { - try { - const member = await msg.guild.members.fetch(await msg.client.users.fetch(match[1])) - if (!member.user.bot) { - const verificationData = await member.fetchVerificationData() - if (verificationData !== null) { - return this.validateAndSet(arg, key, verificationData.robloxId, verificationData.robloxUsername) - } - } - } catch {} - - const id = parseInt(match[0].match(/^(\d+)$/)?.[1] ?? '') - if (!isNaN(id)) { - try { - const username = (await userService.getUser(id)).name - return this.validateAndSet(arg, key, id, username) - } catch {} - } else { - return false - } - } - - const search = val.toLowerCase() - const members = msg.guild?.members.cache.filter(memberFilterExact(search)) - if (members?.size === 1) { - const member = members.first() - if (typeof member !== 'undefined' && !member.user.bot) { - const verificationData = await member.fetchVerificationData() - if (verificationData !== null) { - return this.validateAndSet(arg, key, verificationData.robloxId, verificationData.robloxUsername) - } - } - } - - if (!search.includes(' ')) { - try { - const id = await userService.getIdFromUsername(search) - return this.validateAndSet(arg, key, id, search) - } catch {} - } - return false - } - - public parse (val: string, msg: CommandoMessage): RobloxUser | null { - const key = `${msg.guild.id}_${val}` - const result = this.cache.get(key) - this.cache.delete(key) - return result ?? null - } - - public override isEmpty (val: string, msg: CommandoMessage, arg: Argument): boolean { - return arg.default === 'self' ? false : super.isEmpty(val, msg, arg) - } - - private validateAndSet (arg: Argument, key: string, id: number, username: string | null): boolean { - if (arg.oneOf?.includes(String(id)) ?? true) { - this.cache.set(key, { id, username }) - return true - } - return false - } -} - -function memberFilterExact (search: string): (member: GuildMember) => boolean { - return (member: GuildMember) => member.user.username.toLowerCase() === search || - (member.nickname !== null && member.nickname.toLowerCase() === search) || - `${member.user.username.toLowerCase()}#${member.user.discriminator}` === search -} diff --git a/src/types/role-binding.ts b/src/types/role-binding.ts deleted file mode 100644 index 1b6167fb..00000000 --- a/src/types/role-binding.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { Argument, CommandoClient, CommandoMessage } from 'discord.js-commando' -import BaseArgumentType from './base' -import { RoleBinding } from '../structures' - -export default class RoleBindingArgumentType extends BaseArgumentType { - public constructor (client: CommandoClient) { - super(client, RoleBinding) - } - - public override async validate (val: string, msg: CommandoMessage, arg: Argument): Promise { - if (typeof msg.guild === 'undefined') { - return false - } - await msg.guild.roleBindings.fetch() - return await super.validate(val, msg, arg) - } -} diff --git a/src/types/role-group.ts b/src/types/role-group.ts deleted file mode 100644 index 73131a56..00000000 --- a/src/types/role-group.ts +++ /dev/null @@ -1,9 +0,0 @@ -import BaseArgumentType from './base' -import type { CommandoClient } from 'discord.js-commando' -import { RoleGroup } from '../structures' - -export default class RoleGroupArgumentType extends BaseArgumentType { - public constructor (client: CommandoClient) { - super(client, RoleGroup, 'groups') - } -} diff --git a/src/types/role-message.ts b/src/types/role-message.ts deleted file mode 100644 index 7b8ec439..00000000 --- a/src/types/role-message.ts +++ /dev/null @@ -1,9 +0,0 @@ -import BaseArgumentType from './base' -import type { CommandoClient } from 'discord.js-commando' -import { RoleMessage } from '../structures' - -export default class RoleMessageArgumentType extends BaseArgumentType { - public constructor (client: CommandoClient) { - super(client, RoleMessage) - } -} diff --git a/src/types/tag.ts b/src/types/tag.ts deleted file mode 100644 index 3c708998..00000000 --- a/src/types/tag.ts +++ /dev/null @@ -1,19 +0,0 @@ -import BaseArgumentType, { type FilterableArgumentType } from './base' -import { type BaseStructure, Tag } from '../structures' -import type { CommandoClient } from 'discord.js-commando' - -export default class TagArgumentType extends BaseArgumentType implements FilterableArgumentType { - public constructor (client: CommandoClient) { - super(client, Tag) - } - - public override filterExact (search: string): (structure: BaseStructure) => boolean { - return structure => structure instanceof this.holds && structure.names.resolve(search) !== null - } - - public override filterInexact (search: string): (structure: BaseStructure) => boolean { - return structure => structure instanceof this.holds && structure.names.cache.some(tagName => ( - tagName.name.toLowerCase().includes(search) - )) - } -} diff --git a/src/types/ticket-type.ts b/src/types/ticket-type.ts deleted file mode 100644 index 355fc30c..00000000 --- a/src/types/ticket-type.ts +++ /dev/null @@ -1,9 +0,0 @@ -import BaseArgumentType from './base' -import type { CommandoClient } from 'discord.js-commando' -import { TicketType } from '../structures' - -export default class TicketTypeArgumentType extends BaseArgumentType { - public constructor (client: CommandoClient) { - super(client, TicketType) - } -} diff --git a/src/util/argument.ts b/src/util/argument.ts deleted file mode 100644 index 7f8258ca..00000000 --- a/src/util/argument.ts +++ /dev/null @@ -1,163 +0,0 @@ -import type { Argument, ArgumentType, CommandoMessage } from 'discord.js-commando' -import { type Enum, getEnumKeys } from './util' -import { MessageMentions } from 'discord.js' -import { getDateInfo } from './time' - -type Validator = -(this: ArgumentType, val: string, msg: CommandoMessage, arg: Argument) => boolean | string | Promise -// A test returning true means the validation failed. -type ValidatorTest = -((this: ArgumentType, val: string, msg: CommandoMessage, arg: Argument) => boolean | Promise) | -((this: ArgumentType, val: string, msg?: CommandoMessage, arg?: Argument) => boolean | Promise) - -const dateRegex = /(([0-2]?[0-9]|3[0-1])[-](0?[1-9]|1[0-2])[-][0-9]{4})/ -const timeRegex = /^(2[0-3]|[0-1]?[\d]):[0-5][\d]$/ - -export const urlRegex = /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/gi - -export function validators ( - steps: Array = [] -): ((val: string, msg: CommandoMessage) => Promise) { - return async function (this: Argument, val, msg) { - const valid = await this.type.validate(val, msg, this) - if (valid !== true) { - return valid - } - - if (steps.length === 0) { - return true - } - for (const step of steps) { - if (step instanceof Array) { - const validations = step.map(validator => validator.call(this.type, val, msg, this)) - const results = await Promise.allSettled(validations) - if (results.some(result => result.status === 'fulfilled' && result.value === true)) { - continue - } - - const errors = results - .filter(result => result.status === 'fulfilled' && typeof result.value === 'string') - .map(result => (result as PromiseFulfilledResult).value) - if (errors.length > 0) { - return errors.join('\n') - } - throw (results.find(result => result.status === 'rejected') as PromiseRejectedResult).reason - } else { - const valid = await step.call(this.type, val, msg, this) - if (valid !== true) { - return valid - } - } - } - return true - } -} - -function makeValidator ( - test: ValidatorTest, - message: string -): Validator { - return async function ( - this: ArgumentType, - val, - msg, - arg - ) { - return !(await test.call(this, val, msg, arg)) || `\`${arg.label}\` ${message}.` - } -} - -export const noChannels = makeValidator( - (val: string) => MessageMentions.CHANNELS_PATTERN.test(val), - 'cannot contain channels' -) - -export const noNumber = makeValidator((val: string) => !isNaN(parseInt(val)), 'cannot be a number') - -export const noSpaces = makeValidator((val: string) => val.includes(' '), 'cannot contain spaces') - -export const noUrls = makeValidator((val: string) => urlRegex.test(val), 'cannot contain URLs') - -export const isObject = makeValidator( - (val: string) => { - try { - return Object.prototype.toString.call(JSON.parse(val)) !== '[object Object]' - } catch { - return false - } - }, - 'must be an object' -) - -export const noTags = makeValidator( - (val: string) => ( - MessageMentions.EVERYONE_PATTERN.test(val) || MessageMentions.USERS_PATTERN.test(val) || - MessageMentions.ROLES_PATTERN.test(val) - ), - 'cannot contain tags' -) - -export function typeOf (type: string): Validator { - return makeValidator( - async function ( - this: ArgumentType, - val: string, - msg: CommandoMessage, - arg: Argument - ) { - return typeof await this.parse(val, msg, arg) === type // eslint-disable-line valid-typeof - }, - `must be a ${type}` - ) -} - -export function validDate (dateString: string): boolean { - if (dateRegex.test(dateString)) { - const { day, month, year } = getDateInfo(dateString) - const leapYear = year % 4 === 0 - if (month === 0 || month === 2 || month === 4 || month === 6 || month === 7 || month === 9 || month === 11) { - return day <= 31 - } else if (month === 3 || month === 5 || month === 8 || month === 10) { - return day <= 30 - } else if (month === 1) { - return leapYear ? day <= 29 : day <= 28 - } - } - return false -} - -export function validTime (timeString: string): boolean { - return timeRegex.test(timeString) -} - -export function validateNoneOrType ( - this: Argument, - val: string, - msg: CommandoMessage -): boolean | string | Promise { - return val.toLowerCase() === 'none' || this.type.validate(val, msg, this) -} - -export function parseNoneOrType ( - this: Argument, - val: string, - msg: CommandoMessage -): any | Promise { - return val.toLowerCase() === 'none' ? undefined : this.type.parse(val, msg, this) -} - -export function parseEnum ( - enumLike: T, - transformer?: (attribute: string) => string -): ((this: Argument, val: string, msg: CommandoMessage) => Promise) { - return async function (this: Argument, val: string, msg: CommandoMessage): Promise { - const lowerCaseVal = val.toLowerCase() - return getEnumKeys(enumLike) - .find(key => (transformer?.(key) ?? key).toLowerCase() === lowerCaseVal) ?? - await this.type.parse(val, msg, this) - } -} - -export function guildSettingTransformer (value: string): string { - return value.endsWith('Id') ? value.slice(0, -2) : value -} diff --git a/src/utils/argument.ts b/src/utils/argument.ts new file mode 100644 index 00000000..582cc2cf --- /dev/null +++ b/src/utils/argument.ts @@ -0,0 +1,130 @@ +import type { Argument, ParserFunction, ValidatorFunction } from '../interactions/application-commands' +import { type CommandInteraction, MessageMentions } from 'discord.js' +import type { Enum } from '.' +import { getDateInfo } from './time' +import { getEnumKeys } from './util' + +type ValidatorTest = +((val: string, interaction: CommandInteraction, arg: Argument) => boolean | Promise) + +const dateRegex = /(([0-2]?[0-9]|3[0-1])[-](0?[1-9]|1[0-2])[-][0-9]{4})/ +const timeRegex = /^(2[0-3]|[0-1]?[\d]):[0-5][\d]$/ + +export const urlRegex = /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/gi + +export function validators ( + steps: Array | Array>> = [] +): ValidatorFunction { + return async function (this: Argument, val, interaction) { + if (steps.length === 0) { + return true + } + for (const step of steps) { + if (step instanceof Array) { + const validations = step.map(validator => validator.call(this, val, interaction, this)) + const results = await Promise.allSettled(validations) + if (results.some(result => result.status === 'fulfilled' && result.value === true)) { + continue + } + + const errors = results + .filter(result => result.status === 'fulfilled' && typeof result.value === 'string') + .map(result => (result as PromiseFulfilledResult).value) + if (errors.length > 0) { + return errors.join('\n') + } + throw (results.find(result => result.status === 'rejected') as PromiseRejectedResult).reason + } else { + const valid = await step(val, interaction, this) + if (valid !== true) { + return valid + } + } + } + return true + } +} + +function makeValidator (test: ValidatorTest, message: string): ValidatorFunction { + return async function (val, interaction, arg) { + return await test(val, interaction, arg) || `\`${arg.name ?? arg.key}\` ${message}.` + } +} + +export const noChannels = makeValidator( + (val: string) => !MessageMentions.CHANNELS_PATTERN.test(val), + 'cannot contain channels' +) + +export const noNumber = makeValidator((val: string) => isNaN(parseInt(val)), 'cannot be a number') + +export const noWhitespace = makeValidator((val: string) => !/\s/.test(val), 'cannot contain whitespace characters') + +export const noUrls = makeValidator((val: string) => !urlRegex.test(val), 'cannot contain URLs') + +export const isObject = makeValidator( + (val: string) => { + try { + return Object.prototype.toString.call(JSON.parse(val)) === '[object Object]' + } catch { + return false + } + }, + 'must be an object' +) + +export const noTags = makeValidator( + (val: string) => ( + !MessageMentions.EVERYONE_PATTERN.test(val) && !MessageMentions.USERS_PATTERN.test(val) && + !MessageMentions.ROLES_PATTERN.test(val) + ), + 'cannot contain tags' +) + +export function typeOf (type: string): ValidatorFunction { + return makeValidator( + async function (val, interaction, arg) { + // eslint-disable-next-line valid-typeof + return typeof (await arg.parse?.(val, interaction, arg) ?? val) === type + }, + `must be a ${type}` + ) +} + +export function validDate (dateString: string): boolean { + if (dateRegex.test(dateString)) { + const { day, month, year } = getDateInfo(dateString) + const leapYear = year % 4 === 0 + if (month === 0 || month === 2 || month === 4 || month === 6 || month === 7 || month === 9 || month === 11) { + return day <= 31 + } else if (month === 3 || month === 5 || month === 8 || month === 10) { + return day <= 30 + } else if (month === 1) { + return leapYear ? day <= 29 : day <= 28 + } + } + return false +} + +export function validTime (timeString: string): boolean { + return timeRegex.test(timeString) +} + +export function parseEnum ( + enumLike: T, + transformer?: (attribute: string) => string +): ParserFunction { + return function (val) { + const lowerCaseVal = val.toLowerCase() + const result = getEnumKeys(enumLike) + .find(key => (transformer?.(key) ?? key).toLowerCase() === lowerCaseVal) + if (typeof result !== 'undefined') { + return result + } + throw new Error('Unknown enum value.') + } +} + +export function guildSettingTransformer (value: string): string { + return value.endsWith('Id') ? value.slice(0, -2) : value +} diff --git a/src/util/constants.ts b/src/utils/constants.ts similarity index 53% rename from src/util/constants.ts rename to src/utils/constants.ts index 2033c6d5..2c22d699 100644 --- a/src/util/constants.ts +++ b/src/utils/constants.ts @@ -1,15 +1,41 @@ export const TYPES = { + // Client + Client: Symbol.for('Client'), + Dispatcher: Symbol.for('Dispatcher'), + SettingProvider: Symbol.for('SettingProvider'), + WebSocketManager: Symbol.for('WebSocketManager'), + + // Factories + Argument: Symbol.for('Argument'), + ArgumentFactory: Symbol.for('ArgumentFactory'), + + ArgumentType: Symbol.for('ArgumentType'), + ArgumentTypeFactory: Symbol.for('ArgumentTypeFactory'), + + Command: Symbol.for('Command'), + CommandFactory: Symbol.for('CommandFactory'), + + Handler: Symbol.for('Handler'), + EventHandlerFactory: Symbol.for('EventHandlerFactory'), + PacketHandlerFactory: Symbol.for('PacketHandlerFactory'), + + Job: Symbol.for('Job'), + JobFactory: Symbol.for('JobFactory'), + + Manager: Symbol.for('Manager'), + ManagerFactory: Symbol.for('ManagerFactory'), + + Structure: Symbol.for('Structure'), + StructureFactory: Symbol.for('StructureFactory'), + // Repositories ChannelRepository: Symbol.for('ChannelRepository'), - CommandRepository: Symbol.for('CommandRepository'), EmojiRepository: Symbol.for('EmojiRepository'), GroupRepository: Symbol.for('GroupRepository'), GuildRepository: Symbol.for('GuildRepository'), - GuildCommandRepository: Symbol.for('GuildCommandRepository'), MemberRepository: Symbol.for('MemberRepository'), MessageRepository: Symbol.for('MessageRepository'), PanelRepository: Symbol.for('PanelRepository'), - PermissionRepository: Symbol.for('PermissionRepository'), RoleRepository: Symbol.for('RoleRepository'), RoleBindingRepository: Symbol.for('RoleBindingRepository'), RoleMessageRepository: Symbol.for('RoleMessageRepository'), @@ -18,13 +44,21 @@ export const TYPES = { TicketRepository: Symbol.for('TicketRepository'), TicketTypeRepository: Symbol.for('TicketTypeRepository'), - // Other - Handler: Symbol.for('Handler'), - EventHandlerFactory: Symbol.for('EventHandlerFactory'), - PacketHandlerFactory: Symbol.for('PacketHandlerFactory'), + // Services + ChannelLinkService: Symbol.for('ChannelLinkService'), + PersistentRoleService: Symbol.for('PersistentRoleService') +} - Job: Symbol.for('Job'), - JobFactory: Symbol.for('JobFactory') +export enum GuildSetting { + logsChannelId, + primaryColor, + ratingsChannelId, + robloxGroupId, + robloxUsernamesInNicknames, + suggestionsChannelId, + ticketArchivesChannelId, + ticketsCategoryId, + verificationPreference } export enum CommandType { diff --git a/src/util/decorators.ts b/src/utils/decorators.ts similarity index 79% rename from src/util/decorators.ts rename to src/utils/decorators.ts index cbc2318d..57a37bc2 100644 --- a/src/util/decorators.ts +++ b/src/utils/decorators.ts @@ -1,4 +1,17 @@ import { type ValidationArguments, type ValidationOptions, registerDecorator } from 'class-validator' +import type { Constructor } from '.' +import { createClassDecorator } from './util' + +/** + * Applies given options to the class. + */ +export function ApplyOptions (options: T): ClassDecorator { + return createClassDecorator((target: Constructor) => ( + // Using Reflect.defineMetadata instead of a Proxy because Proxy's + // handler.construct doesn't work when Inversify creates an instance. + Reflect.defineMetadata('options', options, target) + )) +} /** * Used to mark a XOR relation between given and specified properties. diff --git a/src/util/index.ts b/src/utils/index.ts similarity index 88% rename from src/util/index.ts rename to src/utils/index.ts index a53b3e7d..a5f131b1 100644 --- a/src/util/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,4 @@ +export * from './types' export * as argumentUtil from './argument' export * as constants from './constants' export * as decorators from './decorators' diff --git a/src/util/time.ts b/src/utils/time.ts similarity index 80% rename from src/util/time.ts rename to src/utils/time.ts index 6cf1993a..f40ab396 100644 --- a/src/util/time.ts +++ b/src/utils/time.ts @@ -14,7 +14,7 @@ export function diffDays (date1: Date, date2: Date): number { const d2 = new Date(date2) d1.setHours(0, 0, 0) d2.setHours(0, 0, 0) - return Math.round(Math.abs((d1.getTime() - d2.getTime()) / (24 * 60 * 60 * 1000))) + return Math.round(Math.abs((d1.getTime() - d2.getTime()) / 86_400_000)) } export function getDate (date: Date): string { @@ -32,13 +32,13 @@ export function getDateInfo (dateString: string): DateInfo { } export function getDurationString (milliseconds: number): string { - const days = Math.floor(milliseconds / (24 * 60 * 60 * 1000)) - const daysMilliseconds = milliseconds % (24 * 60 * 60 * 1000) - const hours = Math.floor(daysMilliseconds / (60 * 60 * 1000)) - const hoursMilliseconds = milliseconds % (60 * 60 * 1000) - const minutes = Math.floor(hoursMilliseconds / (60 * 1000)) - const minutesMilliseconds = milliseconds % (60 * 1000) - const seconds = Math.floor(minutesMilliseconds / (1000)) + const days = Math.floor(milliseconds / 86_400_000) + const daysMilliseconds = milliseconds % 86_400_000 + const hours = Math.floor(daysMilliseconds / 3_600_000) + const hoursMilliseconds = milliseconds % 3_600_000 + const minutes = Math.floor(hoursMilliseconds / 60_000) + const minutesMilliseconds = milliseconds % 60_000 + const seconds = Math.floor(minutesMilliseconds / 1000) return `${days > 0 ? `${days}d ` : ''}${hours > 0 ? `${hours}h ` : ''}${minutes > 0 ? `${minutes}m ` : ''}${seconds > 0 ? `${seconds}s ` : ''}` } diff --git a/src/utils/types.ts b/src/utils/types.ts new file mode 100644 index 00000000..00a806a0 --- /dev/null +++ b/src/utils/types.ts @@ -0,0 +1,55 @@ +import type { BaseManager } from '../managers' +import type { interfaces } from 'inversify' + +export type Constructor = new (...args: any[]) => T +export type AbstractConstructor = abstract new (...args: any[]) => T +export type Mixin = InstanceType> +export type Enum = Record +export type EnumKeys = Exclude +export type KeyOfType = { [K in keyof T]: T[K] extends U ? K : never }[keyof T] +export type AnyFunction = (...input: any[]) => T +export type Tail = T extends [any, ...infer U] ? U : never + +export type OverloadedParameters = Overloads extends infer U + ? { [K in keyof U]: Parameters any>> } + : never + +export type OverloadedReturnType = Overloads extends infer U + ? { [K in keyof U]: ReturnType any>> } + : never + +// Supports up to 4 overload signatures. +type Overloads = T extends { + (...args: infer A1): infer R1 + (...args: infer A2): infer R2 + (...args: infer A3): infer R3 + (...args: infer A4): infer R4 +} ? [ + (...args: A1) => R1, + (...args: A2) => R2, + (...args: A3) => R3, + (...args: A4) => R4 + ] : T extends { + (...args: infer A1): infer R1 + (...args: infer A2): infer R2 + (...args: infer A3): infer R3 + } ? [ + (...args: A1) => R1, + (...args: A2) => R2, + (...args: A3) => R3 + ] : T extends { + (...args: infer A1): infer R1 + (...args: infer A2): infer R2 + } ? [ + (...args: A1) => R1, + (...args: A2) => R2 + ] : T extends (...args: infer A1) => infer R1 + ? [(...args: A1) => R1] + : any + +export type ManagerFactory = < + T extends BaseManager, + U extends { id: K }, + K extends number | string = number | string + > (managerName: string) => + interfaces.SimpleFactory unknown) ? P : never[]> diff --git a/src/util/util.ts b/src/utils/util.ts similarity index 83% rename from src/util/util.ts rename to src/utils/util.ts index 23b52130..02be6956 100644 --- a/src/util/util.ts +++ b/src/utils/util.ts @@ -1,10 +1,4 @@ -export type Constructor = new (...args: any[]) => T -export type AbstractConstructor = abstract new (...args: any[]) => T -export type Mixin = InstanceType> -export type Enum = Record - -type AnyFunction = (...input: any[]) => T -type EnumKeys = Exclude +import type { Enum, EnumKeys } from '.' function getEnumObject ( enumLike: T @@ -30,7 +24,7 @@ export function formatBytes (bytes: number, decimals = 2): string { export function getAbbreviation (val: string): string { return val .trim() - .split(/ +/) + .split(/\s+/) .map(word => word.charAt(0)) .join('') } @@ -74,3 +68,7 @@ export function getEnumKeys (enumLike: T): string[] { export function getEnumValues (enumLike: T): Array { return [...new Set(Object.values(getEnumObject(enumLike)))] } + +export function createClassDecorator void> (fn: TFunction): ClassDecorator { + return fn +} diff --git a/yarn.lock b/yarn.lock index 263963fc..513471f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -32,21 +32,46 @@ __metadata: languageName: node linkType: hard -"@discordjs/collection@npm:^0.1.6": - version: 0.1.6 - resolution: "@discordjs/collection@npm:0.1.6" - checksum: a0ddf757098fa01a4d9d5812daf3c2ea88c1297618d346705e603642c2f19656188fbfb3d503ee77f2ee69e7a1c1bfe25932aec032692ee3c338335524886e05 +"@discordjs/builders@npm:^0.13.0": + version: 0.13.0 + resolution: "@discordjs/builders@npm:0.13.0" + dependencies: + "@sapphire/shapeshift": ^2.0.0 + "@sindresorhus/is": ^4.6.0 + discord-api-types: ^0.31.1 + fast-deep-equal: ^3.1.3 + ts-mixer: ^6.0.1 + tslib: ^2.3.1 + checksum: 1bce798b8f36b5abb09d8d42a660a5da6a9352ada03e0819d08ae6c98d4d8a556ed86485e252e53c448a40d17bf0d685e7f88f333db76f5b6b3bd562b9759d7c languageName: node linkType: hard -"@discordjs/form-data@npm:^3.0.1": - version: 3.0.1 - resolution: "@discordjs/form-data@npm:3.0.1" +"@discordjs/collection@npm:^0.4.0": + version: 0.4.0 + resolution: "@discordjs/collection@npm:0.4.0" + checksum: fa8fc4246921f3230eb6c5d6d4dc0caf9dd659fcc903175944edf4fb0a9ed9913fdf164733d3f1e644ef469bc79b0d38a526ee620b92169cb40e79b40b0c716b + languageName: node + linkType: hard + +"@discordjs/collection@npm:^0.6.0": + version: 0.6.0 + resolution: "@discordjs/collection@npm:0.6.0" + checksum: a08826f04e3ddaf079913e2e2069727d518b5a0ea79cbeb66d8b559297df6586a25490b4b2aeab631483f68c2160df769115b9b3e114d8437b707dec80b02bab + languageName: node + linkType: hard + +"@discordjs/rest@npm:^0.3.0": + version: 0.3.0 + resolution: "@discordjs/rest@npm:0.3.0" dependencies: - asynckit: ^0.4.0 - combined-stream: ^1.0.8 - mime-types: ^2.1.12 - checksum: 2b431b1a14f8ac521e1c13567856cef7e61e05aafe2721e03d9e074bb7e5f45c89fc123b126964943edf301ec612d04515dcf068ea9773a2e30a99a844c00603 + "@discordjs/collection": ^0.4.0 + "@sapphire/async-queue": ^1.1.9 + "@sapphire/snowflake": ^3.0.1 + discord-api-types: ^0.26.1 + form-data: ^4.0.0 + node-fetch: ^2.6.5 + tslib: ^2.3.1 + checksum: 0e5724156e0375b2181036d25d8847c5b7d8ab46a3409a19dad57ec9b3301d9127917a52558d3daa7e2b513804d4de9fcd5f6d56e056cc48dd567ebf26548c6d languageName: node linkType: hard @@ -151,6 +176,34 @@ __metadata: languageName: node linkType: hard +"@sapphire/async-queue@npm:^1.1.9": + version: 1.2.0 + resolution: "@sapphire/async-queue@npm:1.2.0" + checksum: 9959c91fe031e9350134740b68e64798eff1f72f1417f312a4f7bebbd875035a406ba5ae1e71640c3819dec10d0f86a0588b494088f353f85701f2f1196e4560 + languageName: node + linkType: hard + +"@sapphire/async-queue@npm:^1.3.1": + version: 1.3.1 + resolution: "@sapphire/async-queue@npm:1.3.1" + checksum: 4016010a8b6f2896ce7694eb04c1839b613387cbfb104028a4a1bea471afb1dc4d569b66d3c9770319c47be55035dc786c072c32656d467fc2cded4347055d92 + languageName: node + linkType: hard + +"@sapphire/shapeshift@npm:^2.0.0": + version: 2.2.0 + resolution: "@sapphire/shapeshift@npm:2.2.0" + checksum: f6cdc706548b3c5acbcb24878bacfd5cc28629d72900bb53cf54911da3a42199a479c6875486824b17106360b7dc3c156f509ef9b04190fefa42d241ddec2696 + languageName: node + linkType: hard + +"@sapphire/snowflake@npm:^3.0.1": + version: 3.1.0 + resolution: "@sapphire/snowflake@npm:3.1.0" + checksum: 979d41f531983b992e65f79a75016e92bb4f3984148bd7e2164059b4e8e18df0206c36c5a1a02f32c39c425b268f2e7871d9eef1eb5f1690f8837e451cc00812 + languageName: node + linkType: hard + "@sentry/core@npm:6.16.1": version: 6.16.1 resolution: "@sentry/core@npm:6.16.1" @@ -245,6 +298,13 @@ __metadata: languageName: node linkType: hard +"@sindresorhus/is@npm:^4.6.0": + version: 4.6.0 + resolution: "@sindresorhus/is@npm:4.6.0" + checksum: 83839f13da2c29d55c97abc3bc2c55b250d33a0447554997a85c539e058e57b8da092da396e252b11ec24a0279a0bed1f537fa26302209327060643e327f81d2 + languageName: node + linkType: hard + "@sqltools/formatter@npm:^1.2.2": version: 1.2.3 resolution: "@sqltools/formatter@npm:1.2.3" @@ -294,6 +354,16 @@ __metadata: languageName: node linkType: hard +"@types/node-fetch@npm:^2.6.1": + version: 2.6.1 + resolution: "@types/node-fetch@npm:2.6.1" + dependencies: + "@types/node": "*" + form-data: ^3.0.0 + checksum: a3e5d7f413d1638d795dff03f7b142b1b0e0c109ed210479000ce7b3ea11f9a6d89d9a024c96578d9249570c5fe5287a5f0f4aaba98199222230196ff2d6b283 + languageName: node + linkType: hard + "@types/node@npm:*": version: 16.9.1 resolution: "@types/node@npm:16.9.1" @@ -331,6 +401,15 @@ __metadata: languageName: node linkType: hard +"@types/ws@npm:^8.5.3": + version: 8.5.3 + resolution: "@types/ws@npm:8.5.3" + dependencies: + "@types/node": "*" + checksum: 0ce46f850d41383fcdc2149bcacc86d7232fa7a233f903d2246dff86e31701a02f8566f40af5f8b56d1834779255c04ec6ec78660fe0f9b2a69cf3d71937e4ae + languageName: node + linkType: hard + "@types/zen-observable@npm:^0.8.2": version: 0.8.2 resolution: "@types/zen-observable@npm:0.8.2" @@ -490,15 +569,6 @@ __metadata: languageName: node linkType: hard -"abort-controller@npm:^3.0.0": - version: 3.0.0 - resolution: "abort-controller@npm:3.0.0" - dependencies: - event-target-shim: ^5.0.0 - checksum: 170bdba9b47b7e65906a28c8ce4f38a7a369d78e2271706f020849c1bfe0ee2067d4261df8bbb66eb84f79208fd5b710df759d64191db58cfba7ce8ef9c54b75 - languageName: node - linkType: hard - "acorn-jsx@npm:^5.3.1": version: 5.3.1 resolution: "acorn-jsx@npm:5.3.1" @@ -640,6 +710,7 @@ __metadata: version: 0.0.0-use.local resolution: "arora-discord@workspace:." dependencies: + "@discordjs/rest": ^0.3.0 "@guidojw/bloxy": ^5.7.6 "@sentry/integrations": ^6.16.1 "@sentry/node": ^6.16.1 @@ -653,9 +724,10 @@ __metadata: axios: ^0.26.0 class-validator: ^0.13.2 common-tags: ^1.8.2 - discord.js: ^12.5.3 - discord.js-commando: ^0.12.3 + discord-api-types: ^0.33.0 + discord.js: ^13.7.0 dotenv: ^16.0.0 + emoji-regex: ^10.0.0 eslint: ^8.4.1 eslint-config-standard-with-typescript: ^21.0.1 eslint-plugin-import: ^2.25.3 @@ -663,7 +735,6 @@ __metadata: eslint-plugin-promise: ^6.0.0 eslint-plugin-unicorn: ^41.0.0 inversify: ^6.0.1 - inversify-inject-decorators: ^3.1.0 lodash: ^4.17.21 node-cron: ^3.0.0 pg: ^8.7.1 @@ -971,13 +1042,6 @@ __metadata: languageName: node linkType: hard -"common-tags@npm:^1.8.0": - version: 1.8.0 - resolution: "common-tags@npm:1.8.0" - checksum: fb0cc9420d149176f2bd2b1fc9e6df622cd34eccaca60b276aa3253a7c9241e8a8ed1ec0702b2679eba7e47aeef721869c686bbd7257b75b5c44993c8462cd7f - languageName: node - linkType: hard - "common-tags@npm:^1.8.2": version: 1.8.2 resolution: "common-tags@npm:1.8.2" @@ -1127,31 +1191,48 @@ __metadata: languageName: node linkType: hard -"discord.js-commando@npm:^0.12.3": - version: 0.12.3 - resolution: "discord.js-commando@npm:0.12.3" - dependencies: - common-tags: ^1.8.0 - emoji-regex: ^9.2.0 - is-promise: ^4.0.0 - require-all: ^3.0.0 - checksum: 6ac3c48b593f68b9038fac1dde28c20cc54c5bf6b3a8ec6669f52fe63c15c9e2cdf154e44c27b8cbd9808254ab335438a8aa17d0d5531b156ef523b96905ddba +"discord-api-types@npm:^0.26.1": + version: 0.26.1 + resolution: "discord-api-types@npm:0.26.1" + checksum: e53bfa7589b24108e6b403dbe213da34c4592f72e2b8fde6800dcb6c703065887ecbd644e1cdf694e4c7796954bc51462ced868f26ec45dc1e0dc4fa8d3c723c languageName: node linkType: hard -"discord.js@npm:^12.5.3": - version: 12.5.3 - resolution: "discord.js@npm:12.5.3" - dependencies: - "@discordjs/collection": ^0.1.6 - "@discordjs/form-data": ^3.0.1 - abort-controller: ^3.0.0 +"discord-api-types@npm:^0.30.0": + version: 0.30.0 + resolution: "discord-api-types@npm:0.30.0" + checksum: c4b0afbc453b7ce6b570501f83a654591432eb2ce690681d2e6a9424d5f9687b7ef02dde02702e53562449c5cdcde500e92299a8158eb9bf67ad85c073788e72 + languageName: node + linkType: hard + +"discord-api-types@npm:^0.31.1": + version: 0.31.2 + resolution: "discord-api-types@npm:0.31.2" + checksum: 928d4f57f4c1ace0baf1d3b2c3ab4a4a5107f100217ce889a6e00ad365115fd328a0b922dbc62af6b6ccac9552ffd7ec3d07f4c8820502c5b55d9f2e5382dfba + languageName: node + linkType: hard + +"discord-api-types@npm:^0.33.0": + version: 0.33.0 + resolution: "discord-api-types@npm:0.33.0" + checksum: 8ae2c7e36c34e1d250acab18f8d2941be6135b4e08e48f9c0f584b23a59e8b310ec33f78a46e4aa8294639ec0b5703162f1895eb0f446c70559ae4e69cd26b16 + languageName: node + linkType: hard + +"discord.js@npm:^13.7.0": + version: 13.7.0 + resolution: "discord.js@npm:13.7.0" + dependencies: + "@discordjs/builders": ^0.13.0 + "@discordjs/collection": ^0.6.0 + "@sapphire/async-queue": ^1.3.1 + "@types/node-fetch": ^2.6.1 + "@types/ws": ^8.5.3 + discord-api-types: ^0.30.0 + form-data: ^4.0.0 node-fetch: ^2.6.1 - prism-media: ^1.2.9 - setimmediate: ^1.0.5 - tweetnacl: ^1.0.3 - ws: ^7.4.4 - checksum: 66c95c2fe3e96533117c65410a4229d38cf972794e800b63108047841a4347d489b56e049f42d7bea90e11e7d33b0d2622ad6aa9a63b9595f0b022efc3519fac + ws: ^8.6.0 + checksum: 2bab21c610401af57fae4ab5cf276a5201cfb321b306d9dc74c338b939ad051d3b5f47b465595f46ae41ed06cb1d4e0581fd7980852f91d3f18b4da5f70ef391 languageName: node linkType: hard @@ -1187,6 +1268,13 @@ __metadata: languageName: node linkType: hard +"emoji-regex@npm:^10.0.0": + version: 10.0.0 + resolution: "emoji-regex@npm:10.0.0" + checksum: 9a2e0ad35b84d8ce92777126d122196ff319f64de596e0ca8560298f94eec64b9be26f5b799bb47e2ad3e8791691817f3aec4e3c507e58d5b94a6adeea271921 + languageName: node + linkType: hard + "emoji-regex@npm:^8.0.0": version: 8.0.0 resolution: "emoji-regex@npm:8.0.0" @@ -1194,13 +1282,6 @@ __metadata: languageName: node linkType: hard -"emoji-regex@npm:^9.2.0": - version: 9.2.2 - resolution: "emoji-regex@npm:9.2.2" - checksum: 8487182da74aabd810ac6d6f1994111dfc0e331b01271ae01ec1eb0ad7b5ecc2bbbbd2f053c05cb55a1ac30449527d819bbfbf0e3de1023db308cbcb47f86601 - languageName: node - linkType: hard - "encoding@npm:^0.1.12": version: 0.1.13 resolution: "encoding@npm:0.1.13" @@ -1373,14 +1454,13 @@ __metadata: languageName: node linkType: hard -"eslint-module-utils@npm:^2.7.1": - version: 2.7.1 - resolution: "eslint-module-utils@npm:2.7.1" +"eslint-module-utils@npm:^2.7.2": + version: 2.7.3 + resolution: "eslint-module-utils@npm:2.7.3" dependencies: debug: ^3.2.7 find-up: ^2.1.0 - pkg-dir: ^2.0.0 - checksum: c30dfa125aafe65e5f6a30a31c26932106fcf09934a2f47d7f8a393ed9106da7b07416f2337b55c85f9db0175c873ee0827be5429a24ec381b49940f342b9ac3 + checksum: 77048263f309167a1e6a1e1b896bfb5ddd1d3859b2e2abbd9c32c432aee13d610d46e6820b1ca81b37fba437cf423a404bc6649be64ace9148a3062d1886a678 languageName: node linkType: hard @@ -1397,25 +1477,25 @@ __metadata: linkType: hard "eslint-plugin-import@npm:^2.25.3": - version: 2.25.3 - resolution: "eslint-plugin-import@npm:2.25.3" + version: 2.25.4 + resolution: "eslint-plugin-import@npm:2.25.4" dependencies: array-includes: ^3.1.4 array.prototype.flat: ^1.2.5 debug: ^2.6.9 doctrine: ^2.1.0 eslint-import-resolver-node: ^0.3.6 - eslint-module-utils: ^2.7.1 + eslint-module-utils: ^2.7.2 has: ^1.0.3 is-core-module: ^2.8.0 is-glob: ^4.0.3 minimatch: ^3.0.4 object.values: ^1.1.5 resolve: ^1.20.0 - tsconfig-paths: ^3.11.0 + tsconfig-paths: ^3.12.0 peerDependencies: eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 - checksum: 8bdf4b1fafb0e5c8f57a1673f72d84307d32c06a23942990d198c8b32a85a5ae0098872d1ef5bf80d7dfe8ec542f6a671e3c5e706731a80b493c9015f7a147f5 + checksum: 0af24f5c7c6ca692f42e3947127f0ae7dfe44f1e02740f7cbe988b510a9c52bab0065d7df04e2d953dcc88a4595a00cbdcf14018acf8cd75cfd47b72efcbb734 languageName: node linkType: hard @@ -1634,13 +1714,6 @@ __metadata: languageName: node linkType: hard -"event-target-shim@npm:^5.0.0": - version: 5.0.1 - resolution: "event-target-shim@npm:5.0.1" - checksum: 1ffe3bb22a6d51bdeb6bf6f7cf97d2ff4a74b017ad12284cc9e6a279e727dc30a5de6bb613e5596ff4dc3e517841339ad09a7eec44266eccb1aa201a30448166 - languageName: node - linkType: hard - "ext@npm:^1.1.2": version: 1.4.0 resolution: "ext@npm:1.4.0" @@ -1758,6 +1831,28 @@ __metadata: languageName: node linkType: hard +"form-data@npm:^3.0.0": + version: 3.0.1 + resolution: "form-data@npm:3.0.1" + dependencies: + asynckit: ^0.4.0 + combined-stream: ^1.0.8 + mime-types: ^2.1.12 + checksum: b019e8d35c8afc14a2bd8a7a92fa4f525a4726b6d5a9740e8d2623c30e308fbb58dc8469f90415a856698933c8479b01646a9dff33c87cc4e76d72aedbbf860d + languageName: node + linkType: hard + +"form-data@npm:^4.0.0": + version: 4.0.0 + resolution: "form-data@npm:4.0.0" + dependencies: + asynckit: ^0.4.0 + combined-stream: ^1.0.8 + mime-types: ^2.1.12 + checksum: 01135bf8675f9d5c61ff18e2e2932f719ca4de964e3be90ef4c36aacfc7b9cb2fceb5eca0b7e0190e3383fe51c5b37f4cb80b62ca06a99aaabfcfd6ac7c9328c + languageName: node + linkType: hard + "fs-minipass@npm:^2.0.0": version: 2.1.0 resolution: "fs-minipass@npm:2.1.0" @@ -2109,13 +2204,6 @@ __metadata: languageName: node linkType: hard -"inversify-inject-decorators@npm:^3.1.0": - version: 3.1.0 - resolution: "inversify-inject-decorators@npm:3.1.0" - checksum: 61fd6948c0914ec1c6f10a87903c949d3987903fdb2d23952a1257223f192d0386862974568044dd73d2fcf4f0f37d59eb4dc83124b1d143ed4852266e1585b6 - languageName: node - linkType: hard - "inversify@npm:^6.0.1": version: 6.0.1 resolution: "inversify@npm:6.0.1" @@ -2138,18 +2226,21 @@ __metadata: linkType: hard "is-bigint@npm:^1.0.1": - version: 1.0.1 - resolution: "is-bigint@npm:1.0.1" - checksum: 04aa6fde59d2b7929df865acb89c8d7f89f919cc149b8be11e3560b1aab8667e5d939cc8954097c496f7dda80fd5bb67f829ca80ab66cc68918e41e2c1b9c5d7 + version: 1.0.4 + resolution: "is-bigint@npm:1.0.4" + dependencies: + has-bigints: ^1.0.1 + checksum: c56edfe09b1154f8668e53ebe8252b6f185ee852a50f9b41e8d921cb2bed425652049fbe438723f6cb48a63ca1aa051e948e7e401e093477c99c84eba244f666 languageName: node linkType: hard "is-boolean-object@npm:^1.1.0": - version: 1.1.0 - resolution: "is-boolean-object@npm:1.1.0" + version: 1.1.2 + resolution: "is-boolean-object@npm:1.1.2" dependencies: - call-bind: ^1.0.0 - checksum: 3ead0446176ee42a69f87658bf12d53c135095336d34765fa65f137f378ea125429bf777f91f6dd3407db80829d742bc4fb2fdaf8d2cf6ba82a2de2a07fbbac7 + call-bind: ^1.0.2 + has-tostringtag: ^1.0.0 + checksum: c03b23dbaacadc18940defb12c1c0e3aaece7553ef58b162a0f6bba0c2a7e1551b59f365b91e00d2dbac0522392d576ef322628cb1d036a0fe51eb466db67222 languageName: node linkType: hard @@ -2162,14 +2253,7 @@ __metadata: languageName: node linkType: hard -"is-callable@npm:^1.1.4": - version: 1.2.3 - resolution: "is-callable@npm:1.2.3" - checksum: 084a732afd78e14a40cd5f6f34001edd500f43bb542991c1305b88842cab5f2fb6b48f0deed4cd72270b2e71cab3c3a56c69b324e3a02d486f937824bb7de553 - languageName: node - linkType: hard - -"is-callable@npm:^1.2.4": +"is-callable@npm:^1.1.4, is-callable@npm:^1.2.4": version: 1.2.4 resolution: "is-callable@npm:1.2.4" checksum: 1a28d57dc435797dae04b173b65d6d1e77d4f16276e9eff973f994eadcfdc30a017e6a597f092752a083c1103cceb56c91e3dadc6692fedb9898dfaba701575f @@ -2185,19 +2269,21 @@ __metadata: languageName: node linkType: hard -"is-core-module@npm:^2.8.0": - version: 2.8.0 - resolution: "is-core-module@npm:2.8.0" +"is-core-module@npm:^2.8.0, is-core-module@npm:^2.8.1": + version: 2.8.1 + resolution: "is-core-module@npm:2.8.1" dependencies: has: ^1.0.3 - checksum: f8b52714891e1a6c6577fcb8d5e057bab064a7a30954aab6beb5092e311473eb8da57afd334de4981dc32409ffca998412efc3a2edceb9e397cef6098d21dd91 + checksum: 418b7bc10768a73c41c7ef497e293719604007f88934a6ffc5f7c78702791b8528102fb4c9e56d006d69361549b3d9519440214a74aefc7e0b79e5e4411d377f languageName: node linkType: hard "is-date-object@npm:^1.0.1": - version: 1.0.2 - resolution: "is-date-object@npm:1.0.2" - checksum: ac859426e5df031abd9d1eeed32a41cc0de06e47227bd972b8bc716460a9404654b3dba78f41e8171ccf535c4bfa6d72a8d1d15a0873f9646698af415e92c2fb + version: 1.0.5 + resolution: "is-date-object@npm:1.0.5" + dependencies: + has-tostringtag: ^1.0.0 + checksum: baa9077cdf15eb7b58c79398604ca57379b2fc4cf9aa7a9b9e295278648f628c9b201400c01c5e0f7afae56507d741185730307cbe7cad3b9f90a77e5ee342fc languageName: node linkType: hard @@ -2241,16 +2327,18 @@ __metadata: linkType: hard "is-negative-zero@npm:^2.0.1": - version: 2.0.1 - resolution: "is-negative-zero@npm:2.0.1" - checksum: a46f2e0cb5e16fdb8f2011ed488979386d7e68d381966682e3f4c98fc126efe47f26827912baca2d06a02a644aee458b9cba307fb389f6b161e759125db7a3b8 + version: 2.0.2 + resolution: "is-negative-zero@npm:2.0.2" + checksum: f3232194c47a549da60c3d509c9a09be442507616b69454716692e37ae9f37c4dea264fb208ad0c9f3efd15a796a46b79df07c7e53c6227c32170608b809149a languageName: node linkType: hard "is-number-object@npm:^1.0.4": - version: 1.0.4 - resolution: "is-number-object@npm:1.0.4" - checksum: d8e4525b5c151f1830872bf217901b58b3a9f66d93fe2f71c2087418e03d7f5c19a3ad64afa0feb70dafd93f7b97e205e3520a8ff007be665e54b377f5b736a8 + version: 1.0.6 + resolution: "is-number-object@npm:1.0.6" + dependencies: + has-tostringtag: ^1.0.0 + checksum: c697704e8fc2027fc41cb81d29805de4e8b6dc9c3efee93741dbf126a8ecc8443fef85adbc581415ae7e55d325e51d0a942324ae35c829131748cce39cba55f3 languageName: node linkType: hard @@ -2261,13 +2349,6 @@ __metadata: languageName: node linkType: hard -"is-promise@npm:^4.0.0": - version: 4.0.0 - resolution: "is-promise@npm:4.0.0" - checksum: 0b46517ad47b00b6358fd6553c83ec1f6ba9acd7ffb3d30a0bf519c5c69e7147c132430452351b8a9fc198f8dd6c4f76f8e6f5a7f100f8c77d57d9e0f4261a8a - languageName: node - linkType: hard - "is-regex@npm:^1.1.4": version: 1.1.4 resolution: "is-regex@npm:1.1.4" @@ -2295,11 +2376,11 @@ __metadata: linkType: hard "is-symbol@npm:^1.0.2, is-symbol@npm:^1.0.3": - version: 1.0.3 - resolution: "is-symbol@npm:1.0.3" + version: 1.0.4 + resolution: "is-symbol@npm:1.0.4" dependencies: - has-symbols: ^1.0.1 - checksum: c6d54bd01218fa202da8ce91525ca41a907819be5f000df9ab9621467e087eb36f34b2dbfa51a2a699a282e860681ffa6a787d69e944ba99a46d3df553ff2798 + has-symbols: ^1.0.2 + checksum: 92805812ef590738d9de49d677cd17dfd486794773fb6fa0032d16452af46e9b91bb43ffe82c983570f015b37136f4b53b28b8523bfb10b0ece7a66c31a54510 languageName: node linkType: hard @@ -2311,11 +2392,11 @@ __metadata: linkType: hard "is-weakref@npm:^1.0.1": - version: 1.0.1 - resolution: "is-weakref@npm:1.0.1" + version: 1.0.2 + resolution: "is-weakref@npm:1.0.2" dependencies: - call-bind: ^1.0.0 - checksum: fdafb7b955671dd2f9658ff47c86e4025c0650fc68a3542a40e5a75898a763b1abd6b1e1f9f13207eed49541cdd76af67d73c44989ea358b201b70274cf8f6c1 + call-bind: ^1.0.2 + checksum: 95bd9a57cdcb58c63b1c401c60a474b0f45b94719c30f548c891860f051bc2231575c290a6b420c6bc6e7ed99459d424c652bd5bf9a1d5259505dc35b4bf83de languageName: node linkType: hard @@ -2711,6 +2792,20 @@ __metadata: languageName: node linkType: hard +"node-fetch@npm:^2.6.5": + version: 2.6.7 + resolution: "node-fetch@npm:2.6.7" + dependencies: + whatwg-url: ^5.0.0 + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + checksum: 8d816ffd1ee22cab8301c7756ef04f3437f18dace86a1dae22cf81db8ef29c0bf6655f3215cb0cdb22b420b6fe141e64b26905e7f33f9377a7fa59135ea3e10b + languageName: node + linkType: hard + "node-gyp-build@npm:^4.2.0": version: 4.2.3 resolution: "node-gyp-build@npm:4.2.3" @@ -2785,9 +2880,9 @@ __metadata: linkType: hard "object-inspect@npm:^1.11.0, object-inspect@npm:^1.9.0": - version: 1.11.0 - resolution: "object-inspect@npm:1.11.0" - checksum: 8c64f89ce3a7b96b6925879ad5f6af71d498abc217e136660efecd97452991216f375a7eb47cb1cb50643df939bf0c7cc391567b7abc6a924d04679705e58e27 + version: 1.12.0 + resolution: "object-inspect@npm:1.12.0" + checksum: 2b36d4001a9c921c6b342e2965734519c9c58c355822243c3207fbf0aac271f8d44d30d2d570d450b2cc6f0f00b72bcdba515c37827d2560e5f22b1899a31cf4 languageName: node linkType: hard @@ -2982,7 +3077,7 @@ __metadata: languageName: node linkType: hard -"path-parse@npm:^1.0.6": +"path-parse@npm:^1.0.6, path-parse@npm:^1.0.7": version: 1.0.7 resolution: "path-parse@npm:1.0.7" checksum: 49abf3d81115642938a8700ec580da6e830dde670be21893c62f4e10bd7dd4c3742ddc603fe24f898cba7eb0c6bc1777f8d9ac14185d34540c6d4d80cd9cae8a @@ -3084,15 +3179,6 @@ __metadata: languageName: node linkType: hard -"pkg-dir@npm:^2.0.0": - version: 2.0.0 - resolution: "pkg-dir@npm:2.0.0" - dependencies: - find-up: ^2.1.0 - checksum: 8c72b712305b51e1108f0ffda5ec1525a8307e54a5855db8fb1dcf77561a5ae98e2ba3b4814c9806a679f76b2f7e5dd98bde18d07e594ddd9fdd25e9cf242ea1 - languageName: node - linkType: hard - "pluralize@npm:^8.0.0": version: 8.0.0 resolution: "pluralize@npm:8.0.0" @@ -3137,27 +3223,6 @@ __metadata: languageName: node linkType: hard -"prism-media@npm:^1.2.9": - version: 1.2.9 - resolution: "prism-media@npm:1.2.9" - peerDependencies: - "@discordjs/opus": ^0.5.0 - ffmpeg-static: ^4.2.7 || ^3.0.0 || ^2.4.0 - node-opus: ^0.3.3 - opusscript: ^0.0.8 - peerDependenciesMeta: - "@discordjs/opus": - optional: true - ffmpeg-static: - optional: true - node-opus: - optional: true - opusscript: - optional: true - checksum: ee404a0c44d1dc3a2a1224035826355ed02d6b6f69cc958ea92639bdd52a42b483fa4cdd966e486ed8a4d26b235deed6176e0d998887748389d6e09bffa39dcb - languageName: node - linkType: hard - "progress@npm:^2.0.0": version: 2.0.3 resolution: "progress@npm:2.0.3" @@ -3267,13 +3332,6 @@ __metadata: languageName: node linkType: hard -"require-all@npm:^3.0.0": - version: 3.0.0 - resolution: "require-all@npm:3.0.0" - checksum: f2d652d6bca4201bda1ff2f7d4f46a3edd136cc6fd81d38caff50eaa4669ae7ae459f32a06b0892e754bf0f7aae21fbe69172409dff1abd78be5c12cab750a01 - languageName: node - linkType: hard - "require-directory@npm:^2.1.1": version: 2.1.1 resolution: "require-directory@npm:2.1.1" @@ -3288,7 +3346,7 @@ __metadata: languageName: node linkType: hard -"resolve@npm:^1.10.0, resolve@npm:^1.10.1, resolve@npm:^1.20.0": +"resolve@npm:^1.10.0, resolve@npm:^1.10.1": version: 1.20.0 resolution: "resolve@npm:1.20.0" dependencies: @@ -3298,7 +3356,20 @@ __metadata: languageName: node linkType: hard -"resolve@patch:resolve@^1.10.0#~builtin, resolve@patch:resolve@^1.10.1#~builtin, resolve@patch:resolve@^1.20.0#~builtin": +"resolve@npm:^1.20.0": + version: 1.22.0 + resolution: "resolve@npm:1.22.0" + dependencies: + is-core-module: ^2.8.1 + path-parse: ^1.0.7 + supports-preserve-symlinks-flag: ^1.0.0 + bin: + resolve: bin/resolve + checksum: a2d14cc437b3a23996f8c7367eee5c7cf8149c586b07ca2ae00e96581ce59455555a1190be9aa92154785cf9f2042646c200d0e00e0bbd2b8a995a93a0ed3e4e + languageName: node + linkType: hard + +"resolve@patch:resolve@^1.10.0#~builtin, resolve@patch:resolve@^1.10.1#~builtin": version: 1.20.0 resolution: "resolve@patch:resolve@npm%3A1.20.0#~builtin::version=1.20.0&hash=07638b" dependencies: @@ -3308,6 +3379,19 @@ __metadata: languageName: node linkType: hard +"resolve@patch:resolve@^1.20.0#~builtin": + version: 1.22.0 + resolution: "resolve@patch:resolve@npm%3A1.22.0#~builtin::version=1.22.0&hash=07638b" + dependencies: + is-core-module: ^2.8.1 + path-parse: ^1.0.7 + supports-preserve-symlinks-flag: ^1.0.0 + bin: + resolve: bin/resolve + checksum: c79ecaea36c872ee4a79e3db0d3d4160b593f2ca16e031d8283735acd01715a203607e9ded3f91f68899c2937fa0d49390cddbe0fb2852629212f3cda283f4a7 + languageName: node + linkType: hard + "retry@npm:^0.12.0": version: 0.12.0 resolution: "retry@npm:0.12.0" @@ -3408,13 +3492,6 @@ __metadata: languageName: node linkType: hard -"setimmediate@npm:^1.0.5": - version: 1.0.5 - resolution: "setimmediate@npm:1.0.5" - checksum: c9a6f2c5b51a2dabdc0247db9c46460152ffc62ee139f3157440bd48e7c59425093f42719ac1d7931f054f153e2d26cf37dfeb8da17a794a58198a2705e527fd - languageName: node - linkType: hard - "sha.js@npm:^2.4.11": version: 2.4.11 resolution: "sha.js@npm:2.4.11" @@ -3667,6 +3744,13 @@ __metadata: languageName: node linkType: hard +"supports-preserve-symlinks-flag@npm:^1.0.0": + version: 1.0.0 + resolution: "supports-preserve-symlinks-flag@npm:1.0.0" + checksum: 53b1e247e68e05db7b3808b99b892bd36fb096e6fba213a06da7fab22045e97597db425c724f2bbd6c99a3c295e1e73f3e4de78592289f38431049e1277ca0ae + languageName: node + linkType: hard + "tar@npm:^6.0.2, tar@npm:^6.1.2": version: 6.1.11 resolution: "tar@npm:6.1.11" @@ -3726,15 +3810,29 @@ __metadata: languageName: node linkType: hard -"tsconfig-paths@npm:^3.11.0": - version: 3.11.0 - resolution: "tsconfig-paths@npm:3.11.0" +"tr46@npm:~0.0.3": + version: 0.0.3 + resolution: "tr46@npm:0.0.3" + checksum: 726321c5eaf41b5002e17ffbd1fb7245999a073e8979085dacd47c4b4e8068ff5777142fc6726d6ca1fd2ff16921b48788b87225cbc57c72636f6efa8efbffe3 + languageName: node + linkType: hard + +"ts-mixer@npm:^6.0.1": + version: 6.0.1 + resolution: "ts-mixer@npm:6.0.1" + checksum: 7050f6e85a24155d18cecdcc0a098d1038991cc498317fcffa9d7a8654c776d417fb97e65de1ce8e7ed54ef4814abd8057d0efb9c3b24e9cc78ac3c0f48bbf53 + languageName: node + linkType: hard + +"tsconfig-paths@npm:^3.12.0": + version: 3.12.0 + resolution: "tsconfig-paths@npm:3.12.0" dependencies: "@types/json5": ^0.0.29 json5: ^1.0.1 minimist: ^1.2.0 strip-bom: ^3.0.0 - checksum: e14aaa6883f316d611db41cbb0fc8779b59c66b31d1e045565ad4540c77ccd3d2bb66f7c261b74ff535d3cc6b4a1ce21dc84774bf2a2a603ed6b0fb96f7e0cc7 + checksum: 4999ec6cd1c7cc06750a460dbc0d39fe3595a4308cb5f1d0d0a8283009cf9c0a30d5a156508c28fe3a47760508af5263ab288fc23d71e9762779674257a95d3b languageName: node linkType: hard @@ -3752,6 +3850,13 @@ __metadata: languageName: node linkType: hard +"tslib@npm:^2.3.1": + version: 2.3.1 + resolution: "tslib@npm:2.3.1" + checksum: de17a98d4614481f7fcb5cd53ffc1aaf8654313be0291e1bfaee4b4bb31a20494b7d218ff2e15017883e8ea9626599b3b0e0229c18383ba9dce89da2adf15cb9 + languageName: node + linkType: hard + "tsutils@npm:^3.17.1, tsutils@npm:^3.21.0": version: 3.21.0 resolution: "tsutils@npm:3.21.0" @@ -3763,13 +3868,6 @@ __metadata: languageName: node linkType: hard -"tweetnacl@npm:^1.0.3": - version: 1.0.3 - resolution: "tweetnacl@npm:1.0.3" - checksum: e4a57cac188f0c53f24c7a33279e223618a2bfb5fea426231991652a13247bea06b081fd745d71291fcae0f4428d29beba1b984b1f1ce6f66b06a6d1ab90645c - languageName: node - linkType: hard - "type-check@npm:^0.4.0, type-check@npm:~0.4.0": version: 0.4.0 resolution: "type-check@npm:0.4.0" @@ -4010,6 +4108,13 @@ __metadata: languageName: node linkType: hard +"webidl-conversions@npm:^3.0.0": + version: 3.0.1 + resolution: "webidl-conversions@npm:3.0.1" + checksum: c92a0a6ab95314bde9c32e1d0a6dfac83b578f8fa5f21e675bc2706ed6981bc26b7eb7e6a1fab158e5ce4adf9caa4a0aee49a52505d4d13c7be545f15021b17c + languageName: node + linkType: hard + "websocket@npm:^1.0.28": version: 1.0.34 resolution: "websocket@npm:1.0.34" @@ -4024,6 +4129,16 @@ __metadata: languageName: node linkType: hard +"whatwg-url@npm:^5.0.0": + version: 5.0.0 + resolution: "whatwg-url@npm:5.0.0" + dependencies: + tr46: ~0.0.3 + webidl-conversions: ^3.0.0 + checksum: b8daed4ad3356cc4899048a15b2c143a9aed0dfae1f611ebd55073310c7b910f522ad75d727346ad64203d7e6c79ef25eafd465f4d12775ca44b90fa82ed9e2c + languageName: node + linkType: hard + "which-boxed-primitive@npm:^1.0.2": version: 1.0.2 resolution: "which-boxed-primitive@npm:1.0.2" @@ -4082,9 +4197,9 @@ __metadata: languageName: node linkType: hard -"ws@npm:^7.4.4": - version: 7.4.5 - resolution: "ws@npm:7.4.5" +"ws@npm:^8.3.0": + version: 8.3.0 + resolution: "ws@npm:8.3.0" peerDependencies: bufferutil: ^4.0.1 utf-8-validate: ^5.0.2 @@ -4093,13 +4208,13 @@ __metadata: optional: true utf-8-validate: optional: true - checksum: 5c7d1527f93ef27f9306aaf52db76315e8ff84174d1df717196527c50334c80bc10307dcaf6674a9aca4bb73aac3f77c23d3d9b1800e8aa810a5ee7f52d67cfb + checksum: 71f6919e3cb2c60ae53e00b13d7782bb77005750641855153a1716c23b7011259fe3a29a432522a3044dc7c579a7e2f5a495bb79ba9f823ce6c2e763300ef99b languageName: node linkType: hard -"ws@npm:^8.3.0": - version: 8.3.0 - resolution: "ws@npm:8.3.0" +"ws@npm:^8.6.0": + version: 8.6.0 + resolution: "ws@npm:8.6.0" peerDependencies: bufferutil: ^4.0.1 utf-8-validate: ^5.0.2 @@ -4108,7 +4223,7 @@ __metadata: optional: true utf-8-validate: optional: true - checksum: 71f6919e3cb2c60ae53e00b13d7782bb77005750641855153a1716c23b7011259fe3a29a432522a3044dc7c579a7e2f5a495bb79ba9f823ce6c2e763300ef99b + checksum: e2fca82059f1e087d0c78e2f37135e1b8332bc804fce46f83c2db1cb8571685abf9d2c99b964bab3752536ad90b99b46fb8d1428899aed3e560684ab4641bffd languageName: node linkType: hard