diff --git a/.env.example b/.env.example index 3a7c9181..84f8f829 100644 --- a/.env.example +++ b/.env.example @@ -40,3 +40,5 @@ ONBOARDING_CHANNEL= JOIN_LOG_CHANNEL= INTRO_CHANNEL= INTRO_ROLE= +REPEL_ROLE_NAME=MiniMod # The name of the role that is used for MiniMods +REPEL_DELETE_COUNT=2 # The number of messages to delete when using the repel command diff --git a/README.md b/README.md index 530dbd34..2c03a100 100644 --- a/README.md +++ b/README.md @@ -105,13 +105,15 @@ cd webdev-support-bot cp .env.example .env # and enter a token + yarn install # or npm install code . +yarn docker:dev:up yarn dev # or npm dev # or be fancy with a one-liner -git clone https://github.com/ljosberinn/webdev-support-bot/ && cd webdev-support-bot && cp .env.example .env && yarn install && code . && yarn dev +git clone https://github.com/ljosberinn/webdev-support-bot/ && cd webdev-support-bot && cp .env.example .env && yarn install && code . && yarn docker:dev:up && yarn dev ``` ## Environment variables diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 00000000..bf3ce949 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,16 @@ +services: + mongodb: + image: mongo:7.0 + container_name: webdev-support-bot-mongo-dev + restart: unless-stopped + environment: + MONGO_INITDB_ROOT_USERNAME: admin + MONGO_INITDB_ROOT_PASSWORD: password + MONGO_INITDB_DATABASE: webdev-support-bot + ports: + - "27017:27017" + volumes: + - mongodb_dev_data:/data/db + +volumes: + mongodb_dev_data: diff --git a/package.json b/package.json index 635186d6..6302acf9 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,9 @@ "test:ci": "jest --ci", "lint:fix": "eslint --fix src && prettier --write src", "lint:types": "tsc --noEmit", - "install:clean": "rm -rf node_modules && rm yarn.lock && yarn" + "install:clean": "rm -rf node_modules && rm yarn.lock && yarn", + "docker:dev:up": "docker compose -f docker-compose.dev.yml up -d", + "docker:dev:down": "docker compose -f docker-compose.dev.yml down" }, "keywords": [], "author": "", @@ -66,5 +68,6 @@ "lint-staged": { "src/**/*.{js,jsx,ts,tsx,json,css,scss,md}": "prettier --write", "*.js": "eslint --fix" - } + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/src/env.ts b/src/env.ts index 92ec7990..449ecaba 100644 --- a/src/env.ts +++ b/src/env.ts @@ -42,3 +42,7 @@ export const { ONBOARDING_CHANNEL } = process.env; export const { JOIN_LOG_CHANNEL } = process.env; export const { INTRO_CHANNEL } = process.env; export const { INTRO_ROLE } = process.env; + +export const { REPEL_ROLE_NAME } = process.env; +export const REPEL_DELETE_COUNT = + Number.parseInt(process.env.REPEL_DELETE_COUNT) || 2; diff --git a/src/v2/commands/index.ts b/src/v2/commands/index.ts index bc02beeb..a9b2171d 100644 --- a/src/v2/commands/index.ts +++ b/src/v2/commands/index.ts @@ -36,6 +36,7 @@ import { resourceInteraction } from './resource/index.js'; import { shitpostInteraction } from './shitpost/index.js'; // import { warn } from './warn/index.js'; import { whynoInteraction } from './whyno/index.js'; +import { repelInteraction } from './repel'; export const guildCommands = new Map( [ @@ -50,8 +51,9 @@ export const guildCommands = new Map( whynoInteraction, roleCommands, setupCommands, + repelInteraction, // warn // Not used atm - ].map(command => [command.name, command]) + ].map(command => [command.name, command]), ); // placeholder for now export const applicationCommands = new Collection< @@ -88,7 +90,7 @@ const stripNullish = (obj: T): T => { return Object.fromEntries( Object.entries(obj) .map(([a, b]) => [a, stripNullish(b)]) - .filter(([, b]) => b != null) + .filter(([, b]) => b != null), ) as T; }; @@ -121,7 +123,7 @@ export const registerCommands = async (client: Client): Promise => { content: 'Something went wrong when trying to execute the command', }); } - }) + }), ); for (const { onAttach } of applicationCommands.values()) { @@ -152,7 +154,7 @@ export const registerCommands = async (client: Client): Promise => { await addCommands( discordCommandsById, applicationCommands, - client.application.commands + client.application.commands, ); console.log('General Commands All Added'); @@ -170,14 +172,14 @@ async function addCommands( ApplicationCommand<{ guild: GuildResolvable }> >, commandDescriptions: Map, - commandManager: ApplicationCommandManager | GuildApplicationCommandManager + commandManager: ApplicationCommandManager | GuildApplicationCommandManager, ) { const discordChatInputCommandsById = serverCommands.filter( - x => x.type === ApplicationCommandType.ChatInput + x => x.type === ApplicationCommandType.ChatInput, ); const discordCommands = new Collection( - discordChatInputCommandsById.map(value => [value.name, value]) + discordChatInputCommandsById.map(value => [value.name, value]), ); const validCommands = pipe< @@ -188,22 +190,22 @@ async function addCommands( ([key, val]: [string, CommandDataWithHandler]) => 'guild' in commandManager && val.guildValidate ? val.guildValidate(commandManager.guild) - : true + : true, ), map(([key]) => key), ]); const newCommands = difference( validCommands(commandDescriptions), - discordCommands.keys() + discordCommands.keys(), ); const existingCommands = intersection( validCommands(commandDescriptions), - discordCommands.keys() + discordCommands.keys(), ); const deletedCommands = difference( discordCommands.keys(), - validCommands(commandDescriptions) + validCommands(commandDescriptions), ); // const new = await client.application.commands.create() @@ -213,15 +215,15 @@ async function addCommands( editExistingCommands( commandDescriptions, commandManager, - discordCommands + discordCommands, )(existingCommands), - deleteRemovedCommands(commandManager, discordCommands)(deletedCommands) - ) + deleteRemovedCommands(commandManager, discordCommands)(deletedCommands), + ), ); } function getDestination( - commandManager: ApplicationCommandManager | GuildApplicationCommandManager + commandManager: ApplicationCommandManager | GuildApplicationCommandManager, ) { return 'guild' in commandManager ? `Guild: ${commandManager.guild.name}` @@ -230,7 +232,7 @@ function getDestination( function createNewCommands( cmdDescriptions: Map, - cmdMgr: ApplicationCommandManager | GuildApplicationCommandManager + cmdMgr: ApplicationCommandManager | GuildApplicationCommandManager, ) { const destination = getDestination(cmdMgr); return map(async (name: string) => { @@ -248,7 +250,7 @@ function createNewCommands( function editExistingCommands( cmdDescriptions: Map, cmdMgr: ApplicationCommandManager | GuildApplicationCommandManager, - existingCommands: Map + existingCommands: Map, ) { const destination = getDestination(cmdMgr); return map((name: string) => { @@ -260,7 +262,7 @@ function editExistingCommands( if ( !isEqual( getRelevantCmdProperties(cmd), - getRelevantCmdProperties(existing) + getRelevantCmdProperties(existing), ) ) { console.info(`Updating ${name} for ${destination}`); @@ -272,7 +274,7 @@ function editExistingCommands( function deleteRemovedCommands( cmdMgr: ApplicationCommandManager | GuildApplicationCommandManager, - existingCommands: Map + existingCommands: Map, ) { const destination = getDestination(cmdMgr); return map(async (name: string) => { diff --git a/src/v2/commands/repel/index.ts b/src/v2/commands/repel/index.ts new file mode 100644 index 00000000..1f16f074 --- /dev/null +++ b/src/v2/commands/repel/index.ts @@ -0,0 +1,209 @@ +import { + ApplicationCommandOptionType, + ChannelType, + PermissionFlagsBits, + User, + type Client, + type CommandInteraction, + type GuildMember, + type TextChannel, +} from 'discord.js'; +import type { CommandDataWithHandler } from '../../../types'; +import { REPEL_DELETE_COUNT, REPEL_ROLE_NAME } from '../../env'; + +const TARGET_KEY = 'target'; +const MESSAGE_LINK_KEY = 'message_link'; +const DAY = 24 * 60 * 60 * 1000; +const TIMEOUT_DURATION = 6 * 60 * 60 * 1000; // 6 hours in milliseconds + +const reply = ( + interaction: CommandInteraction, + content: string, + ephemeral = true, +) => interaction.reply({ content, ephemeral }); + +const getTargetFromMessage = async ( + client: Client, + guild: any, + messageLink: string, +) => { + const match = messageLink.match(/(?:channels|@me)\/(?:(\d+)\/)?(\d+)\/(\d+)/); + if (!match) throw new Error('Invalid message link format.'); + const messageId = match[3]; + const channelId = match[2]; + + const channel = channelId ? await client.channels.fetch(channelId) : null; + if (channel?.type !== ChannelType.GuildText) + throw new Error('Invalid channel for message link.'); + + const message = await (channel as TextChannel).messages.fetch(messageId); + return await guild.members.fetch(message.author.id); +}; + +export const repelInteraction: CommandDataWithHandler = { + name: 'repel', + description: + 'Remove recent messages and timeout a user (requires timeout permissions)', + options: [ + { + name: TARGET_KEY, + description: 'The user to repel', + type: ApplicationCommandOptionType.User, + required: false, + }, + { + name: MESSAGE_LINK_KEY, + description: 'Message link to identify the user to repel', + type: ApplicationCommandOptionType.String, + required: false, + }, + ], + + handler: async (client: Client, interaction: CommandInteraction) => { + if (!interaction.inGuild() || !interaction.guild) { + await reply(interaction, 'This command can only be used in a server.'); + } + const repelRole = interaction.guild.roles.cache.find( + role => role.name === REPEL_ROLE_NAME, + ); + + if (!repelRole) { + await reply( + interaction, + `${REPEL_ROLE_NAME || 'Repel'} role not found. Please contact an admin.`, + ); + return; + } + const member = interaction.member as GuildMember; + + const canUseCommand = + member.permissions.has(PermissionFlagsBits.ModerateMembers) || + member.roles.cache.has(repelRole.id) || + member.roles.cache.some(role => role.position >= repelRole.position); + + if (!canUseCommand) { + await reply( + interaction, + `You do not have permission to use this command. You need the ${REPEL_ROLE_NAME} role or moderate members permission.`, + ); + return; + } + + const targetUser = interaction.options.get(TARGET_KEY, false)?.user as + | User + | undefined; + const messageLink = interaction.options.get(MESSAGE_LINK_KEY, false) + ?.value as string | undefined; + + if (!targetUser && !messageLink) { + await reply( + interaction, + 'You must specify either a user or a message link.', + ); + } + + try { + let targetMember: GuildMember; + + if (targetUser) { + targetMember = await interaction.guild.members.fetch(targetUser.id); + } else if (messageLink) { + targetMember = await getTargetFromMessage( + client, + interaction.guild, + messageLink!, + ); + } + + if (targetMember.id === member.id) { + await reply(interaction, 'You cannot repel yourself.'); + return; + } + + const botMember = await interaction.guild.members.fetch(client.user!.id); + const isOwner = interaction.guild.ownerId === member.id; + + if (targetMember.id === interaction.guild.ownerId) { + await reply(interaction, 'Cannot moderate the server owner.'); + } + + if ( + !isOwner && + targetMember.roles.highest.position >= member.roles.highest.position + ) { + await reply( + interaction, + 'You cannot moderate this user due to role hierarchy.', + ); + } + + if ( + targetMember.roles.highest.position >= botMember.roles.highest.position + ) { + await reply( + interaction, + 'I cannot moderate this user due to role hierarchy.', + ); + } + + await interaction.deferReply({ ephemeral: true }); + + let deletedCount = 0; + const textChannels = interaction.guild.channels.cache.filter( + ch => ch.type === ChannelType.GuildText, + ); + + for (const [, channel] of textChannels) { + if (deletedCount >= REPEL_DELETE_COUNT) break; + + try { + const messages = await channel.messages.fetch({ + limit: 100, + }); + const userMessages = messages + .filter( + m => + m.author.id === targetMember.id && + Date.now() - m.createdTimestamp < 14 * DAY, + ) + .first( + Math.min(REPEL_DELETE_COUNT - deletedCount, REPEL_DELETE_COUNT), + ); + if (userMessages.length > 0) { + userMessages.length === 1 + ? await userMessages[0].delete() + : await (channel as TextChannel).bulkDelete(userMessages); + deletedCount += userMessages.length; + } + } catch {} + } + + const isUserTimedOut = targetMember.communicationDisabledUntilTimestamp + ? targetMember.communicationDisabledUntilTimestamp > Date.now() + : false; + + if (!isUserTimedOut) { + await targetMember.timeout( + TIMEOUT_DURATION, + `Repel command used by ${member.user.tag}`, + ); + await interaction.editReply({ + content: `Successfully repelled ${targetMember.user.tag}. Removed ${deletedCount} messages and timed out for 6 hours.`, + }); + } else { + await interaction.editReply({ + content: `Successfully repelled ${targetMember.user.tag}. Removed ${deletedCount} messages.`, + }); + } + } catch (error: any) { + const errorMsg = + error.message || 'An error occurred while executing this command.'; + + if (interaction.deferred) { + await interaction.editReply({ content: errorMsg }); + } else { + await reply(interaction, errorMsg + ' Please try again later.'); + } + } + }, +}; diff --git a/src/v2/index.ts b/src/v2/index.ts index de3e4290..369de506 100644 --- a/src/v2/index.ts +++ b/src/v2/index.ts @@ -40,7 +40,7 @@ import { stripMarkdownQuote } from './utils/content_format.js'; const NON_COMMAND_MSG_TYPES = new Set([ ChannelType.GuildText, ChannelType.PrivateThread, - ChannelType.PublicThread + ChannelType.PublicThread, ]); if (IS_PROD) { @@ -121,7 +121,7 @@ client.once('ready', async (): Promise => { try { await client.user.setAvatar('./logo.png'); - } catch { } + } catch {} }); const detectVarLimited = limitFnByUser(detectVar, {