Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

implement mute command #151

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
BOT_TOKEN=
GUILD_ID=
MOD_CHANNEL_ID=
MIC_MUTE_APPEAL_CHANNEL_ID=

# Sticky Message
MESSAGE_COOLDOWN_SEC=15
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
</details>

Expand Down
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ model UserProfile {
tag String
displayName String
strikes Int @default(0)
severeMuted Boolean @default(false)
}

// Metadata of Sticky Message
Expand Down
1 change: 1 addition & 0 deletions smoke/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ services:
BOT_TOKEN: dummy
GUILD_ID: dummy
MOD_CHANNEL_ID: dummy
MIC_MUTE_APPEAL_CHANNEL_ID: dummy
1 change: 1 addition & 0 deletions src/Bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export class Bot {
IntentsBitField.Flags.GuildMembers,
IntentsBitField.Flags.GuildMessages,
IntentsBitField.Flags.MessageContent,
IntentsBitField.Flags.GuildVoiceStates,
],
})
private readonly runtimeConfiguration = new RuntimeConfiguration()
Expand Down
6 changes: 6 additions & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -22,4 +25,7 @@ export default [
report,
user,
slowmode,
...severeMute,
...severeMutePardon,
micMuteAppeal,
] satisfies Plugin[]
88 changes: 88 additions & 0 deletions src/commands/moderators/micMuteAppeal.ts
Original file line number Diff line number Diff line change
@@ -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<CacheType>,
): Promise<boolean> {
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
}
98 changes: 98 additions & 0 deletions src/commands/moderators/severeMute.ts
Original file line number Diff line number Diff line change
@@ -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.
Copy link
Collaborator

@dtinth dtinth May 26, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if user already left the voice channel when a mod decides to severe mute? it won’t crash, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it won't crash the bot.

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.`,
)
}
}
}
110 changes: 110 additions & 0 deletions src/commands/moderators/severeMutePardon.ts
Original file line number Diff line number Diff line change
@@ -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.`,
)
}
}
}
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down