diff --git a/src/config.ts b/src/config.ts index 8923bffa..85c94101 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,10 +10,12 @@ export interface ConfigInterface { meetingVote?: string vcSpeechLog?: string discussion?: string + other?: string } role?: { admin?: string verified?: string + nitrotan?: string } } translateGasUrl?: string diff --git a/src/discord.ts b/src/discord.ts index 9ee909a1..838e5a41 100644 --- a/src/discord.ts +++ b/src/discord.ts @@ -53,11 +53,17 @@ import { SearchImageCommand } from './commands/searchimg' import { AkakeseCommand } from './commands/akakese' import { ToenCommand } from './commands/toen' import { UnmuteCommand } from './commands/unmute' +import { NitrotanReactionEvent } from './events/nitrotan-reaction' +import { NitrotanMessageEvent } from './events/nitrotan-message' +import { NitrotanOptimizeTask } from './tasks/nitrotan-optimize' +import { NitrotanProfileTask } from './tasks/nitrotan-profile' export class Discord { private config: Configuration public readonly client: Client + private readonly tasks: BaseDiscordTask[] = [] + public static readonly commands: BaseCommand[] = [ new AkakeseCommand(), new AlphaCommand(), @@ -126,6 +132,8 @@ export class Discord { new MeetingNewVoteEvent(this), new MeetingReactionVoteEvent(this), new NewDiscussionMention(this), + new NitrotanMessageEvent(this), + new NitrotanReactionEvent(this), new PinPrefixEvent(this), new PinReactionEvent(this), new VCSpeechLogMessageUrlEvent(this), @@ -138,10 +146,17 @@ export class Discord { Logger.configure('Discord.login').error('❌ login failed', error as Error) }) - const tasks: BaseDiscordTask[] = [new MeetingVoteTask(this)] - for (const task of tasks) { + this.tasks = [ + new MeetingVoteTask(this), + new NitrotanOptimizeTask(this), + new NitrotanProfileTask(this), + ] + for (const task of this.tasks) { task.register().catch((error: unknown) => { - Logger.configure('Discord.task').error('❌ task failed', error as Error) + Logger.configure('Discord.task').error( + '❌ task register failed', + error as Error + ) }) } @@ -168,6 +183,12 @@ export class Discord { onReady() { const logger = Logger.configure('Discord.onReady') logger.info(`👌 ready: ${this.client.user?.tag}`) + + for (const task of this.tasks) { + task.execute().catch((error: unknown) => { + logger.error('❌ task execute failed', error as Error) + }) + } } async onMessageCreate(message: Message) { diff --git a/src/events/nitrotan-message.ts b/src/events/nitrotan-message.ts new file mode 100644 index 00000000..b0fae4e3 --- /dev/null +++ b/src/events/nitrotan-message.ts @@ -0,0 +1,140 @@ +import { GuildPremiumTier, Message } from 'discord.js' +import { BaseDiscordEvent } from '.' +import { + Nitrotan, + NitrotanReason, + NitrotanReasonType, +} from '@/features/nitrotan' + +/** + * Nitrotan判別で、メッセージ投稿に関するイベントのハンドラー + */ +export class NitrotanMessageEvent extends BaseDiscordEvent<'messageCreate'> { + readonly eventName = 'messageCreate' + + private emojiRegex = /<(?a?):(?\w+):(?\d+)>/g + + async execute(message: Message): Promise { + if (!message.guild) return + + const userId = message.author.id + + const nitrotan = await Nitrotan.of(this.discord) + if (nitrotan.isNitrotan(userId)) return + + const reason = this.getNitrotanReason(message) + if (!reason) return + + await nitrotan.add(userId, reason) + } + + /** + * Nitroユーザーが取れる行動かどうか。かつどんな操作であるか + */ + private getNitrotanReason(message: Message): NitrotanReasonType | null { + if (this.isAnimationEmojiMessage(message)) { + return NitrotanReason.USE_ANIMATION_EMOJI_MESSAGE + } + if (this.isOtherServerEmojiMessage(message)) { + return NitrotanReason.USE_OTHER_SERVER_EMOJI_MESSAGE + } + if (this.isCustomStickerMessage(message)) { + return NitrotanReason.USE_OTHER_SERVER_STICKER + } + if (this.isOverBoostFileMessage(message)) { + return NitrotanReason.SEND_LARGE_FILE + } + if (this.isOver2000CharactersMessage(message)) { + return NitrotanReason.SEND_LARGE_MESSAGE + } + + return null + } + + /** + * アニメーション絵文字を使用したメッセージを送信したか + * + * @param message メッセージ + * @returns アニメーション絵文字を使用したメッセージを送信したか + */ + private isAnimationEmojiMessage(message: Message): boolean { + const matches = message.content.matchAll(this.emojiRegex) + + return [...matches].some((match) => match.groups?.animated === 'a') + } + + /** + * 他サーバーの絵文字を使用したメッセージを送信したか + * + * @param message メッセージ + * @returns 他サーバーの絵文字を使用したメッセージを送信したか + */ + private isOtherServerEmojiMessage(message: Message): boolean { + const matches = message.content.matchAll(this.emojiRegex) + + const guildEmojis = message.guild?.emojis.cache ?? new Map() + + return [...matches].some((match) => { + const emojiId = match.groups?.id + if (!emojiId) return false + return !guildEmojis.has(emojiId) + }) + } + + /** + * サーバにないカスタムスタンプを使用したか + * + * @param message メッセージ + * @returns サーバにないカスタムスタンプを使用したか + */ + private isCustomStickerMessage(message: Message): boolean { + const stickers = message.stickers + + const guildStickers = + message.guild?.stickers.cache ?? new Map() + + return stickers.some((sticker) => { + const stickerId = sticker.id + if (!stickerId) return false + return !guildStickers.has(stickerId) + }) + } + + /** + * ブーストによる上限値より大きなファイルを送信したか + * + * @param message メッセージ + * @returns ブーストによる上限値より大きなファイルを送信したか + */ + private isOverBoostFileMessage(message: Message): boolean { + const premiumTier = message.guild?.premiumTier ?? 0 + + const maxFileSize = (() => { + switch (premiumTier) { + case GuildPremiumTier.Tier2: { + return 50 + } // ブーストLv2 + case GuildPremiumTier.Tier3: { + return 100 + } // ブーストLv3 + default: { + return 25 + } // 通常、またはブーストLv1 + } + })() + + return message.attachments.some( + (attachment) => attachment.size > maxFileSize * 1024 * 1024 + ) + } + + /** + * 2000文字より多い文章を投稿したか + * + * @param message メッセージ + * @returns 2000文字より多い文章を投稿したか + */ + private isOver2000CharactersMessage(message: Message): boolean { + return message.content.length > 2000 + } +} diff --git a/src/events/nitrotan-reaction.ts b/src/events/nitrotan-reaction.ts new file mode 100644 index 00000000..e0d9ccb0 --- /dev/null +++ b/src/events/nitrotan-reaction.ts @@ -0,0 +1,88 @@ +import { + MessageReaction, + PartialMessageReaction, + PartialUser, + User, +} from 'discord.js' +import { BaseDiscordEvent } from '.' +import { + Nitrotan, + NitrotanReason, + NitrotanReasonType, +} from '@/features/nitrotan' + +/** + * Nitrotan判別で、メッセージ投稿に関するイベントのハンドラー + */ +export class NitrotanReactionEvent extends BaseDiscordEvent<'messageReactionAdd'> { + readonly eventName = 'messageReactionAdd' + + // アニメーション絵文字をリアクションした + // 他サーバーの絵文字をリアクションした + // スーパーリアクションを使用した + + async execute( + reaction: MessageReaction | PartialMessageReaction, + user: User | PartialUser + ): Promise { + // 部分的なオブジェクトをフェッチ + reaction = reaction.partial ? await reaction.fetch() : reaction + user = user.partial ? await user.fetch() : user + + const message = reaction.message.partial + ? await reaction.message.fetch() + : reaction.message + if (!message.guild) return + + const nitrotan = await Nitrotan.of(this.discord) + if (nitrotan.isNitrotan(user.id)) return + + const reason = this.getNitrotanReason(reaction) + if (!reason) return + + await nitrotan.add(user.id, reason) + } + + /** + * Nitroユーザーが取れる行動かどうか。かつどんな操作であるか + */ + private getNitrotanReason( + reaction: MessageReaction + ): NitrotanReasonType | null { + if (this.isAnimationEmojiReaction(reaction)) { + return NitrotanReason.USE_ANIMATION_EMOJI_REACTION + } + if (this.isOtherServerEmojiReaction(reaction)) { + return NitrotanReason.USE_OTHER_SERVER_EMOJI_REACTION + } + + return null + } + + /** + * アニメーション絵文字を最初にリアクションしたか + * + * @param reaction リアクション + * @returns アニメーション絵文字を最初にリアクションしたか + */ + private isAnimationEmojiReaction(reaction: MessageReaction): boolean { + if (reaction.count !== 1) return false + + return reaction.emoji.animated ?? false + } + + /** + * 他サーバーの絵文字を最初にリアクションしたか + * + * @param reaction リアクション + * @returns 他サーバーの絵文字を最初にリアクションしたか + */ + private isOtherServerEmojiReaction(reaction: MessageReaction): boolean { + if (reaction.count !== 1) return false + + return ( + 'guild' in reaction.emoji && + reaction.emoji.guild.id === reaction.message.guild?.id + ) + } +} diff --git a/src/features/nitrotan.ts b/src/features/nitrotan.ts new file mode 100644 index 00000000..e31c7d29 --- /dev/null +++ b/src/features/nitrotan.ts @@ -0,0 +1,305 @@ +import { Discord } from '@/discord' +import { Logger } from '@book000/node-utils' +import { Guild, Role, TextChannel } from 'discord.js' +import fs from 'node:fs' + +export const NitrotanReason = { + USE_ANIMATION_EMOJI_MESSAGE: 'USE_ANIMATION_EMOJI_MESSAGE', + USE_ANIMATION_EMOJI_REACTION: 'USE_ANIMATION_EMOJI_REACTION', + USE_OTHER_SERVER_EMOJI_MESSAGE: 'USE_OTHER_SERVER_EMOJI_MESSAGE', + USE_OTHER_SERVER_EMOJI_REACTION: 'USE_OTHER_SERVER_EMOJI_REACTION', + USE_OTHER_SERVER_STICKER: 'USE_OTHER_SERVER_STICKER', + SEND_LARGE_FILE: 'SEND_LARGE_FILE', + AVATAR_ANIME: 'AVATAR_ANIME', + SERVER_ORIGINAL_AVATAR: 'SERVER_ORIGINAL_AVATAR', + BANNER: 'BANNER', + PROFILE_THEME: 'PROFILE_THEME', + SEND_LARGE_MESSAGE: 'SEND_LARGE_MESSAGE', +} as const + +/** Nitrotanの理由 */ +export type NitrotanReasonType = keyof typeof NitrotanReason + +const NitrotanReasonText: Record = { + USE_ANIMATION_EMOJI_MESSAGE: + 'アニメーション絵文字を使用したメッセージを送信した', + USE_ANIMATION_EMOJI_REACTION: 'アニメーション絵文字をリアクションした', + USE_OTHER_SERVER_EMOJI_MESSAGE: + '他サーバーの絵文字を使用したメッセージを送信した', + USE_OTHER_SERVER_EMOJI_REACTION: '他サーバーの絵文字をリアクションした', + USE_OTHER_SERVER_STICKER: '他サーバーのスタンプを使用した', + SEND_LARGE_FILE: 'ブーストによる上限値より大きなファイルを送信した', + AVATAR_ANIME: 'アニメーションアイコンを設定した', + SERVER_ORIGINAL_AVATAR: 'サーバ独自のアバターを設定した', + BANNER: 'バナーを設定した', + PROFILE_THEME: 'プロフィールテーマを設定した', + SEND_LARGE_MESSAGE: '2000文字より多い文章を投稿した', +} + +interface NitrotanUser { + /** DiscordのユーザーID */ + discordId: string + /** いつからNitroか */ + since: Date + /** 最後に確認した日時 */ + lastChecked: Date + /** 理由 */ + reason: NitrotanReasonType +} + +/** + * Nitroユーザーにロールをつけたり、Nitroではなくなったと思われるユーザーからロールを外す機能 + */ +export class Nitrotan { + private readonly guild: Guild + private readonly role: Role + private readonly channel: TextChannel + + private static nitrotans: NitrotanUser[] = [] + private static lastLoadAt: Date | null = null + + private constructor(guild: Guild, role: Role, channel: TextChannel) { + this.guild = guild + this.role = role + this.channel = channel + + this.load() + } + + public static async of(discord: Discord) { + const config = discord.getConfig() + + const channelId = + config.get('discord').channel?.other ?? '1149857948089192448' + const channel = + discord.client.channels.cache.get(channelId) ?? + (await discord.client.channels.fetch(channelId).catch(() => null)) + if (!channel) { + throw new Error('Channel not found') + } + if (!(channel instanceof TextChannel)) { + throw new TypeError('Channel is not a TextChannel') + } + + let guild: Guild + let guildId = config.get('discord').guildId + if (guildId) { + guild = + discord.client.guilds.cache.get(guildId) ?? + (await discord.client.guilds.fetch(guildId)) + } else { + guildId = channel.guild.id + guild = channel.guild + } + + const roleId = config.get('discord').role?.nitrotan ?? '1149583556138508328' + const role = + guild.roles.cache.get(roleId) ?? + (await guild.roles.fetch(roleId).catch(() => null)) + + if (!role) { + throw new Error('Role not found') + } + + return new Nitrotan(guild, role, channel) + } + + /** + * ファイルからNitroユーザーデータを読み込む。 + * 最終読み込み日時から30分以上経過している場合は読み込む。 + * + * @param force 最終読み込み日時を無視して強制的に読み込むか + */ + public load(force = false) { + if (!force && Nitrotan.lastLoadAt) { + const now = new Date() + const diff = now.getTime() - Nitrotan.lastLoadAt.getTime() + if (diff < 30 * 60 * 1000) { + return + } + } + const path = this.getPath() + + if (!fs.existsSync(path)) { + Nitrotan.nitrotans = [] + Nitrotan.lastLoadAt = new Date() + + this.save() + return + } + + const data = fs.readFileSync(path, 'utf8') + const nitrotans = JSON.parse(data) as NitrotanUser[] + // Date型に変換 + Nitrotan.nitrotans = nitrotans.map((nitrotan: NitrotanUser) => { + nitrotan.since = new Date(nitrotan.since) + nitrotan.lastChecked = new Date(nitrotan.lastChecked) + return nitrotan + }) + + Nitrotan.lastLoadAt = new Date() + } + + /** + * ファイルにNitroユーザーデータを保存する + */ + public save() { + const path = this.getPath() + fs.writeFileSync(path, JSON.stringify(Nitrotan.nitrotans, null, 2), 'utf8') + } + + /** + * ユーザーがNitroとして認識しているか + */ + public isNitrotan(discordId: string) { + this.load() + return Nitrotan.nitrotans.some( + (nitrotan) => nitrotan.discordId === discordId + ) + } + + /** + * ユーザーをNitroとして認識する + */ + public async add(discordId: string, reason: NitrotanReasonType) { + const logger = Logger.configure('Nitrotan.add') + if (this.isNitrotan(discordId)) { + return + } + + Nitrotan.nitrotans.push({ + discordId, + since: new Date(), + lastChecked: new Date(), + reason, + }) + this.save() + + const member = await this.getMember(discordId) + if (!member) { + return + } + + await member.roles.add( + this.role, + `Nitrotanとして認識されたため (${reason})` + ) + + const reasonText = NitrotanReasonText[reason] + await this.channel.send( + `\`${member.user.username}\` (${discordId}) を Nitrotan として認識しました。\n理由: ${reasonText}` + ) + + logger.info(`Added role ${member.user.username} [${discordId}] (${reason})`) + } + + /** + * Nitroとして認識しているユーザーを整理する + * + * - 最後に確認した日時から1週間以上経過しているユーザーはNitroではなくなったとみなす + * - Nitroではなくなったユーザーからロールを外す + * - ファイルに保存する + * - Nitroとして認識しているにもかかわらず、ロールがついていないユーザーにロールをつける + */ + public async optimize() { + const logger = Logger.configure('Nitrotan.optimize') + this.load() + + const now = new Date() + const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) + + const nolongerNitrotans = Nitrotan.nitrotans.filter( + (nitrotan) => nitrotan.lastChecked < oneWeekAgo + ) + const stillNitrotans = Nitrotan.nitrotans.filter( + (nitrotan) => nitrotan.lastChecked >= oneWeekAgo + ) + + Nitrotan.nitrotans = stillNitrotans + this.save() + + logger.info( + `Optimizing - no longer: ${nolongerNitrotans.length} / still: ${stillNitrotans.length}` + ) + + // Nitroではなくなったユーザーからロールを外す + for (const nitrotan of nolongerNitrotans) { + const member = await this.getMember(nitrotan.discordId) + if (!member) { + logger.warn(`Member not found: ${nitrotan.discordId}`) + continue + } + + await member.roles.remove( + this.role, + 'Nitroではなくなったと見なされたため' + ) + + await this.channel.send( + `\`${member.user.username}\` (${nitrotan.discordId}) は Nitro ではなくなったと見なされました。` + ) + + logger.info( + `Removed role ${member.user.username} [${nitrotan.discordId}] (${nitrotan.reason})` + ) + } + + // Nitroとして認識しているにもかかわらず、ロールがついていないユーザーにロールをつける + for (const nitrotan of stillNitrotans) { + const member = await this.getMember(nitrotan.discordId) + if (!member) { + logger.warn(`Member not found: ${nitrotan.discordId}`) + continue + } + + if (member.roles.cache.has(this.role.id)) { + continue + } + + await member.roles.add( + this.role, + `Nitrotanとして認識されたため (${nitrotan.reason})` + ) + + logger.info( + `Added role ${member.user.username} [${nitrotan.discordId}] (${nitrotan.reason})` + ) + } + } + + /** + * Nitrotanの一覧を取得する + */ + public getUsers() { + this.load() + return Nitrotan.nitrotans + } + + public getGuild() { + return this.guild + } + + private async getMember(discordId: string) { + const cacheMember = this.guild.members.cache.get(discordId) + if (cacheMember) { + return cacheMember + } + + return await this.guild.members.fetch(discordId).catch(() => null) + } + + /** + * Nitroユーザーデータのファイルパスを取得する + * + * - 環境変数 DATA_DIR が設定されている場合はそのディレクトリを使用する + * - 環境変数 DATA_DIR が設定されていない場合は data/ ディレクトリを使用する + * - ファイル名は nitrotan.json とする + * + * @returns ファイルパス + */ + private getPath() { + const dataDir = process.env.DATA_DIR ?? 'data/' + + return `${dataDir}/nitrotan.json` + } +} diff --git a/src/tasks/nitrotan-optimize.ts b/src/tasks/nitrotan-optimize.ts new file mode 100644 index 00000000..a3000e04 --- /dev/null +++ b/src/tasks/nitrotan-optimize.ts @@ -0,0 +1,17 @@ +import { BaseDiscordTask } from '.' +import { Nitrotan } from '@/features/nitrotan' + +/** + * Nitrotanの整理処理 + */ +export class NitrotanOptimizeTask extends BaseDiscordTask { + get interval(): number { + // 3時間毎 + return 3 * 60 * 60 + } + + async execute(): Promise { + const nitrotan = await Nitrotan.of(this.discord) + await nitrotan.optimize() + } +} diff --git a/src/tasks/nitrotan-profile.ts b/src/tasks/nitrotan-profile.ts new file mode 100644 index 00000000..440ebcfd --- /dev/null +++ b/src/tasks/nitrotan-profile.ts @@ -0,0 +1,85 @@ +import { GuildMember } from 'discord.js' +import { BaseDiscordTask } from '.' +import { + Nitrotan, + NitrotanReason, + NitrotanReasonType, +} from '@/features/nitrotan' + +/** + * Nitro判別で、プロフィールに関するチェック処理 + */ +export class NitrotanProfileTask extends BaseDiscordTask { + get interval(): number { + // 30分毎 + return 30 * 60 + } + + async execute(): Promise { + const nitrotan = await Nitrotan.of(this.discord) + const guild = nitrotan.getGuild() + + const members = await guild.members.fetch() + for (const [, member] of members) { + if (nitrotan.isNitrotan(member.id)) continue + + const reason = this.getNitrotanReason(member) + if (!reason) continue + + await nitrotan.add(member.id, reason) + } + } + + /** + * Nitroユーザーが取れる行動かどうか。かつどんな操作であるか + */ + private getNitrotanReason(member: GuildMember): NitrotanReasonType | null { + if (this.isGifAvatar(member)) { + return NitrotanReason.AVATAR_ANIME + } + + if (this.isDifferentAvatar(member)) { + return NitrotanReason.SERVER_ORIGINAL_AVATAR + } + + if (this.hasBanner(member)) { + return NitrotanReason.BANNER + } + + if (this.hasProfileTheme(member)) { + return NitrotanReason.PROFILE_THEME + } + + return null + } + + /** + * アバターがGIFであるかどうか + */ + private isGifAvatar(member: GuildMember): boolean { + const user = member.user + return user.avatarURL()?.endsWith('.gif') ?? false + } + + /** + * アカウントのアバターとサーバのアバターが異なるかどうか + */ + private isDifferentAvatar(member: GuildMember): boolean { + const user = member.user + return user.displayAvatarURL() !== member.displayAvatarURL() + } + + /** + * バナー画像を設定しているか + */ + private hasBanner(member: GuildMember): boolean { + return !!member.user.banner + } + + /** + * プロフィールテーマを設定しているか + */ + private hasProfileTheme(member: GuildMember): boolean { + return !!member.user.accentColor + } +}