From 94fb6cfe515fcdcfedfdc0286c9a83ed3402ac7a Mon Sep 17 00:00:00 2001 From: 01110111-wave Date: Fri, 26 May 2023 18:33:28 +0700 Subject: [PATCH 1/2] implement mute command --- .env.example | 1 + README.md | 1 + prisma/schema.prisma | 1 + src/Bot.ts | 1 + src/commands/index.ts | 6 ++ src/commands/moderators/micMuteAppeal.ts | 88 ++++++++++++++++ src/commands/moderators/severeMute.ts | 98 +++++++++++++++++ src/commands/moderators/severeMutePardon.ts | 110 ++++++++++++++++++++ src/config.ts | 1 + 9 files changed, 307 insertions(+) create mode 100644 src/commands/moderators/micMuteAppeal.ts create mode 100644 src/commands/moderators/severeMute.ts create mode 100644 src/commands/moderators/severeMutePardon.ts diff --git a/.env.example b/.env.example index 6f25e9e6..ec3a8130 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ BOT_TOKEN= GUILD_ID= MOD_CHANNEL_ID= +MIC_MUTE_APPEAL_CHANNEL_ID= # Sticky Message MESSAGE_COOLDOWN_SEC=15 diff --git a/README.md b/README.md index 78391ebf..4ff88ad0 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Discord bot for KaoGeek, built with TypeScript and [discord.js](https://discord. - `MESSAGE_COOLDOWN_SEC` cooldown to push the sticky message to the bottom of channel - `MESSAGE_MAX` the maximum message before push sticky message to the bottom of channel - `MOD_CHANNEL_ID` Discord channel ID for bot to report moderation actions + - `MIC_MUTE_APPEAL_CHANNEL_ID` Discord channel ID for server mute appeal - `DATABASE_URL` Prisma database URL, you can use SQLite for development, set it to `file:./dev.db` diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c7013f1d..217568cf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -89,6 +89,7 @@ model UserProfile { tag String displayName String strikes Int @default(0) + severeMuted Boolean @default(false) } // Metadata of Sticky Message diff --git a/src/Bot.ts b/src/Bot.ts index 4c3a4839..41eea0d0 100644 --- a/src/Bot.ts +++ b/src/Bot.ts @@ -22,6 +22,7 @@ export class Bot { IntentsBitField.Flags.GuildMembers, IntentsBitField.Flags.GuildMessages, IntentsBitField.Flags.MessageContent, + IntentsBitField.Flags.GuildVoiceStates, ], }) private readonly runtimeConfiguration = new RuntimeConfiguration() diff --git a/src/commands/index.ts b/src/commands/index.ts index 3be4ba56..edd77a85 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -4,7 +4,10 @@ import ping from './info/ping' import activeThreads from './moderators/activeThreads' import deleteAllMessage from './moderators/deleteAllMessage' import inspectProfile from './moderators/inspectProfile' +import micMuteAppeal from './moderators/micMuteAppeal' import report from './moderators/report' +import severeMute from './moderators/severeMute' +import severeMutePardon from './moderators/severeMutePardon' import slowmode from './moderators/slowmode' import user from './moderators/user' import nominate from './nominations/nominate' @@ -22,4 +25,7 @@ export default [ report, user, slowmode, + ...severeMute, + ...severeMutePardon, + micMuteAppeal, ] satisfies Plugin[] diff --git a/src/commands/moderators/micMuteAppeal.ts b/src/commands/moderators/micMuteAppeal.ts new file mode 100644 index 00000000..4283afbf --- /dev/null +++ b/src/commands/moderators/micMuteAppeal.ts @@ -0,0 +1,88 @@ +import { + CacheType, + ChatInputCommandInteraction, + DiscordAPIError, + GuildMember, +} from 'discord.js' + +import { Environment } from '@/config' +import { addUserModerationLogEntry } from '@/features/profileInspector' +import { prisma } from '@/prisma' +import { UserModerationLogEntryType } from '@/types/UserModerationLogEntry' +import { defineCommandHandler } from '@/types/defineCommandHandler' + +export default defineCommandHandler({ + data: { + name: 'appeal-for-server-mute', + description: + 'Appeal for microphone muted. Use when server muted only, else you will be timed out for one minute.', + }, + ephemeral: true, + execute: async (_botContext, interaction) => { + if ( + !interaction.isChatInputCommand() || + interaction.channelId !== Environment.MIC_MUTE_APPEAL_CHANNEL_ID + ) { + interaction.deleteReply() + return + } + if (interaction.member instanceof GuildMember) { + //when start the bot, all user voice state might be null.This if statement is to prevent it. + if (interaction.member.voice.serverMute === null) { + interaction.editReply('Please join voice channel') + return + } + //prevent spamming appeal when the user is not mute + if (interaction.member.voice.serverMute === false) { + await interaction.editReply( + 'You are not muted, you will be timed out for one minute due to the false mute appeal.', + ) + try { + //time out does not work on user with higher role hierachy. + await interaction.member.timeout(1000 * 60) + } catch (error) { + if (error instanceof DiscordAPIError && error.code === 50_013) { + console.error(`error`, error.message) + } + } + return + } + //if the user is mute, unmute the user. + //unmuting might be depended on reason why user is server muted. + else { + try { + if (await isMutedForSeverePunishment(interaction)) { + interaction.editReply( + `You were severe muted. Please, appeal to a moderator directly for severe mute pardon.`, + ) + } else { + await interaction.member.voice.setMute(false) + await interaction.editReply(`Unmute ${interaction.member.user}`) + await addUserModerationLogEntry( + interaction.user.id, + interaction.user.id, + UserModerationLogEntryType.Mute, + `Unmute ${interaction.member.user.tag} by auto mute appeal`, + ) + } + } catch (error) { + if (error instanceof DiscordAPIError && error.code === 40_032) { + interaction.editReply( + `${interaction.member.user}, please connect to voice channel, so we can unmute you.`, + ) + } + } + } + } + }, +}) + +async function isMutedForSeverePunishment( + interaction: ChatInputCommandInteraction, +): Promise { + const profile = await prisma.userProfile.findFirst({ + where: { id: interaction.user.id }, + }) //retreive the latest mute record of user + //null mean no profile have been registered into DB, so user have not been punished with severe mute. + return profile ? profile.severeMuted : false +} diff --git a/src/commands/moderators/severeMute.ts b/src/commands/moderators/severeMute.ts new file mode 100644 index 00000000..49d93fe7 --- /dev/null +++ b/src/commands/moderators/severeMute.ts @@ -0,0 +1,98 @@ +import { + ApplicationCommandOptionType, + ApplicationCommandType, + CommandInteraction, + DiscordAPIError, + GuildMember, + PermissionsBitField, +} from 'discord.js' + +import { addUserModerationLogEntry } from '@/features/profileInspector' +import { prisma } from '@/prisma' +import { UserModerationLogEntryType } from '@/types/UserModerationLogEntry' +import { defineCommandHandler } from '@/types/defineCommandHandler' + +export default [ + defineCommandHandler({ + data: { + name: 'Severe mute', + type: ApplicationCommandType.User, + defaultMemberPermissions: PermissionsBitField.Flags.MuteMembers, + dmPermission: false, + }, + ephemeral: true, + execute: async (_botContext, interaction) => { + if (!interaction.guild || !interaction.isContextMenuCommand()) return + + const userId = interaction.targetId + const member = interaction.guild.members.cache.get(userId) + if (!member) return + + await severeMute(interaction, member) + }, + }), + defineCommandHandler({ + data: { + name: 'severe-mute', + description: + 'Server mute a user, and unallow user to be unmute automatically by mute appeal.', + defaultMemberPermissions: PermissionsBitField.Flags.MuteMembers, + type: ApplicationCommandType.ChatInput, + options: [ + { + name: 'user', + description: 'The user to mute', + type: ApplicationCommandOptionType.User, + }, + ], + }, + ephemeral: true, + execute: async (_botContext, interaction) => { + if (!interaction.guild || !interaction.isChatInputCommand()) return + + const userId = interaction.options.getUser('user')?.id + if (!userId) return + const member = interaction.guild.members.cache.get(userId) + if (!member) return + + await severeMute(interaction, member) + }, + }), +] +async function severeMute( + interaction: CommandInteraction, + member: GuildMember, +) { + try { + //muting might fail if the target is in higher role hierachy. + await member.voice.setMute(true, 'Severe mute from breaking server rules.') // imply that severe mute will be use only when user break server rule. + await prisma.userProfile.upsert({ + where: { id: member.user.id }, + update: { severeMuted: true }, + create: { + id: member.user.id, + tag: member.user.tag, + displayName: member.displayName, + severeMuted: true, + }, + }) // severeMuted bool will be use when mute appeal + await addUserModerationLogEntry( + member.user.id, + interaction.user.id, + UserModerationLogEntryType.Mute, + `Apply severe mute punishment to ${member.user.tag}.`, + ) + await interaction.editReply(`${member.user} is severely muted.`) + } catch (error) { + if (error instanceof DiscordAPIError && error.code === 40_032) { + await interaction.editReply( + `${member.user} is not in voice channel, so muting fail.`, + ) + } + if (error instanceof DiscordAPIError && error.code === 50_013) { + await interaction.editReply( + `${member.user} is in higher role hierachy than you, so muting fail.`, + ) + } + } +} diff --git a/src/commands/moderators/severeMutePardon.ts b/src/commands/moderators/severeMutePardon.ts new file mode 100644 index 00000000..13e8c6b5 --- /dev/null +++ b/src/commands/moderators/severeMutePardon.ts @@ -0,0 +1,110 @@ +import { + ApplicationCommandOptionType, + ApplicationCommandType, + CommandInteraction, + DiscordAPIError, + GuildMember, + PermissionsBitField, +} from 'discord.js' + +import { Environment } from '@/config' +import { addUserModerationLogEntry } from '@/features/profileInspector' +import { prisma } from '@/prisma' +import { UserModerationLogEntryType } from '@/types/UserModerationLogEntry' +import { defineCommandHandler } from '@/types/defineCommandHandler' + +export default [ + defineCommandHandler({ + //please used this command on apology message of punished member, if you deem that they regret their wrong doing. + data: { + name: 'Severe mute pardon', + type: ApplicationCommandType.Message, + defaultMemberPermissions: PermissionsBitField.Flags.MuteMembers, + dmPermission: false, + }, + ephemeral: true, + execute: async (_botContext, interaction) => { + if ( + !interaction.guild || + !interaction.isContextMenuCommand() || + interaction.channelId !== Environment.MIC_MUTE_APPEAL_CHANNEL_ID + ) + return + + const messageId = interaction.targetId + const message = await interaction.channel?.messages.fetch(messageId) + if (!message) return + + const userId = message.author.id + const member = interaction.guild.members.cache.get(userId) + if (!member) return + + await severeMutePardon(interaction, member) + }, + }), + defineCommandHandler({ + data: { + name: 'severe-mute-pardon', + description: `Pardon severe mute punishment, this command should not be used unless in case of wrong punishment.`, + type: ApplicationCommandType.ChatInput, + defaultMemberPermissions: PermissionsBitField.Flags.MuteMembers, + options: [ + { + name: 'user', + description: 'The user to pardon', + type: ApplicationCommandOptionType.User, + }, + ], + }, + ephemeral: true, + execute: async (_botContext, interaction) => { + if ( + !interaction.guild || + !interaction.isChatInputCommand() || + interaction.channelId !== Environment.MIC_MUTE_APPEAL_CHANNEL_ID + ) + return + + const userId = interaction.options.getUser('user')?.id + if (!userId) return + const member = interaction.guild.members.cache.get(userId) + if (!member) return + + await severeMutePardon(interaction, member) + }, + }), +] + +async function severeMutePardon( + interaction: CommandInteraction, + member: GuildMember, +) { + try { + //unmuting might fail if the target is in higher role hierachy. + await member.voice.setMute(false, 'Pardon severe mute') + await prisma.userProfile.update({ + where: { id: member.user.id }, + data: { severeMuted: false }, + }) + await addUserModerationLogEntry( + member.user.id, + interaction.user.id, + UserModerationLogEntryType.Mute, + `Pardon severe mute punishment to ${member.user.tag}.`, + ) + await interaction.editReply( + `${member.user} is pardon for severe mute punishment.`, + ) + } catch (error) { + if (error instanceof DiscordAPIError && error.code === 40_032) { + await interaction.editReply( + `${member.user} is not in voice channel, so pardon fail.`, + ) + } + if (error instanceof DiscordAPIError && error.code === 50_013) { + await interaction.editReply( + `${member.user} is in higher role hierachy than you, so pardon fail.`, + ) + } + } +} diff --git a/src/config.ts b/src/config.ts index aeedb3a5..675a85a6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,6 +8,7 @@ export const EnvironmentSchema = z.object({ BOT_TOKEN: z.string(), GUILD_ID: z.string(), MOD_CHANNEL_ID: z.string(), + MIC_MUTE_APPEAL_CHANNEL_ID: z.string(), DATABASE_URL: z.string(), PRISMA_LOG: z.coerce.boolean().default(false), MESSAGE_COOLDOWN_SEC: z.coerce.number().default(15), From f24e58bb09592ba306496e68146bb3d6bc399aa8 Mon Sep 17 00:00:00 2001 From: 01110111-wave Date: Fri, 26 May 2023 18:41:37 +0700 Subject: [PATCH 2/2] add smoke test env --- smoke/docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/smoke/docker-compose.yml b/smoke/docker-compose.yml index 67b60328..65f6fd5b 100644 --- a/smoke/docker-compose.yml +++ b/smoke/docker-compose.yml @@ -8,3 +8,4 @@ services: BOT_TOKEN: dummy GUILD_ID: dummy MOD_CHANNEL_ID: dummy + MIC_MUTE_APPEAL_CHANNEL_ID: dummy