diff --git a/refactor/commands/chatInput/ngword.ts b/refactor/commands/chatInput/ngword.ts new file mode 100644 index 0000000..1878b45 --- /dev/null +++ b/refactor/commands/chatInput/ngword.ts @@ -0,0 +1,143 @@ +import { + ApplicationIntegrationType, + AutoModerationActionType, + AutoModerationRuleEventType, + AutoModerationRuleTriggerType, + ChatInputCommandInteraction, + InteractionContextType, + PermissionFlagsBits, + SlashCommandBuilder, + type SlashCommandSubcommandsOnlyBuilder, +} from 'discord.js'; +import { constants } from '../../config/constants.js'; +import { type ChatInputCommand } from '../../core/types/ChatInputCommand.js'; +import { type CommandSetting } from '../../core/types/CommandSetting.js'; +import { GuildSetting } from '../../database/entities/GuildSetting.js'; +import { GuildSettingManager } from '../../utils/GuildSettingManager.js'; +import { userFormat } from '../../utils/userFormat.js'; +type Rule = { + name: string; + value: 'invite-link' | 'token' | 'mention' | 'email'; +}; +export default class NgWord implements ChatInputCommand { + public command: SlashCommandBuilder | SlashCommandSubcommandsOnlyBuilder; + public settings: CommandSetting; + get rules(): Rule[] { + return [ + { name: '招待リンク(おすすめ)', value: 'invite-link' }, + { name: 'トークン(おすすめ)', value: 'token' }, + { name: '全員メンション', value: 'mention' }, + { name: 'メールアドレス', value: 'email' }, + ]; + } + getRuleRegex(ruleName: 'invite-link' | 'token' | 'mention' | 'email') { + const data = { + 'invite-link': [ + constants.regexs.inviteUrls.dicoall, + constants.regexs.inviteUrls.disboard, + constants.regexs.inviteUrls.discoparty, + constants.regexs.inviteUrls.discord, + constants.regexs.inviteUrls.discordCafe, + constants.regexs.inviteUrls.dissoku, + constants.regexs.inviteUrls.sabach, + ], + token: [constants.regexs.discordToken], + mention: [constants.regexs.mention], + email: [constants.regexs.email], + }; + return data[ruleName].map((regex) => `${regex}`); + } + constructor() { + this.command = new SlashCommandBuilder() + .setName('ngword') + .setDescription('NGワード関連コマンド') + .addSubcommand((input) => + input + .setName('add') + .setDescription('AutoModにAquedが提供するルールを追加します') + .addStringOption((input) => + input.setName('rule').setDescription('ルール').addChoices(this.rules).setRequired(true), + ), + ) + .addSubcommand((input) => + input + .setName('remove') + .setDescription('AutoModからAquedが提供するルールを削除します') + .addStringOption((input) => + input.setName('rule').setDescription('ルール').addChoices(this.rules).setRequired(true), + ), + ) + .setContexts(InteractionContextType.Guild) + .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild); + + this.settings = { enable: true, permissions: [PermissionFlagsBits.ManageGuild] }; + } + isValidValue(value: string): value is Rule['value'] { + return this.rules.some((rule) => rule.value === value); + } + + async run(interaction: ChatInputCommandInteraction) { + const commandName = interaction.options.getSubcommand(); + const ruleName = interaction.options.getString('rule'); + if (!ruleName || !commandName || !interaction.inCachedGuild()) return; + if (!this.isValidValue(ruleName)) { + return await interaction.reply({ content: 'ルールが存在しません。', ephemeral: true }); + } + + const settings = new GuildSettingManager(interaction.guildId); + const setting = (await settings.getSetting()) ?? new GuildSetting(interaction.guildId); + switch (commandName) { + case 'add': { + const rule = await interaction.guild.autoModerationRules.create({ + name: `${ruleName} By Aqued`, + eventType: AutoModerationRuleEventType.MessageSend, + triggerType: AutoModerationRuleTriggerType.Keyword, + triggerMetadata: { + regexPatterns: this.getRuleRegex(ruleName).map((value) => String(value)), + }, + actions: [{ type: AutoModerationActionType.BlockMessage }], + enabled: true, + reason: `${userFormat(interaction.member)}によって作成されました。`, + }); + + const autoMods: string[] = setting.autoMods ?? []; + autoMods.push(rule.id); + await settings.setSetting({ autoMods }); + return await interaction.reply('登録しました!'); + } + + case 'remove': { + if (!setting.autoMods || setting.autoMods.length === 0) { + return await interaction.reply({ content: 'AutoModがAquedによって登録されていません', ephemeral: true }); + } + + let removedCount = 0; + for (const id of setting.autoMods) { + const rule = interaction.guild.autoModerationRules.cache.find( + (value) => value.id === id && value.name === `${ruleName} By Aqued`, + ); + + if (rule) { + await rule.delete(`${userFormat(interaction.member)}によって${rule.name}が削除されました`); + const index = setting.autoMods.indexOf(id); + if (index !== -1) { + setting.autoMods.splice(index, 1); + removedCount++; + } + } + } + + if (removedCount > 0) { + await settings.setSetting({ autoMods: setting.autoMods }); + return await interaction.reply(`削除しました (${removedCount} 件のルール)。`); + } else { + return await interaction.reply({ content: '削除するルールが見つかりませんでした。', ephemeral: true }); + } + } + + default: + return; + } + } +} diff --git a/refactor/commands/chatInput/ping.ts b/refactor/commands/chatInput/ping.ts index 2daf450..0189cbf 100644 --- a/refactor/commands/chatInput/ping.ts +++ b/refactor/commands/chatInput/ping.ts @@ -7,11 +7,13 @@ export default class Ping implements ChatInputCommand { public settings: CommandSetting; constructor() { this.command = new SlashCommandBuilder().setName('ping').setDescription('ping!!!'); - this.settings = {}; + this.settings = { enable: true }; } async run(interaction: ChatInputCommandInteraction) { await interaction.reply({ - embeds: [new EmbedBuilder().setTitle(':ping_pong: | Pong!').setDescription(`\`${interaction.client.ws.ping} ms\``)], + embeds: [ + new EmbedBuilder().setTitle(':ping_pong: | Pong!').setDescription(`\`${interaction.client.ws.ping} ms\``), + ], }); } } diff --git a/refactor/commands/chatInput/top.ts b/refactor/commands/chatInput/top.ts new file mode 100644 index 0000000..9519ffc --- /dev/null +++ b/refactor/commands/chatInput/top.ts @@ -0,0 +1,38 @@ +import { ChatInputCommandInteraction, EmbedBuilder, InteractionContextType, SlashCommandBuilder } from 'discord.js'; +import { inspect } from 'util'; +import { Logger } from '../../core/Logger.js'; +import { type ChatInputCommand } from '../../core/types/ChatInputCommand.js'; +import { type CommandSetting } from '../../core/types/CommandSetting.js'; + +export default class Top implements ChatInputCommand { + public command: SlashCommandBuilder; + public settings: CommandSetting; + constructor() { + this.command = new SlashCommandBuilder() + .setName('top') + .setDescription('top!!!') + .setContexts(InteractionContextType.Guild); + this.settings = { enable: true }; + } + async run(interaction: ChatInputCommandInteraction) { + try { + if (!interaction.channel) return; + const messages = await interaction.channel.messages.fetch({ after: '0', limit: 1 }); + const message = messages.first(); + if (message) { + await interaction.reply({ + embeds: [new EmbedBuilder().setDescription(`[**一番上のメッセージへジャンプ!**](${message.url})`)], + }); + } else { + await interaction.reply({ + embeds: [new EmbedBuilder().setAuthor({ name: 'メッセージの取得に失敗' })], + }); + } + } catch (error) { + Logger.error(inspect(error)); + await interaction.reply({ + embeds: [new EmbedBuilder().setAuthor({ name: 'メッセージの取得に失敗' })], + }); + } + } +} diff --git a/refactor/config/constants.ts b/refactor/config/constants.ts index 3431db1..b465d0d 100644 --- a/refactor/config/constants.ts +++ b/refactor/config/constants.ts @@ -31,4 +31,18 @@ export const constants = { }, loggerThreadId: '', }, + regexs: { + inviteUrls: { + dissoku: /dissoku\.net/g, + disboard: /disboard\.org/g, + discoparty: /discoparty\.jp/g, + discord: /(https?:\/\/)?(www\.)?(discord\.(gg|com|net)|discordapp\.(com|net)\/invite)\/[\dA-Za-z]+/g, + discordCafe: /discordcafe\.app/g, + dicoall: /dicoall\.com/g, + sabach: /sabach\.jp/g, + }, + mention: /@everyone|@here/, + discordToken: /^(mfa\.[a-z0-9_-]{20,})|([a-z0-9_-]{23,28}\.[a-z0-9_-]{6,7}\.[a-z0-9_-]{27})$/gm, + email: /[^@\s]+@[^@\s]+\.[^@\s]+/, + }, }; diff --git a/refactor/core/client.ts b/refactor/core/client.ts index 2bca1c3..d2137c0 100644 --- a/refactor/core/client.ts +++ b/refactor/core/client.ts @@ -2,6 +2,7 @@ import { ActivityType, Client, GatewayIntentBits, SnowflakeUtil } from 'discord. import { config } from '../config/config.js'; import { CommandLoader } from './CommandLoader.js'; import { EventLoader } from './EventLoader.js'; +import { dataSource } from './typeorm.config.js'; export const client = new Client({ intents: [ GatewayIntentBits.Guilds, @@ -22,6 +23,7 @@ declare module 'discord.js' { config: typeof config; commands: { chatInput: CommandLoader }; readyId: string; + cooldown: Map>; }; } } @@ -32,6 +34,8 @@ client.aqued = { chatInput: new CommandLoader('commands/chatInput'), }, readyId: SnowflakeUtil.generate().toString(), + cooldown: new Map(), }; await client.aqued.events.loadAllEvents(); +await dataSource.initialize(); diff --git a/refactor/core/typeorm.config.ts b/refactor/core/typeorm.config.ts new file mode 100644 index 0000000..0131151 --- /dev/null +++ b/refactor/core/typeorm.config.ts @@ -0,0 +1,16 @@ +import 'reflect-metadata'; +import { DataSource } from 'typeorm'; +import { config } from '../config/config.js'; +import { entities } from '../database/entities/index.js'; + +export const dataSource = new DataSource({ + type: 'mysql', + host: config.mysql.host, + username: config.mysql.user, + password: config.mysql.password, + port: config.mysql.port, + database: 'aqued', + synchronize: true, // for "develop" + dropSchema: true, // for "develop" + entities: entities, +}); diff --git a/refactor/core/types/ChatInputCommand.ts b/refactor/core/types/ChatInputCommand.ts index 4a7d8c4..936fd5d 100644 --- a/refactor/core/types/ChatInputCommand.ts +++ b/refactor/core/types/ChatInputCommand.ts @@ -1,6 +1,8 @@ -import { ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js'; +import { ChatInputCommandInteraction, SlashCommandBuilder, type SlashCommandSubcommandsOnlyBuilder } from 'discord.js'; +import type { CommandSetting } from './CommandSetting.js'; export interface ChatInputCommand { - command: SlashCommandBuilder; + command: SlashCommandBuilder | SlashCommandSubcommandsOnlyBuilder; + settings: CommandSetting; run(interaction: ChatInputCommandInteraction): Promise; } diff --git a/refactor/core/types/CommandSetting.ts b/refactor/core/types/CommandSetting.ts index 3405485..4cecbc6 100644 --- a/refactor/core/types/CommandSetting.ts +++ b/refactor/core/types/CommandSetting.ts @@ -4,11 +4,7 @@ import { ChannelType } from 'discord.js'; */ export interface CommandSetting { /** - * 実行前に評価する関数 - */ - check?: () => boolean; - /** - * 必須権限(実行時にもチェックします) + * 必須権限(実行時にBotと実行ユーザー側でチェックします) */ permissions?: bigint[]; /** @@ -31,16 +27,4 @@ export interface CommandSetting { * 実行できるチャンネルのタイプ */ channelTypes?: ChannelType[]; - /** - * クールダウン - */ - cooldown?: number; - /** - * クールダウンの対象 - */ - cooldownTargets?: 'guild' | 'user' | 'channel' | 'bot'; - /** - * クールダウンになるまでの回数 - */ - cooldownLimit?: number; } diff --git a/refactor/database/entities/GuildSetting.ts b/refactor/database/entities/GuildSetting.ts new file mode 100644 index 0000000..ed2bebb --- /dev/null +++ b/refactor/database/entities/GuildSetting.ts @@ -0,0 +1,13 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +@Entity({ name: 'GUILD_SETTING' }) +export class GuildSetting { + @PrimaryColumn({ name: 'GUILD_ID', type: 'bigint', comment: 'ギルドID' }) + guildId: string; + @Column({ name: 'AUTO_MODS', type: 'simple-array', comment: 'Aquedにより設定されたAutoModのId配列', nullable: true }) + autoMods?: string[]; + + constructor(guildId: string) { + this.guildId = guildId; + } +} diff --git a/refactor/database/entities/index.ts b/refactor/database/entities/index.ts new file mode 100644 index 0000000..98b3fe0 --- /dev/null +++ b/refactor/database/entities/index.ts @@ -0,0 +1,3 @@ +import { GuildSetting } from './GuildSetting.js'; + +export const entities = [GuildSetting]; diff --git a/refactor/events/interaction.ts b/refactor/events/interaction.ts index e79750f..6fdf8a8 100644 --- a/refactor/events/interaction.ts +++ b/refactor/events/interaction.ts @@ -1,24 +1,107 @@ -import { BaseInteraction, Events } from 'discord.js'; +import { BaseInteraction, Events, GuildMember } from 'discord.js'; import { oldButtonPaginationDisable } from '../components/button/pagenation.js'; +import { config } from '../config/config.js'; import type { EventListener } from '../core/types/EventListener.js'; + export default class InteractionCommandHandler implements EventListener { public name: Events.InteractionCreate; public once: boolean; + constructor() { this.name = Events.InteractionCreate; this.once = false; } + async execute(interaction: BaseInteraction) { if (interaction.isChatInputCommand()) { const command = interaction.client.aqued.commands.chatInput.getCommand(interaction.commandName); - if (command) await command.run(interaction); - else - await interaction.reply({ - content: 'コマンドをリロード中です。', + + if (command) { + const setting = command.settings; + if (!setting.enable) { + return await interaction.reply({ + content: 'このコマンドは無効です!', + ephemeral: true, + }); + } + if (setting.guildOnly && !interaction.inGuild()) { + return await interaction.reply({ + content: 'このコマンドはサーバー内でのみ使用できます!', + ephemeral: true, + }); + } + if (setting.adminOnly && !config.bot.admins.includes(interaction.user.id)) + return await interaction.reply({ + content: 'このコマンドはAquedの管理者のみ使用できます!', + ephemeral: true, + }); + if (setting.modOnly && !config.bot.mods.includes(interaction.user.id)) + return await interaction.reply({ + content: 'このコマンドはAquedのモデレーターのみ使用できます!', + ephemeral: true, + }); + if (setting.channelTypes && interaction.channel && !setting.channelTypes.includes(interaction.channel.type)) { + return await interaction.reply({ + content: 'このコマンドは以下のチャンネルのみで使用いただけます!\n' + setting.channelTypes.join('\n'), + ephemeral: true, + }); + } + if ( + interaction.member instanceof GuildMember && + interaction.channel && + !interaction.channel.isDMBased() && + setting.permissions + ) { + for (const value of setting.permissions) { + if (!interaction.member.permissionsIn(interaction.channel).has(value)) { + if (interaction.replied) + await interaction.followUp({ + content: 'このコマンドを実行する権限がありません: ' + value.toString(), + ephemeral: true, + }); + else + await interaction.reply({ + content: 'このコマンドを実行する権限がありません: ' + value.toString(), + ephemeral: true, + }); + } + } + } + if (!interaction.client.aqued.cooldown.has(command.command.name)) + interaction.client.aqued.cooldown.set(command.command.name, new Map()); + + const now = Date.now(); + const timestamps = interaction.client.aqued.cooldown.get(command.command.name); + const cooldownAmount = 3000; + if (!timestamps) return; + const userCooldown = timestamps.get(interaction.user.id); + if (userCooldown) { + const expirationTime = userCooldown + cooldownAmount; + + if (now < expirationTime) { + const timeLeft = (expirationTime - now) / 1000; + return await interaction.reply({ + content: `クールダウン中です。あと、\`${timeLeft.toFixed(1)}\`秒お待ちください。`, + ephemeral: true, + }); + } + } + + timestamps.set(interaction.user.id, now); + setTimeout(() => timestamps.delete(interaction.user.id), cooldownAmount); + + return await command.run(interaction); + } else { + return await interaction.reply({ + content: + 'コマンドが存在しません...\n恐らくコマンドがまだ読み込まれていないのでしょう。\nこれは非常に稀です!おめでとうございます', ephemeral: true, }); + } } else if (interaction.isButton()) { - await oldButtonPaginationDisable(interaction); + return await oldButtonPaginationDisable(interaction); + } else { + return; } } } diff --git a/refactor/utils/GuildSettingManager.ts b/refactor/utils/GuildSettingManager.ts new file mode 100644 index 0000000..b5d3178 --- /dev/null +++ b/refactor/utils/GuildSettingManager.ts @@ -0,0 +1,25 @@ +import { dataSource } from '../core/typeorm.config.js'; +import { GuildSetting } from '../database/entities/GuildSetting.js'; + +export class GuildSettingManager { + guildId: string; + constructor(guildId: string) { + this.guildId = guildId; + } + async getSetting() { + const repo = dataSource.getRepository(GuildSetting); + const guildSetting = await repo.findOne({ where: { guildId: this.guildId } }); + return guildSetting; + } + + async setSetting(updated: Partial) { + let guildSetting = await this.getSetting(); + return dataSource.transaction(async (em) => { + const repo = em.getRepository(GuildSetting); + if (!guildSetting) guildSetting = new GuildSetting(this.guildId); + Object.assign(guildSetting, updated); + await repo.save(guildSetting); + return guildSetting; + }); + } +} diff --git a/refactor/utils/userFormat.ts b/refactor/utils/userFormat.ts new file mode 100644 index 0000000..443c32a --- /dev/null +++ b/refactor/utils/userFormat.ts @@ -0,0 +1,23 @@ +import { GuildMember, User } from 'discord.js'; + +export const userFormat = (user: User | GuildMember): string => { + const getUserName = (u: User) => { + if (u.discriminator === '0') { + return u.globalName ? `${u.globalName} (@${u.username})` : `@${u.username}`; + } else { + return u.globalName ? `${u.globalName} (${u.username}#${u.discriminator})` : `${u.username}#${u.discriminator}`; + } + }; + + if (user instanceof User) { + return getUserName(user); + } + + if (user instanceof GuildMember) { + const { nickname, user: memberUser } = user; + const formattedName = getUserName(memberUser); + return nickname ? `${nickname} (${formattedName})` : formattedName; + } + + return '不明なユーザー(これはバグです。Aqued開発者に報告してください)'; +}; diff --git a/refactor/utils/webhookChecker.ts b/refactor/utils/webhookChecker.ts new file mode 100644 index 0000000..d8f297d --- /dev/null +++ b/refactor/utils/webhookChecker.ts @@ -0,0 +1,3 @@ +export const webhookChecker = (discriminator: string) => { + return discriminator === '0000'; +};