diff --git a/.env.example b/.env.example index 3733af2..76b2665 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,8 @@ DISCORD_TOKEN="" # Your bot token CLIENT_ID="" # Your bot's application ID -GUIDES_CHANNEL_ID="" # The ID of the channel where guides will be posted \ No newline at end of file + +SERVER_ID= +MODERATORS_ROLE_IDS= # Comma separated list of role IDs that are Moderators(Mods, Admins, etc) + +REPEL_LOG_CHANNEL_ID= # Channel ID where the bot will log repel actions +GUIDES_CHANNEL_ID="" # The ID of the channel where guides will be posted diff --git a/src/commands/index.ts b/src/commands/index.ts index dc97772..3bb312e 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,9 +1,13 @@ import { docsCommands } from './docs/index.js'; import { guidesCommand } from './guides/index.js'; +import cacheMessages from './moderation/cache-messages.js'; +import { repelCommand } from './moderation/repel.js'; import { pingCommand } from './ping.js'; import { tipsCommands } from './tips/index.js'; import type { Command } from './types.js'; export const commands = new Map( - [pingCommand, guidesCommand, docsCommands, tipsCommands].flat().map((cmd) => [cmd.data.name, cmd]) + [pingCommand, guidesCommand, docsCommands, tipsCommands, repelCommand, cacheMessages] + .flat() + .map((cmd) => [cmd.data.name, cmd]) ); diff --git a/src/commands/moderation/cache-messages.ts b/src/commands/moderation/cache-messages.ts new file mode 100644 index 0000000..004bd74 --- /dev/null +++ b/src/commands/moderation/cache-messages.ts @@ -0,0 +1,49 @@ +import { ApplicationCommandOptionType, PermissionFlagsBits, PermissionsBitField } from 'discord.js'; +import { fetchAndCachePublicChannelsMessages } from '../../util/cache.js'; +import { createCommand } from '../../util/commands.js'; + +export default createCommand({ + data: { + name: 'cache-messages', + description: 'Cache messages in all text channels of the server', + default_member_permissions: new PermissionsBitField( + PermissionFlagsBits.ManageMessages + ).toJSON(), + options: [ + { + name: 'force', + description: 'Force re-caching even if messages are already cached', + type: ApplicationCommandOptionType.Boolean, + required: false, + }, + ], + }, + execute: async (interaction) => { + await interaction.deferReply(); + if (!interaction.guild || !interaction.isChatInputCommand()) { + await interaction.editReply('This command can only be used in a guild.'); + return; + } + + if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageMessages)) { + await interaction.editReply('You do not have permission to use this command.'); + return; + } + + const guild = interaction.guild; + const force = interaction.options.getBoolean('force') ?? false; + + await interaction.editReply('Caching messages in all public text channels...'); + + const { cachedChannels, totalChannels, failedChannels } = + await fetchAndCachePublicChannelsMessages(guild, force); + + const failedMessage = failedChannels.length + ? `\nFailed to cache messages in the following channels: ${failedChannels.map((id) => `<#${id}>`).join(', ')}` + : ''; + + await interaction.editReply( + `Cached messages in ${cachedChannels} out of ${totalChannels} text channels.${failedMessage}` + ); + }, +}); diff --git a/src/commands/moderation/repel.ts b/src/commands/moderation/repel.ts new file mode 100644 index 0000000..36a5457 --- /dev/null +++ b/src/commands/moderation/repel.ts @@ -0,0 +1,431 @@ +import { + ApplicationCommandOptionType, + ApplicationCommandType, + ChannelType, + type ChatInputCommandInteraction, + EmbedBuilder, + GuildMember, + MessageFlags, + PermissionFlagsBits, + type Role, + type TextChannel, + type User, +} from 'discord.js'; +import { HOUR, MINUTE, timeToString } from '../../constants/time.js'; +import { config } from '../../env.js'; +import { getPublicChannels } from '../../util/channel.js'; +import { logToChannel } from '../../util/channel-logging.js'; +import { buildCommandString, createCommand } from '../../util/commands.js'; + +const DEFAULT_LOOK_BACK = 10 * MINUTE; +const DEFAULT_TIMEOUT_DURATION = 1 * HOUR; + +const isUserInServer = (target: User | GuildMember): target is GuildMember => { + return target instanceof GuildMember; +}; + +const isUserTimedOut = (target: GuildMember) => { + return target.communicationDisabledUntilTimestamp + ? target.communicationDisabledUntilTimestamp > Date.now() + : false; +}; + +const checkCanRepel = ({ + commandUser, + repelRole, +}: { + commandUser: GuildMember; + repelRole: Role; +}): { + ok: boolean; + message?: string; +} => { + const hasPermission = + commandUser.permissions.has(PermissionFlagsBits.ModerateMembers) || + commandUser.roles.cache.has(repelRole.id); + if (!hasPermission) { + return { ok: false, message: 'You do not have permission to use this command.' }; + } + return { ok: true }; +}; + +const checkCanRepelTarget = ({ + target, + commandUser, + botMember, +}: { + target: User | GuildMember; + commandUser: GuildMember; + botMember: GuildMember; +}): { + ok: boolean; + message?: string; +} => { + if (!isUserInServer(target)) { + return { ok: true }; + } + + if (target.user.bot) { + return { ok: false, message: 'You cannot repel a bot.' }; + } + + if (target.id === commandUser.id) { + return { ok: false, message: 'You cannot repel yourself.' }; + } + + const isTargetServerOwner = target.id === target.guild.ownerId; + if (isTargetServerOwner) { + return { ok: false, message: 'You cannot repel the server owner.' }; + } + + if (target.roles.highest.position >= commandUser.roles.highest.position) { + return { ok: false, message: 'You cannot repel this user due to role hierarchy.' }; + } + + if (target.roles.highest.position >= botMember.roles.highest.position) { + return { ok: false, message: 'I cannot repel this user due to role hierarchy.' }; + } + + return { ok: true }; +}; + +const getTargetFromInteraction = async ( + interaction: ChatInputCommandInteraction +): Promise => { + const targetFromOption = interaction.options.getUser(RepelOptions.TARGET, true); + let target: User | GuildMember | null = null; + if (!interaction.inGuild() || interaction.guild === null) { + return targetFromOption; + } + try { + target = await interaction.guild.members.fetch(targetFromOption.id); + } catch (error) { + console.error('Error fetching target as guild member:', error); + target = targetFromOption; + } + return target; +}; + +const handleTimeout = async ({ + target, + duration, +}: { + target: GuildMember | User; + duration: number; +}): Promise => { + if (duration === 0 || !isUserInServer(target) || isUserTimedOut(target)) { + return 0; + } + try { + await target.timeout(duration * HOUR, 'Repel command executed'); + return duration; + } catch (error) { + console.error('Error applying timeout to user:', error); + return 0; + } +}; + +const getTextChannels = (interaction: ChatInputCommandInteraction) => { + if (!interaction.inGuild() || !interaction.guild) { + console.error('Interaction is not in a guild'); + return []; + } + const channels = getPublicChannels(interaction.guild).map((c) => c); + return channels; +}; + +const handleDeleteMessages = async ({ + target, + channels, + lookBack, +}: { + target: GuildMember | User; + channels: TextChannel[]; + lookBack: number; +}) => { + let deleted = 0; + const failedChannels: string[] = []; + await Promise.allSettled( + channels.map(async (channel) => { + try { + const messages = channel.messages.cache; + const targetMessages = messages + .filter( + (message) => + message.author.id === target.id && + message.deletable && + Date.now() - message.createdTimestamp < lookBack + ) + .first(10); + + if (targetMessages.length === 0) { + return; + } + await channel.bulkDelete(targetMessages, true); + deleted += targetMessages.length; + } catch (error) { + console.error(`Error deleting messages in channel ${channel.name}:`, error); + failedChannels.push(channel.id); + throw error; + } + }) + ); + if (failedChannels.length > 0) { + console.error(`Failed to delete messages in ${failedChannels.length} channel(s).`); + } + return { deleted, failedChannels }; +}; + +const logRepelAction = async ({ + interaction, + member, + target, + duration, + deleteCount, + reason, + failedChannels, +}: { + interaction: ChatInputCommandInteraction; + member: GuildMember; + target: User | GuildMember; + reason: string; + duration?: number; + deleteCount: number; + failedChannels: string[]; +}) => { + const channelInfo = + interaction.channel?.type === ChannelType.GuildVoice + ? `**${interaction.channel.name}** voice chat` + : `<#${interaction.channelId}>`; + const memberAuthor = { + name: member.user.tag, + iconURL: member.user.displayAvatarURL(), + }; + const targetAuthor = { + name: isUserInServer(target) + ? `${target.user.tag} | Repel | ${target.user.username}` + : `${target.tag} | Repel | ${target.username}`, + iconURL: isUserInServer(target) ? target.user.displayAvatarURL() : target.displayAvatarURL(), + }; + + const commandEmbed = new EmbedBuilder() + .setAuthor(memberAuthor) + .setDescription(`Used \`repel\` command in ${channelInfo}.\n${buildCommandString(interaction)}`) + .setColor('Green') + .setTimestamp(); + const resultEmbed = new EmbedBuilder() + .setAuthor(targetAuthor) + .addFields( + { + name: 'Target', + value: `<@${target.id}>`, + inline: true, + }, + { + name: 'Moderator', + value: `<@${member.id}>`, + inline: true, + }, + { + name: 'Reason', + value: reason, + inline: true, + }, + { + name: 'Deleted Messages', + value: deleteCount.toString(), + inline: true, + }, + { + name: 'Timeout Duration', + value: duration ? `${timeToString(duration)}` : 'No Timeout', + inline: true, + } + ) + .setColor('Orange') + .setTimestamp(); + + const failedChannelsEmbed = + failedChannels.length > 0 + ? new EmbedBuilder() + .setTitle('Failed to delete messages in the following channels:') + .setDescription(failedChannels.map((id) => `<#${id}>`).join('\n')) + .setColor('Red') + .setTimestamp() + : null; + + const modMessage = interaction.options.getString(RepelOptions.MESSAGE_FOR_MODS) ?? false; + const mentionText = modMessage + ? `${config.moderatorsRoleIds.map((id) => `<@&${id}>`)} - ${modMessage}` + : undefined; + const channel = interaction.client.channels.cache.get( + config.repel.repelLogChannelId + ) as TextChannel; + + const embed = + failedChannelsEmbed !== null + ? [commandEmbed, resultEmbed, failedChannelsEmbed] + : [commandEmbed, resultEmbed]; + + await logToChannel({ + channel, + content: { + type: 'embed', + embed, + content: mentionText, + }, + }); +}; + +const RepelOptions = { + TARGET: 'target', + REASON: 'reason', + LOOK_BACK: 'look_back', + TIMEOUT_DURATION: 'timeout_duration', + MESSAGE_FOR_MODS: 'message_for_mods', +} as const; + +export const repelCommand = createCommand({ + data: { + name: 'repel', + type: ApplicationCommandType.ChatInput, + description: 'Remove recent messages and timeout a user', + options: [ + { + name: RepelOptions.TARGET, + required: true, + type: ApplicationCommandOptionType.User, + description: 'The user to timeout and remove messages from', + }, + { + name: RepelOptions.REASON, + required: true, + type: ApplicationCommandOptionType.String, + description: 'Reason for the timeout and message removal', + }, + { + name: RepelOptions.LOOK_BACK, + required: false, + type: ApplicationCommandOptionType.Integer, + description: `Number of recent messages to delete (default: ${timeToString(DEFAULT_LOOK_BACK)})`, + choices: [ + { + name: '10 minutes (Default)', + value: 10 * MINUTE, + }, + { + name: '30 minutes', + value: 30 * MINUTE, + }, + { + name: '1 hour', + value: 1 * HOUR, + }, + { + name: '3 hours', + value: 3 * HOUR, + }, + ], + }, + { + name: RepelOptions.TIMEOUT_DURATION, + required: false, + type: ApplicationCommandOptionType.Integer, + description: `Duration of the timeout in hours (default: ${timeToString(DEFAULT_TIMEOUT_DURATION)})`, + min_value: 1, + max_value: 24, + }, + { + name: RepelOptions.MESSAGE_FOR_MODS, + required: false, + type: ApplicationCommandOptionType.String, + description: 'Optional message to include for moderators in the log', + }, + ], + }, + execute: async (interaction) => { + if (!interaction.inGuild() || !interaction.guild) { + await interaction.reply({ + content: 'This command can only be used in a server.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + if (!interaction.isChatInputCommand()) { + return; + } + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const repelRole = interaction.guild.roles.cache.get(config.repel.repelRoleId); + if (!repelRole) { + await interaction.editReply({ + content: '❌ Repel role is not configured correctly. Please contact an administrator.', + }); + return; + } + + const target = await getTargetFromInteraction(interaction); + + const commandUser = interaction.member as GuildMember; + + const canRepel = checkCanRepel({ commandUser, repelRole }); + if (!canRepel.ok) { + await interaction.editReply({ content: `❌ ${canRepel.message}` }); + return; + } + + const botMember = interaction.guild.members.me; + if (!botMember) { + await interaction.editReply({ + content: '❌ Unable to verify my permissions in the server.', + }); + return; + } + + const canRepelTarget = checkCanRepelTarget({ + target, + commandUser, + botMember, + }); + if (!canRepelTarget.ok) { + await interaction.editReply({ content: `❌ ${canRepelTarget.message}` }); + return; + } + + try { + const reason = interaction.options.getString(RepelOptions.REASON, true); + const lookBack = interaction.options.getInteger(RepelOptions.LOOK_BACK); + const timeoutDuration = interaction.options.getInteger(RepelOptions.TIMEOUT_DURATION); + + const timeout = await handleTimeout({ + target: target, + duration: timeoutDuration ? timeoutDuration * HOUR : DEFAULT_TIMEOUT_DURATION, + }); + + const channels = getTextChannels(interaction); + + const { deleted, failedChannels } = await handleDeleteMessages({ + channels, + target: target, + lookBack: lookBack ?? DEFAULT_LOOK_BACK, + }); + + logRepelAction({ + interaction, + member: commandUser, + target, + reason, + duration: timeout, + deleteCount: deleted, + failedChannels, + }); + + await interaction.editReply({ + content: `Deleted ${deleted} message(s).`, + }); + } catch (error) { + console.error('Error executing repel command:', error); + } + }, +}); diff --git a/src/constants/time.ts b/src/constants/time.ts index 86d1ccc..0708330 100644 --- a/src/constants/time.ts +++ b/src/constants/time.ts @@ -4,3 +4,36 @@ export const HOUR = 60 * MINUTE; export const DAY = 24 * HOUR; export const WEEK = 7 * DAY; export const MONTH = 30 * DAY; + +export const timeToString = (ms: number): string => { + const timeUnits = [ + { label: 'month', value: MONTH }, + { label: 'week', value: WEEK }, + { label: 'day', value: DAY }, + { label: 'hour', value: HOUR }, + { label: 'minute', value: MINUTE }, + { label: 'second', value: SECOND }, + ]; + + const formatTime = (remaining: number, units: typeof timeUnits): string => { + if (remaining === 0 || units.length === 0) { + return ''; + } + + const [currentUnit, ...restUnits] = units; + const count = Math.floor(remaining / currentUnit.value); + const remainder = remaining % currentUnit.value; + + if (count === 0) { + return formatTime(remainder, restUnits); + } + + const currentString = `${count} ${currentUnit.label}${count === 1 ? '' : 's'}`; + const restString = formatTime(remainder, restUnits); + + return restString ? `${currentString}, ${restString}` : currentString; + }; + + const result = formatTime(ms, timeUnits); + return result || '0 seconds'; +}; diff --git a/src/env.ts b/src/env.ts index 98da1f7..cc90dea 100644 --- a/src/env.ts +++ b/src/env.ts @@ -20,6 +20,15 @@ export const config = { token: requireEnv('DISCORD_TOKEN'), clientId: requireEnv('CLIENT_ID'), }, + repel: { + repelLogChannelId: requireEnv('REPEL_LOG_CHANNEL_ID'), + repelRoleId: requireEnv('REPEL_ROLE_ID'), + }, + fetchAndSyncMessages: true, + serverId: requireEnv('SERVER_ID'), + moderatorsRoleIds: requireEnv('MODERATORS_ROLE_IDS') + ? requireEnv('MODERATORS_ROLE_IDS').split(',') + : [], guides: { channelId: requireEnv('GUIDES_CHANNEL_ID'), trackerPath: optionalEnv('GUIDES_TRACKER_PATH'), diff --git a/src/events/ready.ts b/src/events/ready.ts index 46f5dee..dd9127a 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -1,5 +1,6 @@ import { Events } from 'discord.js'; import { config } from '../env.js'; +import { fetchAndCachePublicChannelsMessages } from '../util/cache.js'; import { createEvent } from '../util/events.js'; import { syncGuidesToChannel } from '../util/post-guides.js'; @@ -10,13 +11,19 @@ export const readyEvent = createEvent( }, async (client) => { console.log(`Ready! Logged in as ${client.user.tag}`); + if (config.fetchAndSyncMessages) { + const guild = client.guilds.cache.get(config.serverId); + if (guild) { + await fetchAndCachePublicChannelsMessages(guild, true); + } - // Sync guides to channel - try { - console.log(`🔄 Starting guide sync to channel ${config.guides.channelId}...`); - await syncGuidesToChannel(client, config.guides.channelId); - } catch (error) { - console.error('❌ Failed to sync guides:', error); + // Sync guides to channel + try { + console.log(`🔄 Starting guide sync to channel ${config.guides.channelId}...`); + await syncGuidesToChannel(client, config.guides.channelId); + } catch (error) { + console.error('❌ Failed to sync guides:', error); + } } } ); diff --git a/src/util/cache.ts b/src/util/cache.ts new file mode 100644 index 0000000..24e7366 --- /dev/null +++ b/src/util/cache.ts @@ -0,0 +1,35 @@ +import type { Guild } from 'discord.js'; +import { getPublicChannels } from './channel.js'; + +const PER_CHANNEL_CACHE_LIMIT = 100; +export const cachedChannelsMap = new Set(); + +export const fetchAndCachePublicChannelsMessages = async (guild: Guild, force = false) => { + let cachedChannels = 0; + const failedChannels: string[] = []; + + const channels = getPublicChannels(guild); + + await Promise.allSettled( + channels.map(async (channel) => { + if (force || !cachedChannelsMap.has(channel.id)) { + try { + const messages = await channel.messages.fetch({ limit: PER_CHANNEL_CACHE_LIMIT }); + console.log( + `Fetched and cached ${messages.size} messages from channel ${channel.name} (${channel.id})` + ); + cachedChannelsMap.add(channel.id); + cachedChannels++; + } catch (error) { + console.error( + `Failed to fetch messages from channel ${channel.name} (${channel.id}):`, + error + ); + failedChannels.push(channel.id); + throw error; + } + } + }) + ); + return { cachedChannels, totalChannels: channels.size, failedChannels }; +}; diff --git a/src/util/channel.ts b/src/util/channel.ts new file mode 100644 index 0000000..a55617a --- /dev/null +++ b/src/util/channel.ts @@ -0,0 +1,15 @@ +import { ChannelType, type Guild, PermissionFlagsBits, type TextChannel } from 'discord.js'; + +export const getPublicChannels = (guild: Guild) => { + return guild.channels.cache.filter( + (channel): channel is TextChannel => + channel.type === ChannelType.GuildText && + channel + .permissionsFor(guild.roles.everyone) + ?.has( + PermissionFlagsBits.ViewChannel | + PermissionFlagsBits.ReadMessageHistory | + PermissionFlagsBits.SendMessages + ) + ); +}; diff --git a/src/util/commands.ts b/src/util/commands.ts index 2985b40..7e138d2 100644 --- a/src/util/commands.ts +++ b/src/util/commands.ts @@ -1,4 +1,4 @@ -import type { Client } from 'discord.js'; +import type { ChatInputCommandInteraction, Client } from 'discord.js'; import type { Command } from '../commands/types.js'; export const createCommand = (command: Command): Command => { @@ -25,3 +25,8 @@ export const registerCommands = async ( console.error('Error registering commands:', error); } }; + +export const buildCommandString = (interaction: ChatInputCommandInteraction): string => { + const commandName = interaction.commandName; + return `/${commandName} ${interaction.options.data.map((option) => `${option.name}:${option.value}`).join(' ')}`; +};