Skip to content

Add reaction roles, autoresponder, moderation actions, and action log#3

Draft
Copilot wants to merge 3 commits into
mainfrom
copilot/add-functionality-to-discord-mod
Draft

Add reaction roles, autoresponder, moderation actions, and action log#3
Copilot wants to merge 3 commits into
mainfrom
copilot/add-functionality-to-discord-mod

Conversation

Copilot AI commented Apr 24, 2026

Copy link
Copy Markdown

Replaces dyno.gg functionality the server currently depends on. Four feature areas were missing or incomplete: reaction roles, autoresponder, moderation actions, and action log.

Reaction Roles (/reactionroles)

  • add <channel> <message_id> <emoji> <role> — binds emoji→role on a message; bot self-reacts for discoverability
  • remove / list
  • Handles Unicode and custom emoji via stable ID-based normalisation; fetches partial reaction/message/user objects so it works on uncached messages

Autoresponder (/autoresponder)

  • toggle — per-guild on/off
  • add <trigger> <response> [match_type] — three match modes: contains (default), exact, startsWith
  • remove (with autocomplete) / list

Moderation Actions (/mod)

  • kick, ban, unban, timeout, untimeout — with kickable/bannable/moderatable guards
  • warn, warnings, clearwarnings — SQLite-backed warning history per guild+user
  • All actions emit to the existing modlog channel

Action Log (/moderation actionlog + /moderation actionlogevents)

  • New actionlog channel setting (separate from modlog)
  • Events logged: message delete/edit (before/after content), ban/unban, nickname change, role add/remove
  • Each event type is individually configurable via /moderation actionlogevents <event> [enable] — choose from message_delete, message_edit, member_ban, member_unban, nickname_change, role_change
  • All events default to enabled (opt-out model); omitting enable reports the current state for that event
  • Role-change diffing skipped when oldMember is partial to avoid false positives

Infrastructure

  • src/index.ts: fixed silent bug where array-export event files (e.g. existing joinleave.ts) were never registered; added GuildMessageReactions, GuildModeration intents and Partials.Message, Partials.Reaction
  • src/types/base.ts: added ReactionRole, AutoResponderEntry, Warning types; added ActionLogEvent union type and actionlogEvents per-event toggle map; extended GuildSettings with actionlog, actionlogEvents, and autoresponder
  • src/db.ts: added reactionrolesDB, autoresponderDB, warningsDB
Original prompt

Add more functionality to this Discord mod. We are looking to move off of dyno.gg. From that bot we currently use: reaction roles, automod, autoresponder, moderation, and action log. For some of those this bot has no equivalent, for others (like moderation and automod) it is woefully incomplete.

Created from VS Code.

Copilot AI changed the title [WIP] Add functionality to Discord mod to replace dyno.gg features Add reaction roles, autoresponder, moderation actions, and action log Apr 24, 2026
Copilot AI requested a review from zerebos April 24, 2026 04:00
@zerebos

zerebos commented Apr 24, 2026

Copy link
Copy Markdown
Member

@copilot can you make the action log configurable in some way? We don't always want to log all events. For example we might not keep join/leave logs on

Copilot AI commented Apr 24, 2026

Copy link
Copy Markdown
Author

@copilot can you make the action log configurable in some way? We don't always want to log all events. For example we might not keep join/leave logs on

Done in commit 3312ccb. Added a new /moderation actionlogevents <event> [enable] subcommand with a fixed choices dropdown covering all six logged event types:

  • Message Deleted (message_delete)
  • Message Edited (message_edit)
  • Member Banned (member_ban)
  • Member Unbanned (member_unban)
  • Nickname Changed (nickname_change)
  • Role Changes (role_change)

All events default to enabled (opt-out model), so existing setups aren't affected. Omitting the enable option reports the current state for that event type. Each event is checked independently in the handler, so nickname and role-change events can be toggled separately despite sharing the same Discord event.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds missing Dyno-like moderation features to the bot: reaction roles, an autoresponder module, a richer moderation command set (including warnings), and a configurable “action log” channel with multiple event types.

Changes:

  • Introduces new DB-backed modules for reaction roles, autoresponder triggers, and per-user warning history.
  • Adds /reactionroles, /autoresponder, and /mod commands, plus extends /moderation with action log configuration.
  • Implements new event handlers for reaction roles, autoresponder replies, and action-log event emission; updates client intents/partials and fixes event loader to support array-export event modules.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
src/types/base.ts Extends GuildSettings and adds new feature types (reaction roles, autoresponder entries, warnings, action log event union).
src/index.ts Adds intents/partials needed for new features and fixes event registration for array-export event modules.
src/db.ts Adds new Keyv namespaces for reaction roles, autoresponder, and warnings storage.
src/events/reactionroles.ts Adds reaction add/remove handlers to assign/unassign roles based on configured bindings.
src/events/autoresponder.ts Adds message-create handler to match triggers and send configured responses.
src/events/actionlog.ts Adds action log event handlers (message delete/edit, ban/unban, nickname and role changes).
src/commands/reactionroles.ts Adds /reactionroles add/remove/list for managing bindings and self-reacting for discoverability.
src/commands/autoresponder.ts Adds /autoresponder toggle/add/remove/list plus autocomplete for removal.
src/commands/moderation.ts Adds /moderation actionlog and /moderation actionlogevents settings management.
src/commands/mod.ts Adds /mod moderation actions and SQLite-backed warnings.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/commands/mod.ts
Comment on lines +93 to +96
const member = interaction.guild.members.cache.get(user.id);

if (!member) return await interaction.reply(Messages.error("That user is not in this server.", {ephemeral: true}));
if (!member.kickable) return await interaction.reply(Messages.error("I cannot kick that user. They may have a higher role than me.", {ephemeral: true}));

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

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

interaction.guild.members.cache.get(user.id) can be undefined even when the user is in the guild (member not cached), causing false "user is not in this server" errors and preventing kick/timeout/untimeout. Prefer interaction.options.getMember("user") (for cached interactions) or await interaction.guild.members.fetch(user.id) and reuse that for capability checks like kickable/moderatable.

Copilot uses AI. Check for mistakes.
Comment thread src/commands/mod.ts
data: new SlashCommandBuilder()
.setName("mod")
.setDescription("Moderation actions.")
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers)

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

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

The command is gated by ModerateMembers, but the subcommands include kick/ban/unban which are separate permissions. As written, members who can timeout (but cannot ban/kick) may still be able to run these subcommands successfully. Add explicit permission checks per subcommand (e.g. KickMembers, BanMembers, ModerateMembers) and/or restructure commands so the default permission matches the most sensitive action.

Suggested change
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)

Copilot uses AI. Check for mistakes.
Comment thread src/commands/mod.ts
Comment on lines +216 to +221
async clearwarnings(interaction: ChatInputCommandInteraction<"cached">) {
const user = interaction.options.getUser("user", true);
const key = `${interaction.guild.id}-${user.id}`;
await warningsDB.delete(key);
await interaction.reply(Messages.success(`Cleared all warnings for **${user.tag}**.`, {ephemeral: true}));
},

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

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

PR description says all moderation actions emit to the modlog, but clearwarnings currently only deletes from storage and replies to the moderator. Consider logging this action (including target user and moderator) via logModAction for consistency/auditability.

Copilot uses AI. Check for mistakes.
const emojiInput = interaction.options.getString("emoji", true);
const role = interaction.options.getRole("role", true);
const normalizedEmoji = normalizeEmojiString(emojiInput);

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

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

Before storing the binding, validate that the bot can actually assign the selected role (e.g. role.editable / role not managed). Otherwise /reactionroles add can succeed but reaction handling will always fail at runtime when attempting member.roles.add(...).

Suggested change
if (role.managed || !role.editable) {
return await interaction.editReply(Messages.error("I can't assign that role. Please choose a non-managed role that is below my highest role."));
}

Copilot uses AI. Check for mistakes.
Comment thread src/events/actionlog.ts
Comment on lines +38 to +41
if (message.content) embed.setDescription(message.content.substring(0, 4096));

await logChannel.send({embeds: [embed]}).catch(console.error);
},

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

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

These log messages may include user-generated content (message text, nicknames) containing mentions; channel.send({embeds:[...]}) can therefore ping @everyone/roles/users in the action log. Consider sending with allowedMentions: { parse: [] } to make logs non-pinging.

Copilot uses AI. Check for mistakes.
Comment thread src/events/actionlog.ts
Comment on lines +149 to +153

if (addedRoles.size) embed.addFields({name: "Roles Added", value: addedRoles.map(r => `<@&${r.id}>`).join(", ")});
if (removedRoles.size) embed.addFields({name: "Roles Removed", value: removedRoles.map(r => `<@&${r.id}>`).join(", ")});

await logChannel.send({embeds: [embed]}).catch(console.error);

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

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

Roles Added/Removed field values are built by joining all role mentions, which can exceed Discord's 1024-char embed field limit (causing the send to fail and the log event to be dropped). Consider truncating/splitting into multiple fields when the list is long.

Copilot uses AI. Check for mistakes.
Comment on lines +101 to +109
const description = current
.map(e => `**\`${e.trigger}\`** (${e.matchType})\n↳ ${e.response.substring(0, 80)}${e.response.length > 80 ? "…" : ""}`)
.join("\n\n");

const embed = new EmbedBuilder()
.setColor(Colors.Info)
.setTitle("Auto-Responses")
.setDescription(description);

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

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

The embed description is built from all triggers and can exceed Discord's 4096-char embed description limit in larger configs, causing the response to fail. Consider paging (you already have src/paginator.ts) or truncating the list output.

Copilot uses AI. Check for mistakes.
Comment thread src/commands/mod.ts
)
.setTimestamp();

await logChannel.send({embeds: [embed]}).catch(console.error);

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

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

When sending modlog embeds, consider setting allowedMentions: { parse: [] } (and optionally disable replying-user mentions elsewhere) so user-provided reason text or IDs in fields can’t trigger @everyone/role/user pings in the log channel.

Suggested change
await logChannel.send({embeds: [embed]}).catch(console.error);
await logChannel.send({embeds: [embed], allowedMentions: {parse: []}}).catch(console.error);

Copilot uses AI. Check for mistakes.
Comment on lines +124 to +126
const description = current
.map(r => `${r.emoji} → <@&${r.roleId}> (in <#${r.channelId}>, msg: \`${r.messageId}\`)`)
.join("\n");

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

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

For custom emojis you store only the numeric ID (normalizeEmojiString), so the list output will show something like 1234567890 → @Role instead of rendering the emoji. Consider storing a display form too (original input) or resolving the emoji from interaction.guild.emojis.cache.get(id) when building the list.

Copilot uses AI. Check for mistakes.

if (!match) return;

await message.reply(match.response).catch(console.error);

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

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

message.reply(match.response) will ping the author by default and will also allow @everyone/role/user mentions contained in stored responses. Consider using allowedMentions: { parse: [], repliedUser: false } (or a more targeted policy) to prevent mention spam/abuse.

Suggested change
await message.reply(match.response).catch(console.error);
await message.reply({
content: match.response,
allowedMentions: {
parse: [],
repliedUser: false,
},
}).catch(console.error);

Copilot uses AI. Check for mistakes.
@zerebos

zerebos commented Jun 19, 2026

Copy link
Copy Markdown
Member

/gemini review the current version

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces several new features to the Discord bot, including an autoresponder module, comprehensive moderation commands (kick, ban, timeout, warnings), an action logging system for server events, and reaction roles. The database, types, and main entry point are updated to support these additions. Feedback on the changes highlights a few critical issues: first, partial guild members can trigger false-positive nickname change logs due to undefined properties; second, relying on synchronous member cache lookups in moderation commands can fail for uncached members, so asynchronous fetching should be used instead; and third, checking bot on a partial user in reaction roles can bypass bot checks, which can be resolved by checking it after the user is fully fetched.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/events/actionlog.ts
Comment on lines +113 to +157
async execute(oldMember: GuildMember | PartialGuildMember, newMember: GuildMember) {
// Nickname changes
if (oldMember.nickname !== newMember.nickname) {
const logChannel = await getLogChannel(newMember.guild, "nickname_change");
if (logChannel) {
const embed = new EmbedBuilder()
.setColor(Colors.Info)
.setTitle("Nickname Changed")
.addFields(
{name: "User", value: `<@${newMember.user.id}> (${newMember.user.tag})`, inline: true},
{name: "Before", value: oldMember.nickname ?? "*None*", inline: true},
{name: "After", value: newMember.nickname ?? "*None*", inline: true}
)
.setFooter({text: `User ID: ${newMember.user.id}`})
.setTimestamp();

await logChannel.send({embeds: [embed]}).catch(console.error);
}
}

// Role changes — only reliable when the old member was fully cached
if (!oldMember.partial) {
const addedRoles = newMember.roles.cache.filter(r => !oldMember.roles.cache.has(r.id));
const removedRoles = oldMember.roles.cache.filter(r => !newMember.roles.cache.has(r.id));

if (addedRoles.size || removedRoles.size) {
const logChannel = await getLogChannel(newMember.guild, "role_change");
if (logChannel) {
const embed = new EmbedBuilder()
.setColor(Colors.Info)
.setTitle("Member Roles Updated")
.addFields(
{name: "User", value: `<@${newMember.user.id}> (${newMember.user.tag})`, inline: true}
)
.setFooter({text: `User ID: ${newMember.user.id}`})
.setTimestamp();

if (addedRoles.size) embed.addFields({name: "Roles Added", value: addedRoles.map(r => `<@&${r.id}>`).join(", ")});
if (removedRoles.size) embed.addFields({name: "Roles Removed", value: removedRoles.map(r => `<@&${r.id}>`).join(", ")});

await logChannel.send({embeds: [embed]}).catch(console.error);
}
}
}
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

When oldMember is a partial guild member, properties like nickname are undefined. If the member has no nickname, newMember.nickname is null. Comparing undefined !== null evaluates to true, which triggers a false-positive 'Nickname Changed' log (showing *None* to *None*). Additionally, any update to an uncached member will log a false nickname change from *None* to their current nickname.

Since we cannot reliably diff nicknames or roles for partial members, we should return early if oldMember.partial is true. This also allows us to simplify the code by removing the nested !oldMember.partial check for roles.

        async execute(oldMember: GuildMember | PartialGuildMember, newMember: GuildMember) {
            if (oldMember.partial) return;

            // Nickname changes
            if (oldMember.nickname !== newMember.nickname) {
                const logChannel = await getLogChannel(newMember.guild, "nickname_change");
                if (logChannel) {
                    const embed = new EmbedBuilder()
                        .setColor(Colors.Info)
                        .setTitle("Nickname Changed")
                        .addFields(
                            {name: "User", value: `<@${newMember.user.id}> (${newMember.user.tag})`, inline: true},
                            {name: "Before", value: oldMember.nickname ?? "*None*", inline: true},
                            {name: "After", value: newMember.nickname ?? "*None*", inline: true}
                        )
                        .setFooter({text: `User ID: ${newMember.user.id}`})
                        .setTimestamp();

                    await logChannel.send({embeds: [embed]}).catch(console.error);
                }
            }

            // Role changes
            const addedRoles = newMember.roles.cache.filter(r => !oldMember.roles.cache.has(r.id));
            const removedRoles = oldMember.roles.cache.filter(r => !newMember.roles.cache.has(r.id));

            if (addedRoles.size || removedRoles.size) {
                const logChannel = await getLogChannel(newMember.guild, "role_change");
                if (logChannel) {
                    const embed = new EmbedBuilder()
                        .setColor(Colors.Info)
                        .setTitle("Member Roles Updated")
                        .addFields(
                            {name: "User", value: `<@${newMember.user.id}> (${newMember.user.tag})`, inline: true}
                        )
                        .setFooter({text: `User ID: ${newMember.user.id}`})
                        .setTimestamp();

                    if (addedRoles.size) embed.addFields({name: "Roles Added", value: addedRoles.map(r => `<@&${r.id}>`).join(", ")});
                    if (removedRoles.size) embed.addFields({name: "Roles Removed", value: removedRoles.map(r => `<@&${r.id}>`).join(", ")});

                    await logChannel.send({embeds: [embed]}).catch(console.error);
                }
            }
        },

Comment thread src/commands/mod.ts
Comment on lines +90 to +96
async kick(interaction: ChatInputCommandInteraction<"cached">) {
const user = interaction.options.getUser("user", true);
const reason = interaction.options.getString("reason") ?? "No reason provided";
const member = interaction.guild.members.cache.get(user.id);

if (!member) return await interaction.reply(Messages.error("That user is not in this server.", {ephemeral: true}));
if (!member.kickable) return await interaction.reply(Messages.error("I cannot kick that user. They may have a higher role than me.", {ephemeral: true}));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Using interaction.guild.members.cache.get(user.id) can return undefined if the member is not currently cached, even if they are in the server. This will cause the bot to incorrectly claim 'That user is not in this server.' for uncached members. Fetching the member asynchronously is much more reliable.

Suggested change
async kick(interaction: ChatInputCommandInteraction<"cached">) {
const user = interaction.options.getUser("user", true);
const reason = interaction.options.getString("reason") ?? "No reason provided";
const member = interaction.guild.members.cache.get(user.id);
if (!member) return await interaction.reply(Messages.error("That user is not in this server.", {ephemeral: true}));
if (!member.kickable) return await interaction.reply(Messages.error("I cannot kick that user. They may have a higher role than me.", {ephemeral: true}));
async kick(interaction: ChatInputCommandInteraction<"cached">) {
const user = interaction.options.getUser("user", true);
const reason = interaction.options.getString("reason") ?? "No reason provided";
const member = await interaction.guild.members.fetch(user.id).catch(() => null);
if (!member) return await interaction.reply(Messages.error("That user is not in this server.", {ephemeral: true}));
if (!member.kickable) return await interaction.reply(Messages.error("I cannot kick that user. They may have a higher role than me.", {ephemeral: true}));

Comment thread src/commands/mod.ts
Comment on lines +109 to +115
async ban(interaction: ChatInputCommandInteraction<"cached">) {
const user = interaction.options.getUser("user", true);
const reason = interaction.options.getString("reason") ?? "No reason provided";
const deleteDays = interaction.options.getInteger("delete_days") ?? 0;
const member = interaction.guild.members.cache.get(user.id);

if (member && !member.bannable) return await interaction.reply(Messages.error("I cannot ban that user. They may have a higher role than me.", {ephemeral: true}));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Using interaction.guild.members.cache.get(user.id) can return undefined if the member is not currently cached, which would bypass the hierarchy check (member && !member.bannable) and attempt to ban them anyway. Fetching the member asynchronously ensures the hierarchy check is always performed correctly.

Suggested change
async ban(interaction: ChatInputCommandInteraction<"cached">) {
const user = interaction.options.getUser("user", true);
const reason = interaction.options.getString("reason") ?? "No reason provided";
const deleteDays = interaction.options.getInteger("delete_days") ?? 0;
const member = interaction.guild.members.cache.get(user.id);
if (member && !member.bannable) return await interaction.reply(Messages.error("I cannot ban that user. They may have a higher role than me.", {ephemeral: true}));
async ban(interaction: ChatInputCommandInteraction<"cached">) {
const user = interaction.options.getUser("user", true);
const reason = interaction.options.getString("reason") ?? "No reason provided";
const deleteDays = interaction.options.getInteger("delete_days") ?? 0;
const member = await interaction.guild.members.fetch(user.id).catch(() => null);
if (member && !member.bannable) return await interaction.reply(Messages.error("I cannot ban that user. They may have a higher role than me.", {ephemeral: true}));

Comment thread src/commands/mod.ts
Comment on lines +143 to +150
async timeout(interaction: ChatInputCommandInteraction<"cached">) {
const user = interaction.options.getUser("user", true);
const durationMinutes = interaction.options.getInteger("duration", true);
const reason = interaction.options.getString("reason") ?? "No reason provided";
const member = interaction.guild.members.cache.get(user.id);

if (!member) return await interaction.reply(Messages.error("That user is not in this server.", {ephemeral: true}));
if (!member.moderatable) return await interaction.reply(Messages.error("I cannot timeout that user. They may have a higher role than me.", {ephemeral: true}));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Using interaction.guild.members.cache.get(user.id) can return undefined if the member is not currently cached, even if they are in the server. This will cause the bot to incorrectly claim 'That user is not in this server.' for uncached members. Fetching the member asynchronously is much more reliable.

Suggested change
async timeout(interaction: ChatInputCommandInteraction<"cached">) {
const user = interaction.options.getUser("user", true);
const durationMinutes = interaction.options.getInteger("duration", true);
const reason = interaction.options.getString("reason") ?? "No reason provided";
const member = interaction.guild.members.cache.get(user.id);
if (!member) return await interaction.reply(Messages.error("That user is not in this server.", {ephemeral: true}));
if (!member.moderatable) return await interaction.reply(Messages.error("I cannot timeout that user. They may have a higher role than me.", {ephemeral: true}));
async timeout(interaction: ChatInputCommandInteraction<"cached">) {
const user = interaction.options.getUser("user", true);
const durationMinutes = interaction.options.getInteger("duration", true);
const reason = interaction.options.getString("reason") ?? "No reason provided";
const member = await interaction.guild.members.fetch(user.id).catch(() => null);
if (!member) return await interaction.reply(Messages.error("That user is not in this server.", {ephemeral: true}));
if (!member.moderatable) return await interaction.reply(Messages.error("I cannot timeout that user. They may have a higher role than me.", {ephemeral: true}));

Comment thread src/commands/mod.ts
Comment on lines +163 to +168
async untimeout(interaction: ChatInputCommandInteraction<"cached">) {
const user = interaction.options.getUser("user", true);
const reason = interaction.options.getString("reason") ?? "No reason provided";
const member = interaction.guild.members.cache.get(user.id);

if (!member) return await interaction.reply(Messages.error("That user is not in this server.", {ephemeral: true}));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Using interaction.guild.members.cache.get(user.id) can return undefined if the member is not currently cached, even if they are in the server. This will cause the bot to incorrectly claim 'That user is not in this server.' for uncached members. Fetching the member asynchronously is much more reliable.

Suggested change
async untimeout(interaction: ChatInputCommandInteraction<"cached">) {
const user = interaction.options.getUser("user", true);
const reason = interaction.options.getString("reason") ?? "No reason provided";
const member = interaction.guild.members.cache.get(user.id);
if (!member) return await interaction.reply(Messages.error("That user is not in this server.", {ephemeral: true}));
async untimeout(interaction: ChatInputCommandInteraction<"cached">) {
const user = interaction.options.getUser("user", true);
const reason = interaction.options.getString("reason") ?? "No reason provided";
const member = await interaction.guild.members.fetch(user.id).catch(() => null);
if (!member) return await interaction.reply(Messages.error("That user is not in this server.", {ephemeral: true}));

Comment on lines +20 to +26
if (rawUser.bot) return;

const reaction = rawReaction.partial ? await rawReaction.fetch().catch(() => null) : rawReaction;
if (!reaction) return;

const user = rawUser.partial ? await rawUser.fetch().catch(() => null) : rawUser;
if (!user) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

If rawUser is a PartialUser, the bot property may be undefined. Checking rawUser.bot before fetching can allow bot reactions to bypass the check and trigger reaction roles. It is safer to check user.bot after ensuring the user object is fully fetched.

Suggested change
if (rawUser.bot) return;
const reaction = rawReaction.partial ? await rawReaction.fetch().catch(() => null) : rawReaction;
if (!reaction) return;
const user = rawUser.partial ? await rawUser.fetch().catch(() => null) : rawUser;
if (!user) return;
const reaction = rawReaction.partial ? await rawReaction.fetch().catch(() => null) : rawReaction;
if (!reaction) return;
const user = rawUser.partial ? await rawUser.fetch().catch(() => null) : rawUser;
if (!user || user.bot) return;

Comment on lines +48 to +54
if (rawUser.bot) return;

const reaction = rawReaction.partial ? await rawReaction.fetch().catch(() => null) : rawReaction;
if (!reaction) return;

const user = rawUser.partial ? await rawUser.fetch().catch(() => null) : rawUser;
if (!user) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

If rawUser is a PartialUser, the bot property may be undefined. Checking rawUser.bot before fetching can allow bot reactions to bypass the check and trigger reaction roles. It is safer to check user.bot after ensuring the user object is fully fetched.

Suggested change
if (rawUser.bot) return;
const reaction = rawReaction.partial ? await rawReaction.fetch().catch(() => null) : rawReaction;
if (!reaction) return;
const user = rawUser.partial ? await rawUser.fetch().catch(() => null) : rawUser;
if (!user) return;
const reaction = rawReaction.partial ? await rawReaction.fetch().catch(() => null) : rawReaction;
if (!reaction) return;
const user = rawUser.partial ? await rawUser.fetch().catch(() => null) : rawUser;
if (!user || user.bot) return;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants