From 325ba939cbae3f9350194973113d711b1a897677 Mon Sep 17 00:00:00 2001 From: Ali Hammoud Date: Fri, 17 Oct 2025 20:06:18 +0300 Subject: [PATCH 01/16] =?UTF-8?q?=F0=9F=8C=9F=20feat:=20Add=20buildCommand?= =?UTF-8?q?String=20function=20to=20format=20command=20strings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/commands.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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(' ')}`; +}; From 36feabb783c6ee94346b659e6441f04176539ade Mon Sep 17 00:00:00 2001 From: Ali Hammoud Date: Fri, 17 Oct 2025 20:07:09 +0300 Subject: [PATCH 02/16] =?UTF-8?q?=F0=9F=8C=9F=20feat:=20Implement=20getPub?= =?UTF-8?q?licChannels=20function=20to=20filter=20accessible=20text=20chan?= =?UTF-8?q?nels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/channel.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/util/channel.ts 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 + ) + ); +}; From b33014a0ae41f1e4ae50906d579bcc1b3a1dd117 Mon Sep 17 00:00:00 2001 From: Ali Hammoud Date: Fri, 17 Oct 2025 20:08:19 +0300 Subject: [PATCH 03/16] =?UTF-8?q?=F0=9F=8C=9F=20feat:=20Add=20fetchAndCach?= =?UTF-8?q?ePublicChannelsMessages=20function=20to=20cache=20messages=20fr?= =?UTF-8?q?om=20public=20channels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/cache.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/util/cache.ts diff --git a/src/util/cache.ts b/src/util/cache.ts new file mode 100644 index 0000000..b1d32c9 --- /dev/null +++ b/src/util/cache.ts @@ -0,0 +1,24 @@ +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 channels = getPublicChannels(guild); + + await Promise.all( + channels.map(async (channel) => { + if (force || !cachedChannelsMap.has(channel.id)) { + 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++; + } + }) + ); + return { cachedChannels, totalChannels: channels.size }; +}; From 9bd9c27c3de57f6b936705ff3fc082d6102c3859 Mon Sep 17 00:00:00 2001 From: Ali Hammoud Date: Fri, 17 Oct 2025 20:10:14 +0300 Subject: [PATCH 04/16] =?UTF-8?q?=F0=9F=8C=9F=20feat:=20Implement=20timeTo?= =?UTF-8?q?String=20function=20to=20format=20milliseconds=20into=20readabl?= =?UTF-8?q?e=20time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/constants/time.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) 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'; +}; From e4bc7cd2b0a10d6727a19d26f36b06b7953dec52 Mon Sep 17 00:00:00 2001 From: Ali Hammoud Date: Fri, 17 Oct 2025 20:14:11 +0300 Subject: [PATCH 05/16] =?UTF-8?q?=F0=9F=8C=9F=20feat:=20Add=20cache-messag?= =?UTF-8?q?es=20command=20to=20cache=20messages=20in=20all=20public=20text?= =?UTF-8?q?=20channels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/moderation/cache-messages.ts | 37 +++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/commands/moderation/cache-messages.ts diff --git a/src/commands/moderation/cache-messages.ts b/src/commands/moderation/cache-messages.ts new file mode 100644 index 0000000..25ef536 --- /dev/null +++ b/src/commands/moderation/cache-messages.ts @@ -0,0 +1,37 @@ +import { 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(), + }, + execute: async (interaction) => { + await interaction.deferReply(); + if (!interaction.guild) { + 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; + + await interaction.editReply('Caching messages in all public text channels...'); + + const { cachedChannels, totalChannels } = await fetchAndCachePublicChannelsMessages(guild); + + await interaction.editReply( + `Cached messages in ${cachedChannels} out of ${totalChannels} text channels.` + ); + + return; + }, +}); From 10e6b4a692379045a977fbc07beb2644d17865f0 Mon Sep 17 00:00:00 2001 From: Ali Hammoud Date: Fri, 17 Oct 2025 20:15:23 +0300 Subject: [PATCH 06/16] =?UTF-8?q?=F0=9F=8C=9F=20feat:=20Add=20force=20re-c?= =?UTF-8?q?aching=20option=20to=20cache-messages=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/moderation/cache-messages.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/commands/moderation/cache-messages.ts b/src/commands/moderation/cache-messages.ts index 25ef536..ec99510 100644 --- a/src/commands/moderation/cache-messages.ts +++ b/src/commands/moderation/cache-messages.ts @@ -1,4 +1,4 @@ -import { PermissionFlagsBits, PermissionsBitField } from 'discord.js'; +import { ApplicationCommandOptionType, PermissionFlagsBits, PermissionsBitField } from 'discord.js'; import { fetchAndCachePublicChannelsMessages } from '../../util/cache.js'; import { createCommand } from '../../util/commands.js'; @@ -9,10 +9,18 @@ export default createCommand({ 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) { + if (!interaction.guild || !interaction.isChatInputCommand()) { await interaction.editReply('This command can only be used in a guild.'); return; } @@ -23,10 +31,14 @@ export default createCommand({ } const guild = interaction.guild; + const force = interaction.options.getBoolean('force') ?? false; await interaction.editReply('Caching messages in all public text channels...'); - const { cachedChannels, totalChannels } = await fetchAndCachePublicChannelsMessages(guild); + const { cachedChannels, totalChannels } = await fetchAndCachePublicChannelsMessages( + guild, + force + ); await interaction.editReply( `Cached messages in ${cachedChannels} out of ${totalChannels} text channels.` From e81a74e53f2a6bd9a8c042535847ed6b9c7b1a57 Mon Sep 17 00:00:00 2001 From: Ali Hammoud Date: Fri, 17 Oct 2025 20:19:00 +0300 Subject: [PATCH 07/16] =?UTF-8?q?=F0=9F=8C=9F=20feat:=20Add=20new=20config?= =?UTF-8?q?=20variables=20(repel,=20server=20Id,=20moderator=20role=20Ids?= =?UTF-8?q?=20and=20cache=20on=20start)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 7 +++++++ src/env.ts | 9 +++++++++ 2 files changed, 16 insertions(+) diff --git a/.env.example b/.env.example index 9c885f7..9a64f63 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,9 @@ DISCORD_TOKEN="" # Your bot token CLIENT_ID="" # Your bot's application ID + +SERVER_ID= +MODERATORS_ROLE_IDS= # Comma separated list of role IDs that are Moderators(Mods, Admins, etc) + +FETCH_AND_SYNC_MESSAGES= # if true, the bot will fetch and sync messages from pubic channels on startup + +REPEL_LOG_CHANNEL_ID= # Channel ID where the bot will log repel actions \ No newline at end of file diff --git a/src/env.ts b/src/env.ts index 833adcf..814ecc8 100644 --- a/src/env.ts +++ b/src/env.ts @@ -16,6 +16,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: requireEnv('FETCH_AND_SYNC_MESSAGES') === 'true', + serverId: requireEnv('SERVER_ID'), + moderatorsRoleIds: requireEnv('MODERATORS_ROLE_IDS') + ? requireEnv('MODERATORS_ROLE_IDS').split(',') + : [], // Add more config sections as needed: // database: { // url: requireEnv('DATABASE_URL'), From 0f8d35440f3e94bd9e72e87e88f2ec9cca600918 Mon Sep 17 00:00:00 2001 From: Ali Hammoud Date: Fri, 17 Oct 2025 20:19:53 +0300 Subject: [PATCH 08/16] =?UTF-8?q?=F0=9F=8C=9F=20feat:=20Fetch=20and=20cach?= =?UTF-8?q?e=20messages=20on=20client=20ready=20event?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/events/ready.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/events/ready.ts b/src/events/ready.ts index 9b5bbcb..2acc873 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -1,4 +1,6 @@ import { Events } from 'discord.js'; +import { config } from '../env.js'; +import { fetchAndCachePublicChannelsMessages } from '../util/cache.js'; import { createEvent } from '../util/events.js'; export const readyEvent = createEvent( @@ -6,7 +8,13 @@ export const readyEvent = createEvent( name: Events.ClientReady, once: true, }, - (client) => { + 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); + } + } } ); From 526a2fb0848cf4fb42678ed3a65e0094f12ea5d2 Mon Sep 17 00:00:00 2001 From: Ali Hammoud Date: Fri, 17 Oct 2025 20:21:00 +0300 Subject: [PATCH 09/16] =?UTF-8?q?=F0=9F=8C=9F=20feat:=20Implement=20repel?= =?UTF-8?q?=20command=20to=20timeout=20users=20and=20delete=20recent=20mes?= =?UTF-8?q?sages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/moderation/repel.ts | 402 +++++++++++++++++++++++++++++++ 1 file changed, 402 insertions(+) create mode 100644 src/commands/moderation/repel.ts diff --git a/src/commands/moderation/repel.ts b/src/commands/moderation/repel.ts new file mode 100644 index 0000000..96fb66e --- /dev/null +++ b/src/commands/moderation/repel.ts @@ -0,0 +1,402 @@ +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 { + 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 { + return 0; + } +}; + +const getTextChannels = (interaction: ChatInputCommandInteraction) => { + if (!interaction.inGuild() || !interaction.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; + await Promise.all( + 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 {} + }) + ); + return deleted; +}; + +const logRepelAction = async ({ + interaction, + member, + target, + duration, + deleteCount, + reason, +}: { + interaction: ChatInputCommandInteraction; + member: GuildMember; + target: User | GuildMember; + reason: string; + duration?: number; + deleteCount: number; +}) => { + 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 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; + await logToChannel({ + channel, + content: { + type: 'embed', + embed: [commandEmbed, resultEmbed], + 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 = await handleDeleteMessages({ + channels, + target: target, + lookBack: lookBack ?? DEFAULT_LOOK_BACK, + }); + + logRepelAction({ + interaction, + member: commandUser, + target, + reason, + duration: timeout, + deleteCount: deleted, + }); + + await interaction.editReply({ + content: `Deleted ${deleted} message(s).`, + }); + } catch (error) { + console.error('Error executing repel command:', error); + } + }, +}); From 4827b15e87d86e3f338d49f7a0dffbb90daf22c5 Mon Sep 17 00:00:00 2001 From: Ali Hammoud Date: Fri, 17 Oct 2025 20:21:39 +0300 Subject: [PATCH 10/16] =?UTF-8?q?=F0=9F=8C=9F=20feat:=20Add=20cacheMessage?= =?UTF-8?q?s=20and=20repelCommand=20to=20commands=20map?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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]) ); From 64796e6a58ff2e002d7cac2c1c473656989c611a Mon Sep 17 00:00:00 2001 From: Ali Hammoud Date: Sat, 18 Oct 2025 21:33:03 +0300 Subject: [PATCH 11/16] =?UTF-8?q?=F0=9F=94=A8=20refactor:=20remove=20FETCH?= =?UTF-8?q?=5FAND=5FSYNC=5FMESSAGES=20from=20env=20and=20put=20it=20direct?= =?UTF-8?q?ly=20in=20the=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 -- src/env.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 9a64f63..e3cec0b 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,4 @@ CLIENT_ID="" # Your bot's application ID SERVER_ID= MODERATORS_ROLE_IDS= # Comma separated list of role IDs that are Moderators(Mods, Admins, etc) -FETCH_AND_SYNC_MESSAGES= # if true, the bot will fetch and sync messages from pubic channels on startup - REPEL_LOG_CHANNEL_ID= # Channel ID where the bot will log repel actions \ No newline at end of file diff --git a/src/env.ts b/src/env.ts index 814ecc8..fff9b83 100644 --- a/src/env.ts +++ b/src/env.ts @@ -20,7 +20,7 @@ export const config = { repelLogChannelId: requireEnv('REPEL_LOG_CHANNEL_ID'), repelRoleId: requireEnv('REPEL_ROLE_ID'), }, - fetchAndSyncMessages: requireEnv('FETCH_AND_SYNC_MESSAGES') === 'true', + fetchAndSyncMessages: true, serverId: requireEnv('SERVER_ID'), moderatorsRoleIds: requireEnv('MODERATORS_ROLE_IDS') ? requireEnv('MODERATORS_ROLE_IDS').split(',') From b962467b87a4bdbdb3e2de87b0c521215963b6e1 Mon Sep 17 00:00:00 2001 From: Ali Hammoud Date: Sat, 18 Oct 2025 21:34:05 +0300 Subject: [PATCH 12/16] =?UTF-8?q?=F0=9F=94=A8=20refactor:=20remove=20unnec?= =?UTF-8?q?essary=20`return`=20from=20cache-messages=20command=20handler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/moderation/cache-messages.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/commands/moderation/cache-messages.ts b/src/commands/moderation/cache-messages.ts index ec99510..33cfa72 100644 --- a/src/commands/moderation/cache-messages.ts +++ b/src/commands/moderation/cache-messages.ts @@ -43,7 +43,5 @@ export default createCommand({ await interaction.editReply( `Cached messages in ${cachedChannels} out of ${totalChannels} text channels.` ); - - return; }, }); From d0cb686a046489b10448e4c51b39a936179d2fdb Mon Sep 17 00:00:00 2001 From: Ali Hammoud Date: Sat, 18 Oct 2025 21:39:22 +0300 Subject: [PATCH 13/16] =?UTF-8?q?=F0=9F=8C=9F=20feat:=20add=20error=20logg?= =?UTF-8?q?ing=20for=20target=20fetching=20and=20timeout=20functions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/moderation/repel.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/commands/moderation/repel.ts b/src/commands/moderation/repel.ts index 96fb66e..9f72609 100644 --- a/src/commands/moderation/repel.ts +++ b/src/commands/moderation/repel.ts @@ -99,7 +99,8 @@ const getTargetFromInteraction = async ( } try { target = await interaction.guild.members.fetch(targetFromOption.id); - } catch { + } catch (error) { + console.error('Error fetching target as guild member:', error); target = targetFromOption; } return target; @@ -118,13 +119,15 @@ const handleTimeout = async ({ try { await target.timeout(duration * HOUR, 'Repel command executed'); return duration; - } catch { + } 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); From db8930eb4aa5df3e3c7cfc0449f8d236b1b238a2 Mon Sep 17 00:00:00 2001 From: Ali Hammoud Date: Sat, 18 Oct 2025 21:54:28 +0300 Subject: [PATCH 14/16] =?UTF-8?q?=F0=9F=8C=9F=20feat:=20replace=20Promis.a?= =?UTF-8?q?ll=20with=20Promise.allSettled=20and=20track/log=20failed=20cha?= =?UTF-8?q?nnels=20in=20message=20deletion=20function?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/moderation/repel.ts | 36 +++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/src/commands/moderation/repel.ts b/src/commands/moderation/repel.ts index 9f72609..36a5457 100644 --- a/src/commands/moderation/repel.ts +++ b/src/commands/moderation/repel.ts @@ -144,7 +144,8 @@ const handleDeleteMessages = async ({ lookBack: number; }) => { let deleted = 0; - await Promise.all( + const failedChannels: string[] = []; + await Promise.allSettled( channels.map(async (channel) => { try { const messages = channel.messages.cache; @@ -162,10 +163,17 @@ const handleDeleteMessages = async ({ } await channel.bulkDelete(targetMessages, true); deleted += targetMessages.length; - } catch {} + } catch (error) { + console.error(`Error deleting messages in channel ${channel.name}:`, error); + failedChannels.push(channel.id); + throw error; + } }) ); - return deleted; + if (failedChannels.length > 0) { + console.error(`Failed to delete messages in ${failedChannels.length} channel(s).`); + } + return { deleted, failedChannels }; }; const logRepelAction = async ({ @@ -175,6 +183,7 @@ const logRepelAction = async ({ duration, deleteCount, reason, + failedChannels, }: { interaction: ChatInputCommandInteraction; member: GuildMember; @@ -182,6 +191,7 @@ const logRepelAction = async ({ reason: string; duration?: number; deleteCount: number; + failedChannels: string[]; }) => { const channelInfo = interaction.channel?.type === ChannelType.GuildVoice @@ -235,6 +245,15 @@ const logRepelAction = async ({ .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}` @@ -242,11 +261,17 @@ const logRepelAction = async ({ 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: [commandEmbed, resultEmbed], + embed, content: mentionText, }, }); @@ -380,7 +405,7 @@ export const repelCommand = createCommand({ const channels = getTextChannels(interaction); - const deleted = await handleDeleteMessages({ + const { deleted, failedChannels } = await handleDeleteMessages({ channels, target: target, lookBack: lookBack ?? DEFAULT_LOOK_BACK, @@ -393,6 +418,7 @@ export const repelCommand = createCommand({ reason, duration: timeout, deleteCount: deleted, + failedChannels, }); await interaction.editReply({ From 41ef4cec58767831268d9977b65c17b74fe2b9cd Mon Sep 17 00:00:00 2001 From: Ali Hammoud Date: Sat, 18 Oct 2025 22:04:12 +0300 Subject: [PATCH 15/16] =?UTF-8?q?=F0=9F=8C=9F=20feat:=20enhance=20message?= =?UTF-8?q?=20caching=20by=20tracking=20failed=20channels=20and=20using=20?= =?UTF-8?q?Promise.allSettled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/moderation/cache-messages.ts | 12 +++++----- src/util/cache.ts | 27 ++++++++++++++++------- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/src/commands/moderation/cache-messages.ts b/src/commands/moderation/cache-messages.ts index 33cfa72..004bd74 100644 --- a/src/commands/moderation/cache-messages.ts +++ b/src/commands/moderation/cache-messages.ts @@ -35,13 +35,15 @@ export default createCommand({ await interaction.editReply('Caching messages in all public text channels...'); - const { cachedChannels, totalChannels } = await fetchAndCachePublicChannelsMessages( - guild, - force - ); + 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.` + `Cached messages in ${cachedChannels} out of ${totalChannels} text channels.${failedMessage}` ); }, }); diff --git a/src/util/cache.ts b/src/util/cache.ts index b1d32c9..24e7366 100644 --- a/src/util/cache.ts +++ b/src/util/cache.ts @@ -6,19 +6,30 @@ 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.all( + await Promise.allSettled( channels.map(async (channel) => { if (force || !cachedChannelsMap.has(channel.id)) { - 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++; + 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 }; + return { cachedChannels, totalChannels: channels.size, failedChannels }; }; From e33f68073cfef406c0116bad3293a26ab677c73a Mon Sep 17 00:00:00 2001 From: Ali Hammoud Date: Sat, 18 Oct 2025 22:24:41 +0300 Subject: [PATCH 16/16] =?UTF-8?q?=F0=9F=90=9B=20fix:=20fix=20missing=20}?= =?UTF-8?q?=20after=20resolving=20merge=20conflicts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/events/ready.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/events/ready.ts b/src/events/ready.ts index b94f694..dd9127a 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -17,12 +17,13 @@ export const readyEvent = createEvent( 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); + } } } );