diff --git a/AGENTS.md b/AGENTS.md index b6890cb08..5117dade5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,8 @@ | `src/utils/health.js` | Health monitoring singleton | | `src/utils/permissions.js` | Permission checking for commands | | `src/utils/retry.js` | Retry utility for flaky operations | +| `src/utils/safeSend.js` | Safe message-sending wrappers — sanitizes mentions and enforces allowedMentions on every outgoing message | +| `src/utils/sanitizeMentions.js` | Mention sanitization — strips @everyone/@here from outgoing text via zero-width space insertion | | `src/utils/registerCommands.js` | Discord REST API command registration | | `src/utils/splitMessage.js` | Message splitting for Discord's 2000-char limit | | `src/utils/duration.js` | Duration parsing — "1h", "7d" ↔ ms with human-readable formatting | diff --git a/src/commands/ban.js b/src/commands/ban.js index 3827504eb..4f2b4585d 100644 --- a/src/commands/ban.js +++ b/src/commands/ban.js @@ -13,6 +13,7 @@ import { sendModLogEmbed, shouldSendDm, } from '../modules/moderation.js'; +import { safeEditReply } from '../utils/safeSend.js'; export const data = new SlashCommandBuilder() .setName('ban') @@ -59,7 +60,7 @@ export async function execute(interaction) { interaction.guild.members.me, ); if (hierarchyError) { - return await interaction.editReply(hierarchyError); + return await safeEditReply(interaction, hierarchyError); } if (shouldSendDm(config, 'ban')) { @@ -84,13 +85,15 @@ export async function execute(interaction) { await sendModLogEmbed(interaction.client, config, caseData); info('User banned', { target: user.tag, moderator: interaction.user.tag }); - await interaction.editReply( + await safeEditReply( + interaction, `✅ **${user.tag}** has been banned. (Case #${caseData.case_number})`, ); } catch (err) { logError('Command error', { error: err.message, command: 'ban' }); - await interaction - .editReply('❌ An error occurred. Please try again or contact an administrator.') - .catch(() => {}); + await safeEditReply( + interaction, + '❌ An error occurred. Please try again or contact an administrator.', + ).catch(() => {}); } } diff --git a/src/commands/case.js b/src/commands/case.js index c5e82a220..90d4ec605 100644 --- a/src/commands/case.js +++ b/src/commands/case.js @@ -8,6 +8,7 @@ import { getPool } from '../db.js'; import { info, error as logError } from '../logger.js'; import { getConfig } from '../modules/config.js'; import { ACTION_COLORS, ACTION_LOG_CHANNEL_KEY } from '../modules/moderation.js'; +import { safeEditReply } from '../utils/safeSend.js'; export const data = new SlashCommandBuilder() .setName('case') @@ -117,7 +118,7 @@ export async function execute(interaction) { } } catch (err) { logError('Case command failed', { error: err.message, subcommand }); - await interaction.editReply('Failed to execute case command.'); + await safeEditReply(interaction, 'Failed to execute case command.'); } } @@ -135,11 +136,11 @@ async function handleView(interaction) { ); if (rows.length === 0) { - return await interaction.editReply(`Case #${caseId} not found.`); + return await safeEditReply(interaction, `Case #${caseId} not found.`); } const embed = buildCaseEmbed(rows[0]); - await interaction.editReply({ embeds: [embed] }); + await safeEditReply(interaction, { embeds: [embed] }); } /** @@ -172,7 +173,7 @@ async function handleList(interaction) { const { rows } = await pool.query(query, params); if (rows.length === 0) { - return await interaction.editReply('No cases found matching the criteria.'); + return await safeEditReply(interaction, 'No cases found matching the criteria.'); } const lines = rows.map((row) => { @@ -192,7 +193,7 @@ async function handleList(interaction) { .setFooter({ text: `Showing ${rows.length} case(s)` }) .setTimestamp(); - await interaction.editReply({ embeds: [embed] }); + await safeEditReply(interaction, { embeds: [embed] }); } /** @@ -210,7 +211,7 @@ async function handleReason(interaction) { ); if (rows.length === 0) { - return await interaction.editReply(`Case #${caseId} not found.`); + return await safeEditReply(interaction, `Case #${caseId} not found.`); } const caseRow = rows[0]; @@ -249,7 +250,7 @@ async function handleReason(interaction) { moderator: interaction.user.tag, }); - await interaction.editReply(`Updated reason for case #${caseId}.`); + await safeEditReply(interaction, `Updated reason for case #${caseId}.`); } /** @@ -266,7 +267,7 @@ async function handleDelete(interaction) { ); if (rows.length === 0) { - return await interaction.editReply(`Case #${caseId} not found.`); + return await safeEditReply(interaction, `Case #${caseId} not found.`); } info('Case deleted', { @@ -275,5 +276,5 @@ async function handleDelete(interaction) { moderator: interaction.user.tag, }); - await interaction.editReply(`Deleted case #${caseId} (${rows[0].action}).`); + await safeEditReply(interaction, `Deleted case #${caseId} (${rows[0].action}).`); } diff --git a/src/commands/config.js b/src/commands/config.js index e1cfe2c32..13e402903 100644 --- a/src/commands/config.js +++ b/src/commands/config.js @@ -5,6 +5,7 @@ import { EmbedBuilder, SlashCommandBuilder } from 'discord.js'; import { getConfig, resetConfig, setConfigValue } from '../modules/config.js'; +import { safeEditReply, safeReply } from '../utils/safeSend.js'; /** * Escape backticks in user-provided strings to prevent breaking Discord inline code formatting. @@ -169,7 +170,7 @@ export async function execute(interaction) { await handleReset(interaction); break; default: - await interaction.reply({ + await safeReply(interaction, { content: `❌ Unknown subcommand: \`${subcommand}\``, ephemeral: true, }); @@ -200,7 +201,7 @@ async function handleView(interaction) { const sectionData = config[section]; if (!sectionData) { const safeSection = escapeInlineCode(section); - return await interaction.reply({ + return await safeReply(interaction, { content: `❌ Section \`${safeSection}\` not found in config`, ephemeral: true, }); @@ -254,11 +255,11 @@ async function handleView(interaction) { } } - await interaction.reply({ embeds: [embed], ephemeral: true }); + await safeReply(interaction, { embeds: [embed], ephemeral: true }); } catch (err) { const safeMessage = process.env.NODE_ENV === 'development' ? err.message : 'An internal error occurred.'; - await interaction.reply({ + await safeReply(interaction, { content: `❌ Failed to load config: ${safeMessage}`, ephemeral: true, }); @@ -277,7 +278,7 @@ async function handleSet(interaction) { const validSections = Object.keys(getConfig()); if (!validSections.includes(section)) { const safeSection = escapeInlineCode(section); - return await interaction.reply({ + return await safeReply(interaction, { content: `❌ Invalid section \`${safeSection}\`. Valid sections: ${validSections.join(', ')}`, ephemeral: true, }); @@ -308,15 +309,15 @@ async function handleSet(interaction) { .setFooter({ text: 'Changes take effect immediately' }) .setTimestamp(); - await interaction.editReply({ embeds: [embed] }); + await safeEditReply(interaction, { embeds: [embed] }); } catch (err) { const safeMessage = process.env.NODE_ENV === 'development' ? err.message : 'An internal error occurred.'; const content = `❌ Failed to set config: ${safeMessage}`; if (interaction.deferred) { - await interaction.editReply({ content }); + await safeEditReply(interaction, { content }); } else { - await interaction.reply({ content, ephemeral: true }); + await safeReply(interaction, { content, ephemeral: true }); } } } @@ -343,15 +344,15 @@ async function handleReset(interaction) { .setFooter({ text: 'Changes take effect immediately' }) .setTimestamp(); - await interaction.editReply({ embeds: [embed] }); + await safeEditReply(interaction, { embeds: [embed] }); } catch (err) { const safeMessage = process.env.NODE_ENV === 'development' ? err.message : 'An internal error occurred.'; const content = `❌ Failed to reset config: ${safeMessage}`; if (interaction.deferred) { - await interaction.editReply({ content }); + await safeEditReply(interaction, { content }); } else { - await interaction.reply({ content, ephemeral: true }); + await safeReply(interaction, { content, ephemeral: true }); } } } diff --git a/src/commands/history.js b/src/commands/history.js index 8c55903ba..3217ba6d3 100644 --- a/src/commands/history.js +++ b/src/commands/history.js @@ -6,6 +6,7 @@ import { EmbedBuilder, SlashCommandBuilder } from 'discord.js'; import { getPool } from '../db.js'; import { info, error as logError } from '../logger.js'; +import { safeEditReply } from '../utils/safeSend.js'; export const data = new SlashCommandBuilder() .setName('history') @@ -30,7 +31,7 @@ export async function execute(interaction) { ); if (rows.length === 0) { - return await interaction.editReply(`No moderation history found for ${user.tag}.`); + return await safeEditReply(interaction, `No moderation history found for ${user.tag}.`); } const lines = rows.map((row) => { @@ -68,9 +69,9 @@ export async function execute(interaction) { caseCount: rows.length, }); - await interaction.editReply({ embeds: [embed] }); + await safeEditReply(interaction, { embeds: [embed] }); } catch (err) { logError('Command error', { error: err.message, command: 'history' }); - await interaction.editReply('❌ Failed to fetch moderation history.'); + await safeEditReply(interaction, '❌ Failed to fetch moderation history.').catch(() => {}); } } diff --git a/src/commands/kick.js b/src/commands/kick.js index 929615138..84f8112e5 100644 --- a/src/commands/kick.js +++ b/src/commands/kick.js @@ -13,6 +13,7 @@ import { sendModLogEmbed, shouldSendDm, } from '../modules/moderation.js'; +import { safeEditReply } from '../utils/safeSend.js'; export const data = new SlashCommandBuilder() .setName('kick') @@ -35,13 +36,13 @@ export async function execute(interaction) { const config = getConfig(); const target = interaction.options.getMember('user'); if (!target) { - return await interaction.editReply('❌ User is not in this server.'); + return await safeEditReply(interaction, '❌ User is not in this server.'); } const reason = interaction.options.getString('reason'); const hierarchyError = checkHierarchy(interaction.member, target, interaction.guild.members.me); if (hierarchyError) { - return await interaction.editReply(hierarchyError); + return await safeEditReply(interaction, hierarchyError); } if (shouldSendDm(config, 'kick')) { @@ -62,13 +63,15 @@ export async function execute(interaction) { await sendModLogEmbed(interaction.client, config, caseData); info('User kicked', { target: target.user.tag, moderator: interaction.user.tag }); - await interaction.editReply( + await safeEditReply( + interaction, `✅ **${target.user.tag}** has been kicked. (Case #${caseData.case_number})`, ); } catch (err) { logError('Command error', { error: err.message, command: 'kick' }); - await interaction - .editReply('❌ An error occurred. Please try again or contact an administrator.') - .catch(() => {}); + await safeEditReply( + interaction, + '❌ An error occurred. Please try again or contact an administrator.', + ).catch(() => {}); } } diff --git a/src/commands/lock.js b/src/commands/lock.js index 40cf97675..026352b88 100644 --- a/src/commands/lock.js +++ b/src/commands/lock.js @@ -7,6 +7,7 @@ import { ChannelType, EmbedBuilder, SlashCommandBuilder } from 'discord.js'; import { info, error as logError } from '../logger.js'; import { getConfig } from '../modules/config.js'; import { createCase, sendModLogEmbed } from '../modules/moderation.js'; +import { safeEditReply, safeSend } from '../utils/safeSend.js'; export const data = new SlashCommandBuilder() .setName('lock') @@ -36,7 +37,7 @@ export async function execute(interaction) { const reason = interaction.options.getString('reason'); if (channel.type !== ChannelType.GuildText) { - return await interaction.editReply('❌ Lock can only be used in text channels.'); + return await safeEditReply(interaction, '❌ Lock can only be used in text channels.'); } await channel.permissionOverwrites.edit(interaction.guild.roles.everyone, { @@ -49,7 +50,7 @@ export async function execute(interaction) { `🔒 This channel has been locked by ${interaction.user}${reason ? `\n**Reason:** ${reason}` : ''}`, ) .setTimestamp(); - await channel.send({ embeds: [notifyEmbed] }); + await safeSend(channel, { embeds: [notifyEmbed] }); const config = getConfig(); const caseData = await createCase(interaction.guild.id, { @@ -63,11 +64,12 @@ export async function execute(interaction) { await sendModLogEmbed(interaction.client, config, caseData); info('Channel locked', { channelId: channel.id, moderator: interaction.user.tag }); - await interaction.editReply(`✅ ${channel} has been locked.`); + await safeEditReply(interaction, `✅ ${channel} has been locked.`); } catch (err) { logError('Lock command failed', { error: err.message, command: 'lock' }); - await interaction - .editReply('❌ An error occurred. Please try again or contact an administrator.') - .catch(() => {}); + await safeEditReply( + interaction, + '❌ An error occurred. Please try again or contact an administrator.', + ).catch(() => {}); } } diff --git a/src/commands/memory.js b/src/commands/memory.js index 87f0f8326..58d2c9a4a 100644 --- a/src/commands/memory.js +++ b/src/commands/memory.js @@ -32,6 +32,7 @@ import { searchMemories, } from '../modules/memory.js'; import { isOptedOut, toggleOptOut } from '../modules/optout.js'; +import { safeEditReply, safeReply, safeUpdate } from '../utils/safeSend.js'; import { splitMessage } from '../utils/splitMessage.js'; /** @@ -114,7 +115,7 @@ export async function execute(interaction) { } if (!checkAndRecoverMemory()) { - await interaction.reply({ + await safeReply(interaction, { content: '🧠 Memory system is currently unavailable. The bot still works, just without long-term memory.', ephemeral: true, @@ -143,13 +144,13 @@ async function handleOptOut(interaction, userId) { const { optedOut } = await toggleOptOut(userId); if (optedOut) { - await interaction.reply({ + await safeReply(interaction, { content: '🚫 You have **opted out** of memory collection. The bot will no longer remember things about you. Your existing memories are unchanged — use `/memory forget` to delete them.', ephemeral: true, }); } else { - await interaction.reply({ + await safeReply(interaction, { content: '✅ You have **opted back in** to memory collection. The bot will start remembering things about you again.', ephemeral: true, @@ -171,7 +172,7 @@ async function handleView(interaction, userId, username) { const memories = await getMemories(userId); if (memories.length === 0) { - await interaction.editReply({ + await safeEditReply(interaction, { content: "🧠 I don't have any memories about you yet. Chat with me and I'll start remembering!", }); @@ -182,7 +183,7 @@ async function handleView(interaction, userId, username) { const header = `🧠 **What I remember about ${username}:**\n\n`; const content = formatMemoryList(memoryList, header); - await interaction.editReply({ content }); + await safeEditReply(interaction, { content }); info('Memory view command', { userId, username, count: memories.length }); } @@ -206,7 +207,7 @@ async function handleForgetAll(interaction, userId, username) { const row = new ActionRowBuilder().addComponents(confirmButton, cancelButton); - const response = await interaction.reply({ + const response = await safeReply(interaction, { content: '⚠️ **Are you sure?** This will delete **ALL** your memories permanently. This cannot be undone.', components: [row], @@ -224,27 +225,27 @@ async function handleForgetAll(interaction, userId, username) { const success = await deleteAllMemories(userId); if (success) { - await buttonInteraction.update({ + await safeUpdate(buttonInteraction, { content: '🧹 Done! All your memories have been cleared. Fresh start!', components: [], }); info('All memories cleared', { userId, username }); } else { - await buttonInteraction.update({ + await safeUpdate(buttonInteraction, { content: '❌ Failed to clear memories. Please try again later.', components: [], }); warn('Failed to clear memories', { userId, username }); } } else { - await buttonInteraction.update({ + await safeUpdate(buttonInteraction, { content: '↩️ Memory deletion cancelled.', components: [], }); } } catch { // Timeout — no interaction received within 30 seconds - await interaction.editReply({ + await safeEditReply(interaction, { content: '⏰ Confirmation timed out. No memories were deleted.', components: [], }); @@ -292,16 +293,16 @@ async function handleForgetTopic(interaction, userId, username, topic) { } if (totalDeleted > 0) { - await interaction.editReply({ + await safeEditReply(interaction, { content: `🧹 Forgot ${totalDeleted} memor${totalDeleted === 1 ? 'y' : 'ies'} related to "${topic}".`, }); info('Topic memories cleared', { userId, username, topic, count: totalDeleted }); } else if (totalFound === 0) { - await interaction.editReply({ + await safeEditReply(interaction, { content: `🔍 No memories found matching "${topic}".`, }); } else { - await interaction.editReply({ + await safeEditReply(interaction, { content: `❌ Found memories about "${topic}" but couldn't delete them. Please try again.`, }); } @@ -320,7 +321,7 @@ async function handleAdmin(interaction, subcommand) { interaction.memberPermissions.has(PermissionFlagsBits.Administrator)); if (!hasPermission) { - await interaction.reply({ + await safeReply(interaction, { content: '❌ You need **Manage Server** or **Administrator** permission to use admin commands.', ephemeral: true, @@ -329,7 +330,7 @@ async function handleAdmin(interaction, subcommand) { } if (!checkAndRecoverMemory()) { - await interaction.reply({ + await safeReply(interaction, { content: '🧠 Memory system is currently unavailable. The bot still works, just without long-term memory.', ephemeral: true, @@ -361,7 +362,7 @@ async function handleAdminView(interaction, targetId, targetUsername) { const optedOutStatus = isOptedOut(targetId) ? ' *(opted out)*' : ''; if (memories.length === 0) { - await interaction.editReply({ + await safeEditReply(interaction, { content: `🧠 No memories found for **${targetUsername}**${optedOutStatus}.`, }); return; @@ -371,7 +372,7 @@ async function handleAdminView(interaction, targetId, targetUsername) { const header = `🧠 **Memories for ${targetUsername}${optedOutStatus}:**\n\n`; const content = formatMemoryList(memoryList, header); - await interaction.editReply({ content }); + await safeEditReply(interaction, { content }); info('Admin memory view', { adminId: interaction.user.id, @@ -402,7 +403,7 @@ async function handleAdminClear(interaction, targetId, targetUsername) { const row = new ActionRowBuilder().addComponents(confirmButton, cancelButton); - const response = await interaction.reply({ + const response = await safeReply(interaction, { content: `⚠️ **Are you sure?** This will delete **ALL** memories for **${targetUsername}** permanently. This cannot be undone.`, components: [row], ephemeral: true, @@ -419,27 +420,27 @@ async function handleAdminClear(interaction, targetId, targetUsername) { const success = await deleteAllMemories(targetId); if (success) { - await buttonInteraction.update({ + await safeUpdate(buttonInteraction, { content: `🧹 Done! All memories for **${targetUsername}** have been cleared.`, components: [], }); info('Admin cleared all memories', { adminId, targetId, targetUsername }); } else { - await buttonInteraction.update({ + await safeUpdate(buttonInteraction, { content: `❌ Failed to clear memories for **${targetUsername}**. Please try again later.`, components: [], }); warn('Admin failed to clear memories', { adminId, targetId, targetUsername }); } } else { - await buttonInteraction.update({ + await safeUpdate(buttonInteraction, { content: '↩️ Memory deletion cancelled.', components: [], }); } } catch { // Timeout — no interaction received within 30 seconds - await interaction.editReply({ + await safeEditReply(interaction, { content: '⏰ Confirmation timed out. No memories were deleted.', components: [], }); diff --git a/src/commands/modlog.js b/src/commands/modlog.js index c0538c751..c46ba0d73 100644 --- a/src/commands/modlog.js +++ b/src/commands/modlog.js @@ -15,6 +15,7 @@ import { } from 'discord.js'; import { info, error as logError } from '../logger.js'; import { getConfig, setConfigValue } from '../modules/config.js'; +import { safeEditReply, safeReply } from '../utils/safeSend.js'; export const data = new SlashCommandBuilder() .setName('modlog') @@ -44,9 +45,9 @@ export async function execute(interaction) { break; default: logError('Unknown modlog subcommand', { subcommand, command: 'modlog' }); - await interaction - .reply({ content: '❌ Unknown subcommand.', ephemeral: true }) - .catch(() => {}); + await safeReply(interaction, { content: '❌ Unknown subcommand.', ephemeral: true }).catch( + () => {}, + ); } } @@ -83,7 +84,7 @@ async function handleSetup(interaction) { .setDescription('Select an event category to configure its log channel.') .setTimestamp(); - const reply = await interaction.reply({ + const reply = await safeReply(interaction, { embeds: [embed], components: [row, doneRow], ephemeral: true, @@ -142,20 +143,19 @@ async function handleSetup(interaction) { customId: i.customId, command: 'modlog', }); - await i - .reply({ - content: '❌ Failed to update modlog configuration. Please try again.', - ephemeral: true, - }) - .catch(() => {}); + await safeReply(i, { + content: '❌ Failed to update modlog configuration. Please try again.', + ephemeral: true, + }).catch(() => {}); } }); collector.on('end', async (_, reason) => { if (reason === 'time') { - await interaction - .editReply({ components: [], embeds: [embed.setDescription('⏰ Setup timed out.')] }) - .catch(() => {}); + await safeEditReply(interaction, { + components: [], + embeds: [embed.setDescription('⏰ Setup timed out.')], + }).catch(() => {}); } }); } @@ -179,12 +179,13 @@ async function handleView(interaction) { ); embed.setDescription(lines.join('\n') || 'No channels configured.'); - await interaction.reply({ embeds: [embed], ephemeral: true }); + await safeReply(interaction, { embeds: [embed], ephemeral: true }); } catch (err) { logError('Modlog view failed', { error: err.message, command: 'modlog' }); - await interaction - .reply({ content: '❌ Failed to load mod log configuration.', ephemeral: true }) - .catch(() => {}); + await safeReply(interaction, { + content: '❌ Failed to load mod log configuration.', + ephemeral: true, + }).catch(() => {}); } } @@ -202,11 +203,12 @@ async function handleDisable(interaction) { } info('Mod logging disabled', { moderator: interaction.user.tag }); - await interaction.editReply( + await safeEditReply( + interaction, '✅ Mod logging has been disabled. All log channels have been cleared.', ); } catch (err) { logError('Modlog disable failed', { error: err.message, command: 'modlog' }); - await interaction.editReply('❌ Failed to disable mod logging.').catch(() => {}); + await safeEditReply(interaction, '❌ Failed to disable mod logging.').catch(() => {}); } } diff --git a/src/commands/ping.js b/src/commands/ping.js index f982fef31..128871437 100644 --- a/src/commands/ping.js +++ b/src/commands/ping.js @@ -1,11 +1,12 @@ import { SlashCommandBuilder } from 'discord.js'; +import { safeEditReply, safeReply } from '../utils/safeSend.js'; export const data = new SlashCommandBuilder() .setName('ping') .setDescription('Check bot latency and responsiveness'); export async function execute(interaction) { - const response = await interaction.reply({ + const response = await safeReply(interaction, { content: 'Pinging...', withResponse: true, }); @@ -14,5 +15,5 @@ export async function execute(interaction) { const latency = sent.createdTimestamp - interaction.createdTimestamp; const apiLatency = Math.round(interaction.client.ws.ping); - await interaction.editReply(`🏓 Pong!\n📡 Latency: ${latency}ms\n💓 API: ${apiLatency}ms`); + await safeEditReply(interaction, `🏓 Pong!\n📡 Latency: ${latency}ms\n💓 API: ${apiLatency}ms`); } diff --git a/src/commands/purge.js b/src/commands/purge.js index 819bae1ef..745459d98 100644 --- a/src/commands/purge.js +++ b/src/commands/purge.js @@ -7,6 +7,7 @@ import { SlashCommandBuilder } from 'discord.js'; import { info, error as logError } from '../logger.js'; import { getConfig } from '../modules/config.js'; import { createCase, sendModLogEmbed } from '../modules/moderation.js'; +import { safeEditReply } from '../utils/safeSend.js'; export const data = new SlashCommandBuilder() .setName('purge') @@ -163,13 +164,15 @@ export async function execute(interaction) { await sendModLogEmbed(interaction.client, config, caseData); - await interaction.editReply( + await safeEditReply( + interaction, `Deleted **${deleted.size}** message(s) from **${fetched.size}** scanned message(s).`, ); } catch (err) { logError('Purge command failed', { error: err.message, command: 'purge' }); - await interaction - .editReply('❌ An error occurred. Please try again or contact an administrator.') - .catch(() => {}); + await safeEditReply( + interaction, + '❌ An error occurred. Please try again or contact an administrator.', + ).catch(() => {}); } } diff --git a/src/commands/slowmode.js b/src/commands/slowmode.js index e810cb9dd..1d16e4dc0 100644 --- a/src/commands/slowmode.js +++ b/src/commands/slowmode.js @@ -8,6 +8,7 @@ import { info, error as logError } from '../logger.js'; import { getConfig } from '../modules/config.js'; import { createCase, sendModLogEmbed } from '../modules/moderation.js'; import { formatDuration, parseDuration } from '../utils/duration.js'; +import { safeEditReply } from '../utils/safeSend.js'; export const data = new SlashCommandBuilder() .setName('slowmode') @@ -48,13 +49,14 @@ export async function execute(interaction) { if (durationStr !== '0') { const ms = parseDuration(durationStr); if (!ms) { - return await interaction.editReply( + return await safeEditReply( + interaction, '❌ Invalid duration format. Use formats like: 5s, 1m, 1h', ); } if (ms > 6 * 60 * 60 * 1000) { - return await interaction.editReply('❌ Duration cannot exceed 6 hours.'); + return await safeEditReply(interaction, '❌ Duration cannot exceed 6 hours.'); } seconds = Math.floor(ms / 1000); @@ -79,19 +81,22 @@ export async function execute(interaction) { if (seconds === 0) { info('Slowmode disabled', { channelId: channel.id, moderator: interaction.user.tag }); - await interaction.editReply( + await safeEditReply( + interaction, `✅ Slowmode disabled in ${channel}. (Case #${caseData.case_number})`, ); } else { info('Slowmode set', { channelId: channel.id, seconds, moderator: interaction.user.tag }); - await interaction.editReply( + await safeEditReply( + interaction, `✅ Slowmode set to **${formatDuration(seconds * 1000)}** in ${channel}. (Case #${caseData.case_number})`, ); } } catch (err) { logError('Slowmode command failed', { error: err.message, command: 'slowmode' }); - await interaction - .editReply('❌ An error occurred. Please try again or contact an administrator.') - .catch(() => {}); + await safeEditReply( + interaction, + '❌ An error occurred. Please try again or contact an administrator.', + ).catch(() => {}); } } diff --git a/src/commands/softban.js b/src/commands/softban.js index 342e6be18..b1e843a4e 100644 --- a/src/commands/softban.js +++ b/src/commands/softban.js @@ -13,6 +13,7 @@ import { sendModLogEmbed, shouldSendDm, } from '../modules/moderation.js'; +import { safeEditReply } from '../utils/safeSend.js'; export const data = new SlashCommandBuilder() .setName('softban') @@ -43,14 +44,14 @@ export async function execute(interaction) { const config = getConfig(); const target = interaction.options.getMember('user'); if (!target) { - return await interaction.editReply('❌ User is not in this server.'); + return await safeEditReply(interaction, '❌ User is not in this server.'); } const reason = interaction.options.getString('reason'); const deleteMessageDays = interaction.options.getInteger('delete_messages') ?? 7; const hierarchyError = checkHierarchy(interaction.member, target, interaction.guild.members.me); if (hierarchyError) { - return await interaction.editReply(hierarchyError); + return await safeEditReply(interaction, hierarchyError); } if (shouldSendDm(config, 'softban')) { @@ -96,18 +97,21 @@ export async function execute(interaction) { info('User softbanned', { target: target.user.tag, moderator: interaction.user.tag }); if (unbanError) { - await interaction.editReply( + await safeEditReply( + interaction, `⚠️ **${target.user.tag}** was banned but the unban failed — they remain banned. Please manually unban. (Case #${caseData.case_number})`, ); } else { - await interaction.editReply( + await safeEditReply( + interaction, `✅ **${target.user.tag}** has been soft-banned. (Case #${caseData.case_number})`, ); } } catch (err) { logError('Command error', { error: err.message, command: 'softban' }); - await interaction - .editReply('❌ An error occurred. Please try again or contact an administrator.') - .catch(() => {}); + await safeEditReply( + interaction, + '❌ An error occurred. Please try again or contact an administrator.', + ).catch(() => {}); } } diff --git a/src/commands/status.js b/src/commands/status.js index 2f54c2294..08449e5ea 100644 --- a/src/commands/status.js +++ b/src/commands/status.js @@ -8,6 +8,7 @@ import { EmbedBuilder, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; import { error as logError } from '../logger.js'; import { HealthMonitor } from '../utils/health.js'; +import { safeFollowUp, safeReply } from '../utils/safeSend.js'; export const data = new SlashCommandBuilder() .setName('status') @@ -66,7 +67,7 @@ export async function execute(interaction) { if (detailed) { // Check if user has admin permissions if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) { - await interaction.reply({ + await safeReply(interaction, { content: '❌ Detailed diagnostics are only available to administrators.', ephemeral: true, }); @@ -108,7 +109,7 @@ export async function execute(interaction) { .setTimestamp() .setFooter({ text: 'Detailed diagnostics mode' }); - await interaction.reply({ embeds: [embed], ephemeral: true }); + await safeReply(interaction, { embeds: [embed], ephemeral: true }); } else { // Basic mode - user-friendly status const status = healthMonitor.getStatus(); @@ -134,7 +135,7 @@ export async function execute(interaction) { .setTimestamp() .setFooter({ text: 'Use /status detailed:true for more info' }); - await interaction.reply({ embeds: [embed] }); + await safeReply(interaction, { embeds: [embed] }); } } catch (err) { logError('Status command error', { error: err.message }); @@ -145,9 +146,9 @@ export async function execute(interaction) { }; if (interaction.replied || interaction.deferred) { - await interaction.followUp(reply).catch(() => {}); + await safeFollowUp(interaction, reply).catch(() => {}); } else { - await interaction.reply(reply).catch(() => {}); + await safeReply(interaction, reply).catch(() => {}); } } } diff --git a/src/commands/tempban.js b/src/commands/tempban.js index 522ff8adf..07b6f79cb 100644 --- a/src/commands/tempban.js +++ b/src/commands/tempban.js @@ -15,6 +15,7 @@ import { shouldSendDm, } from '../modules/moderation.js'; import { formatDuration, parseDuration } from '../utils/duration.js'; +import { safeEditReply } from '../utils/safeSend.js'; export const data = new SlashCommandBuilder() .setName('tempban') @@ -53,7 +54,7 @@ export async function execute(interaction) { const durationMs = parseDuration(durationStr); if (!durationMs) { - return await interaction.editReply('❌ Invalid duration format. Use e.g. 1d, 7d, 2w.'); + return await safeEditReply(interaction, '❌ Invalid duration format. Use e.g. 1d, 7d, 2w.'); } let member = null; @@ -70,7 +71,7 @@ export async function execute(interaction) { interaction.guild.members.me, ); if (hierarchyError) { - return await interaction.editReply(hierarchyError); + return await safeEditReply(interaction, hierarchyError); } if (shouldSendDm(config, 'ban')) { @@ -105,13 +106,15 @@ export async function execute(interaction) { moderator: interaction.user.tag, duration: durationStr, }); - await interaction.editReply( + await safeEditReply( + interaction, `✅ **${user.tag}** has been temporarily banned. (Case #${caseData.case_number})`, ); } catch (err) { logError('Command error', { error: err.message, command: 'tempban' }); - await interaction - .editReply('❌ An error occurred. Please try again or contact an administrator.') - .catch(() => {}); + await safeEditReply( + interaction, + '❌ An error occurred. Please try again or contact an administrator.', + ).catch(() => {}); } } diff --git a/src/commands/timeout.js b/src/commands/timeout.js index 68f53ca47..c2b221f75 100644 --- a/src/commands/timeout.js +++ b/src/commands/timeout.js @@ -14,6 +14,7 @@ import { shouldSendDm, } from '../modules/moderation.js'; import { formatDuration, parseDuration } from '../utils/duration.js'; +import { safeEditReply } from '../utils/safeSend.js'; export const data = new SlashCommandBuilder() .setName('timeout') @@ -39,24 +40,24 @@ export async function execute(interaction) { const config = getConfig(); const target = interaction.options.getMember('user'); if (!target) { - return await interaction.editReply('❌ User is not in this server.'); + return await safeEditReply(interaction, '❌ User is not in this server.'); } const durationStr = interaction.options.getString('duration'); const reason = interaction.options.getString('reason'); const durationMs = parseDuration(durationStr); if (!durationMs) { - return await interaction.editReply('❌ Invalid duration format. Use e.g. 30m, 1h, 7d.'); + return await safeEditReply(interaction, '❌ Invalid duration format. Use e.g. 30m, 1h, 7d.'); } const MAX_TIMEOUT_MS = 28 * 24 * 60 * 60 * 1000; if (durationMs > MAX_TIMEOUT_MS) { - return await interaction.editReply('❌ Timeout duration cannot exceed 28 days.'); + return await safeEditReply(interaction, '❌ Timeout duration cannot exceed 28 days.'); } const hierarchyError = checkHierarchy(interaction.member, target, interaction.guild.members.me); if (hierarchyError) { - return await interaction.editReply(hierarchyError); + return await safeEditReply(interaction, hierarchyError); } if (shouldSendDm(config, 'timeout')) { @@ -83,13 +84,15 @@ export async function execute(interaction) { moderator: interaction.user.tag, duration: durationStr, }); - await interaction.editReply( + await safeEditReply( + interaction, `✅ **${target.user.tag}** has been timed out. (Case #${caseData.case_number})`, ); } catch (err) { logError('Command error', { error: err.message, command: 'timeout' }); - await interaction - .editReply('❌ An error occurred. Please try again or contact an administrator.') - .catch(() => {}); + await safeEditReply( + interaction, + '❌ An error occurred. Please try again or contact an administrator.', + ).catch(() => {}); } } diff --git a/src/commands/unban.js b/src/commands/unban.js index 70f23cfa5..033557a88 100644 --- a/src/commands/unban.js +++ b/src/commands/unban.js @@ -7,6 +7,7 @@ import { SlashCommandBuilder } from 'discord.js'; import { info, error as logError } from '../logger.js'; import { getConfig } from '../modules/config.js'; import { createCase, sendModLogEmbed } from '../modules/moderation.js'; +import { safeEditReply } from '../utils/safeSend.js'; export const data = new SlashCommandBuilder() .setName('unban') @@ -53,13 +54,15 @@ export async function execute(interaction) { await sendModLogEmbed(interaction.client, config, caseData); info('User unbanned', { target: userId, moderator: interaction.user.tag }); - await interaction.editReply( + await safeEditReply( + interaction, `✅ **${userId}** has been unbanned. (Case #${caseData.case_number})`, ); } catch (err) { logError('Command error', { error: err.message, command: 'unban' }); - await interaction - .editReply('❌ An error occurred. Please try again or contact an administrator.') - .catch(() => {}); + await safeEditReply( + interaction, + '❌ An error occurred. Please try again or contact an administrator.', + ).catch(() => {}); } } diff --git a/src/commands/unlock.js b/src/commands/unlock.js index cab7403f9..5214c772f 100644 --- a/src/commands/unlock.js +++ b/src/commands/unlock.js @@ -7,6 +7,7 @@ import { ChannelType, EmbedBuilder, SlashCommandBuilder } from 'discord.js'; import { info, error as logError } from '../logger.js'; import { getConfig } from '../modules/config.js'; import { createCase, sendModLogEmbed } from '../modules/moderation.js'; +import { safeEditReply, safeSend } from '../utils/safeSend.js'; export const data = new SlashCommandBuilder() .setName('unlock') @@ -36,7 +37,7 @@ export async function execute(interaction) { const reason = interaction.options.getString('reason'); if (channel.type !== ChannelType.GuildText) { - return await interaction.editReply('❌ Unlock can only be used in text channels.'); + return await safeEditReply(interaction, '❌ Unlock can only be used in text channels.'); } await channel.permissionOverwrites.edit(interaction.guild.roles.everyone, { @@ -49,7 +50,7 @@ export async function execute(interaction) { `🔓 This channel has been unlocked by ${interaction.user}${reason ? `\n**Reason:** ${reason}` : ''}`, ) .setTimestamp(); - await channel.send({ embeds: [notifyEmbed] }); + await safeSend(channel, { embeds: [notifyEmbed] }); const config = getConfig(); const caseData = await createCase(interaction.guild.id, { @@ -63,11 +64,12 @@ export async function execute(interaction) { await sendModLogEmbed(interaction.client, config, caseData); info('Channel unlocked', { channelId: channel.id, moderator: interaction.user.tag }); - await interaction.editReply(`✅ ${channel} has been unlocked.`); + await safeEditReply(interaction, `✅ ${channel} has been unlocked.`); } catch (err) { logError('Unlock command failed', { error: err.message, command: 'unlock' }); - await interaction - .editReply('❌ An error occurred. Please try again or contact an administrator.') - .catch(() => {}); + await safeEditReply( + interaction, + '❌ An error occurred. Please try again or contact an administrator.', + ).catch(() => {}); } } diff --git a/src/commands/untimeout.js b/src/commands/untimeout.js index 51310e52f..e74812ade 100644 --- a/src/commands/untimeout.js +++ b/src/commands/untimeout.js @@ -7,6 +7,7 @@ import { SlashCommandBuilder } from 'discord.js'; import { info, error as logError } from '../logger.js'; import { getConfig } from '../modules/config.js'; import { checkHierarchy, createCase, sendModLogEmbed } from '../modules/moderation.js'; +import { safeEditReply } from '../utils/safeSend.js'; export const data = new SlashCommandBuilder() .setName('untimeout') @@ -29,13 +30,13 @@ export async function execute(interaction) { const config = getConfig(); const target = interaction.options.getMember('user'); if (!target) { - return await interaction.editReply('❌ User is not in this server.'); + return await safeEditReply(interaction, '❌ User is not in this server.'); } const reason = interaction.options.getString('reason'); const hierarchyError = checkHierarchy(interaction.member, target, interaction.guild.members.me); if (hierarchyError) { - return await interaction.editReply(hierarchyError); + return await safeEditReply(interaction, hierarchyError); } await target.timeout(null, reason || undefined); @@ -52,13 +53,15 @@ export async function execute(interaction) { await sendModLogEmbed(interaction.client, config, caseData); info('User timeout removed', { target: target.user.tag, moderator: interaction.user.tag }); - await interaction.editReply( + await safeEditReply( + interaction, `✅ **${target.user.tag}** has had their timeout removed. (Case #${caseData.case_number})`, ); } catch (err) { logError('Command error', { error: err.message, command: 'untimeout' }); - await interaction - .editReply('❌ An error occurred. Please try again or contact an administrator.') - .catch(() => {}); + await safeEditReply( + interaction, + '❌ An error occurred. Please try again or contact an administrator.', + ).catch(() => {}); } } diff --git a/src/commands/warn.js b/src/commands/warn.js index 2ce381bca..25314e411 100644 --- a/src/commands/warn.js +++ b/src/commands/warn.js @@ -14,6 +14,7 @@ import { sendModLogEmbed, shouldSendDm, } from '../modules/moderation.js'; +import { safeEditReply } from '../utils/safeSend.js'; export const data = new SlashCommandBuilder() .setName('warn') @@ -36,13 +37,13 @@ export async function execute(interaction) { const config = getConfig(); const target = interaction.options.getMember('user'); if (!target) { - return await interaction.editReply('❌ User is not in this server.'); + return await safeEditReply(interaction, '❌ User is not in this server.'); } const reason = interaction.options.getString('reason'); const hierarchyError = checkHierarchy(interaction.member, target, interaction.guild.members.me); if (hierarchyError) { - return await interaction.editReply(hierarchyError); + return await safeEditReply(interaction, hierarchyError); } if (shouldSendDm(config, 'warn')) { @@ -70,13 +71,15 @@ export async function execute(interaction) { ); info('User warned', { target: target.user.tag, moderator: interaction.user.tag }); - await interaction.editReply( + await safeEditReply( + interaction, `✅ **${target.user.tag}** has been warned. (Case #${caseData.case_number})`, ); } catch (err) { logError('Command error', { error: err.message, command: 'warn' }); - await interaction - .editReply('❌ An error occurred. Please try again or contact an administrator.') - .catch(() => {}); + await safeEditReply( + interaction, + '❌ An error occurred. Please try again or contact an administrator.', + ).catch(() => {}); } } diff --git a/src/index.js b/src/index.js index 8e77036b4..30fb06e8b 100644 --- a/src/index.js +++ b/src/index.js @@ -35,6 +35,7 @@ import { HealthMonitor } from './utils/health.js'; import { loadCommandsFromDirectory } from './utils/loadCommands.js'; import { getPermissionError, hasPermission } from './utils/permissions.js'; import { registerCommands } from './utils/registerCommands.js'; +import { safeFollowUp, safeReply } from './utils/safeSend.js'; // ES module dirname equivalent const __filename = fileURLToPath(import.meta.url); @@ -53,7 +54,19 @@ dotenvConfig(); // setConfigValue() propagate here automatically without re-assignment. let config = {}; -// Initialize Discord client with required intents +// Initialize Discord client with required intents. +// +// INTENTIONAL DESIGN: allowedMentions restricts which mention types Discord +// will parse. Only 'users' is allowed — @everyone, @here, and role mentions +// are ALL blocked globally at the Client level. This is a defense-in-depth +// measure to prevent the bot from ever mass-pinging, even if AI-generated +// or user-supplied content contains @everyone/@here or <@&roleId>. +// +// To opt-in to role mentions in the future, add 'roles' to the parse array +// below (e.g. { parse: ['users', 'roles'] }). You would also need to update +// SAFE_ALLOWED_MENTIONS in src/utils/safeSend.js to match. +// +// See: https://github.com/BillChirico/bills-bot/issues/61 const client = new Client({ intents: [ GatewayIntentBits.Guilds, @@ -62,6 +75,7 @@ const client = new Client({ GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildVoiceStates, ], + allowedMentions: { parse: ['users'] }, }); // Initialize command collection @@ -163,7 +177,7 @@ client.on('interactionCreate', async (interaction) => { // Permission check if (!hasPermission(member, commandName, config)) { - await interaction.reply({ + await safeReply(interaction, { content: getPermissionError(commandName), ephemeral: true, }); @@ -174,7 +188,7 @@ client.on('interactionCreate', async (interaction) => { // Execute command from collection const command = client.commands.get(commandName); if (!command) { - await interaction.reply({ + await safeReply(interaction, { content: '❌ Command not found.', ephemeral: true, }); @@ -192,9 +206,9 @@ client.on('interactionCreate', async (interaction) => { }; if (interaction.replied || interaction.deferred) { - await interaction.followUp(errorMessage).catch(() => {}); + await safeFollowUp(interaction, errorMessage).catch(() => {}); } else { - await interaction.reply(errorMessage).catch(() => {}); + await safeReply(interaction, errorMessage).catch(() => {}); } } }); diff --git a/src/modules/chimeIn.js b/src/modules/chimeIn.js index f02c3a151..ddd6f3242 100644 --- a/src/modules/chimeIn.js +++ b/src/modules/chimeIn.js @@ -10,6 +10,7 @@ */ import { info, error as logError, warn } from '../logger.js'; +import { safeSend } from '../utils/safeSend.js'; import { needsSplitting, splitMessage } from '../utils/splitMessage.js'; import { OPENCLAW_TOKEN, OPENCLAW_URL } from './ai.js'; @@ -262,10 +263,10 @@ export async function accumulate(message, config) { if (needsSplitting(response)) { const chunks = splitMessage(response); for (const chunk of chunks) { - await message.channel.send(chunk); + await safeSend(message.channel, chunk); } } else { - await message.channel.send(response); + await safeSend(message.channel, response); } } diff --git a/src/modules/events.js b/src/modules/events.js index ca53ff3d5..7ca6cffdb 100644 --- a/src/modules/events.js +++ b/src/modules/events.js @@ -6,6 +6,10 @@ import { Client, Events } from 'discord.js'; import { info, error as logError, warn } from '../logger.js'; import { getUserFriendlyMessage } from '../utils/errors.js'; +// safeReply works with both Interactions (.reply()) and Messages (.reply()). +// Both accept the same options shape including allowedMentions, so the +// safe wrapper applies identically to either target type. +import { safeReply, safeSend } from '../utils/safeSend.js'; import { needsSplitting, splitMessage } from '../utils/splitMessage.js'; import { generateResponse } from './ai.js'; import { accumulate, resetCounter } from './chimeIn.js'; @@ -102,7 +106,7 @@ export function registerMessageCreateHandler(client, config, healthMonitor) { try { if (!cleanContent) { - await message.reply("Hey! What's up?"); + await safeReply(message, "Hey! What's up?"); return; } @@ -136,14 +140,14 @@ export function registerMessageCreateHandler(client, config, healthMonitor) { if (needsSplitting(response)) { const chunks = splitMessage(response); for (const chunk of chunks) { - await targetChannel.send(chunk); + await safeSend(targetChannel, chunk); } } else if (targetChannel === message.channel) { // Inline reply — use message.reply for the reference - await message.reply(response); + await safeReply(message, response); } else { // Thread reply — send directly to the thread - await targetChannel.send(response); + await safeSend(targetChannel, response); } } catch (sendErr) { logError('Failed to send AI response', { @@ -152,7 +156,7 @@ export function registerMessageCreateHandler(client, config, healthMonitor) { }); // Best-effort fallback — if the channel is still reachable, let the user know try { - await message.reply(getUserFriendlyMessage(sendErr)); + await safeReply(message, getUserFriendlyMessage(sendErr)); } catch { // Channel is unreachable — nothing more we can do } diff --git a/src/modules/moderation.js b/src/modules/moderation.js index eac1841c9..c6afb36b6 100644 --- a/src/modules/moderation.js +++ b/src/modules/moderation.js @@ -8,6 +8,7 @@ import { EmbedBuilder } from 'discord.js'; import { getPool } from '../db.js'; import { info, error as logError } from '../logger.js'; import { parseDuration } from '../utils/duration.js'; +import { safeSend } from '../utils/safeSend.js'; import { getConfig } from './config.js'; /** @@ -193,7 +194,7 @@ export async function sendDmNotification(member, action, reason, guildName) { .setTimestamp(); try { - await member.send({ embeds: [embed] }); + await safeSend(member, { embeds: [embed] }); } catch { // User has DMs disabled — silently continue } @@ -237,7 +238,7 @@ export async function sendModLogEmbed(client, config, caseData) { } try { - const sentMessage = await channel.send({ embeds: [embed] }); + const sentMessage = await safeSend(channel, { embeds: [embed] }); // Store log message ID for future editing try { diff --git a/src/modules/spam.js b/src/modules/spam.js index 2231aff77..87c0edb6b 100644 --- a/src/modules/spam.js +++ b/src/modules/spam.js @@ -4,6 +4,7 @@ */ import { EmbedBuilder } from 'discord.js'; +import { safeSend } from '../utils/safeSend.js'; // Spam patterns const SPAM_PATTERNS = [ @@ -52,7 +53,7 @@ export async function sendSpamAlert(message, client, config) { ) .setTimestamp(); - await alertChannel.send({ embeds: [embed] }); + await safeSend(alertChannel, { embeds: [embed] }); // Auto-delete if enabled if (config.moderation?.autoDelete) { diff --git a/src/modules/welcome.js b/src/modules/welcome.js index a6fe08d6a..58b079943 100644 --- a/src/modules/welcome.js +++ b/src/modules/welcome.js @@ -4,6 +4,7 @@ */ import { info, error as logError } from '../logger.js'; +import { safeSend } from '../utils/safeSend.js'; const guildActivity = new Map(); const DEFAULT_ACTIVITY_WINDOW_MINUTES = 45; @@ -145,7 +146,7 @@ export async function sendWelcomeMessage(member, client, config) { { name: member.guild.name, memberCount: member.guild.memberCount }, ); - await channel.send(message); + await safeSend(channel, message); info('Welcome message sent', { user: member.user.tag, guild: member.guild.name }); } catch (err) { logError('Welcome error', { error: err.message, stack: err.stack }); diff --git a/src/utils/safeSend.js b/src/utils/safeSend.js new file mode 100644 index 000000000..6103f9f83 --- /dev/null +++ b/src/utils/safeSend.js @@ -0,0 +1,208 @@ +/** + * Safe Message Sending Wrappers + * Defense-in-depth wrappers around Discord.js message methods. + * Sanitizes content to strip @everyone/@here and enforces allowedMentions + * on every outgoing message. Long channel messages (>2000 chars) are + * automatically split into multiple sends. Interaction replies/edits are + * truncated instead — Discord only allows a single response per interaction + * method call (reply/editReply/followUp). + * + * @see https://github.com/BillChirico/bills-bot/issues/61 + */ + +import { error as logError, warn as logWarn } from '../logger.js'; +import { sanitizeMessageOptions } from './sanitizeMentions.js'; +import { DISCORD_MAX_LENGTH, needsSplitting, splitMessage } from './splitMessage.js'; + +/** Suffix appended when interaction content is truncated. */ +const TRUNCATION_INDICATOR = '… [truncated]'; + +/** + * Default allowedMentions config that only permits user mentions. + * Applied to every outgoing message as defense-in-depth. + */ +const SAFE_ALLOWED_MENTIONS = { parse: ['users'] }; + +/** + * Normalize message arguments into an options object. + * Discord.js accepts either a string or an options object. + * + * @param {string|object} options - Message content or options object + * @returns {object} Normalized options object + */ +function normalizeOptions(options) { + if (typeof options === 'string') { + return { content: options }; + } + return { ...options }; +} + +/** + * Apply sanitization and safe allowedMentions to message options. + * + * **Security: allowedMentions is intentionally overwritten** — callers cannot + * supply their own allowedMentions. This is by design so that no code path + * can accidentally (or maliciously via user-controlled data) re-enable + * @everyone, @here, or role mentions. The only permitted mention type is + * 'users' (direct user pings). + * + * @param {string|object} options - Message content or options object + * @returns {object} Sanitized options with safe allowedMentions + */ +function prepareOptions(options) { + const normalized = normalizeOptions(options); + const sanitized = sanitizeMessageOptions(normalized); + return { + ...sanitized, + allowedMentions: SAFE_ALLOWED_MENTIONS, + }; +} + +/** + * Truncate content to fit within Discord's character limit. + * Used for interaction responses (reply/editReply/followUp) which only + * support a single message — splitting is not possible. + * + * @param {object} prepared - The sanitized options object + * @returns {object} Options with content truncated to DISCORD_MAX_LENGTH + */ +function truncateForInteraction(prepared) { + const content = prepared.content; + if (typeof content === 'string' && content.length > DISCORD_MAX_LENGTH) { + const truncatedContent = + content.slice(0, DISCORD_MAX_LENGTH - TRUNCATION_INDICATOR.length) + TRUNCATION_INDICATOR; + logWarn('Interaction content truncated', { + originalLength: content.length, + maxLength: DISCORD_MAX_LENGTH, + }); + return { ...prepared, content: truncatedContent }; + } + return prepared; +} + +/** + * Send a single prepared options object, or split into multiple sends + * if the content exceeds Discord's 2000-char limit. + * + * @param {Function} sendFn - The underlying send/reply/followUp/editReply function + * @param {object} prepared - The sanitized options object + * @returns {Promise} + */ +async function sendOrSplit(sendFn, prepared) { + const content = prepared.content; + if (typeof content === 'string' && needsSplitting(content)) { + const chunks = splitMessage(content); + const results = []; + for (let i = 0; i < chunks.length; i++) { + const isLast = i === chunks.length - 1; + const chunkPayload = isLast + ? { ...prepared, content: chunks[i] } + : { content: chunks[i], allowedMentions: prepared.allowedMentions }; + results.push(await sendFn(chunkPayload)); + } + return results; + } + return sendFn(prepared); +} + +/** + * Safely send a message to a channel. + * Sanitizes content, enforces allowedMentions, and splits long messages. + * + * @param {import('discord.js').TextBasedChannel} channel - The channel to send to + * @param {string|object} options - Message content or options object + * @returns {Promise} The sent message(s) + */ +export async function safeSend(channel, options) { + try { + return await sendOrSplit((opts) => channel.send(opts), prepareOptions(options)); + } catch (err) { + logError('safeSend failed', { error: err.message, stack: err.stack }); + throw err; + } +} + +/** + * Safely reply to an interaction or message. + * Sanitizes content, enforces allowedMentions, and truncates long messages. + * Works with both Interaction.reply() and Message.reply() — both accept + * the same options shape including allowedMentions. + * + * Unlike safeSend, this does NOT split — interaction replies only support + * a single response, so content is truncated to 2000 chars instead. + * + * @param {import('discord.js').CommandInteraction|import('discord.js').Message} target - The interaction or message to reply to + * @param {string|object} options - Reply content or options object + * @returns {Promise} The reply + */ +export async function safeReply(target, options) { + try { + return await target.reply(truncateForInteraction(prepareOptions(options))); + } catch (err) { + logError('safeReply failed', { error: err.message, stack: err.stack }); + throw err; + } +} + +/** + * Safely send a follow-up to an interaction. + * Sanitizes content, enforces allowedMentions, and truncates long messages. + * + * Unlike safeSend, this does NOT split — interaction follow-ups are + * truncated to 2000 chars to stay within Discord's limit. + * + * @param {import('discord.js').CommandInteraction} interaction - The interaction to follow up on + * @param {string|object} options - Follow-up content or options object + * @returns {Promise} The follow-up message + */ +export async function safeFollowUp(interaction, options) { + try { + return await interaction.followUp(truncateForInteraction(prepareOptions(options))); + } catch (err) { + logError('safeFollowUp failed', { error: err.message, stack: err.stack }); + throw err; + } +} + +/** + * Safely edit an interaction reply. + * Sanitizes content, enforces allowedMentions, and truncates long messages. + * + * Unlike safeSend, this does NOT split — interaction edits only support + * a single message, so content is truncated to 2000 chars instead. + * + * @param {import('discord.js').CommandInteraction} interaction - The interaction whose reply to edit + * @param {string|object} options - Edit content or options object + * @returns {Promise} The edited message + */ +export async function safeEditReply(interaction, options) { + try { + return await interaction.editReply(truncateForInteraction(prepareOptions(options))); + } catch (err) { + logError('safeEditReply failed', { error: err.message, stack: err.stack }); + throw err; + } +} + +/** + * Safely update a message component interaction (button/select menu). + * Sanitizes content, enforces allowedMentions, and truncates long messages. + * + * Used for ButtonInteraction.update() and similar component interactions + * where the bot edits the original message in response to a button click. + * + * Unlike safeSend, this does NOT split — component updates only support + * a single message, so content is truncated to 2000 chars instead. + * + * @param {import('discord.js').MessageComponentInteraction} interaction - The component interaction to update + * @param {string|object} options - Update content or options object + * @returns {Promise} The updated message + */ +export async function safeUpdate(interaction, options) { + try { + return await interaction.update(truncateForInteraction(prepareOptions(options))); + } catch (err) { + logError('safeUpdate failed', { error: err.message, stack: err.stack }); + throw err; + } +} diff --git a/src/utils/sanitizeMentions.js b/src/utils/sanitizeMentions.js new file mode 100644 index 000000000..fa0887baa --- /dev/null +++ b/src/utils/sanitizeMentions.js @@ -0,0 +1,173 @@ +/** + * Mention Sanitization Utility + * Defense-in-depth layer to strip @everyone and @here from outgoing messages. + * Even though allowedMentions is set at the Client level, this ensures + * the raw text never contains these pings. + * + * @see https://github.com/BillChirico/bills-bot/issues/61 + */ + +/** + * Zero-width space character used to break mention parsing. + * Inserted after '@' so Discord doesn't recognize the mention. + */ +const ZWS = '\u200B'; + +/** + * Pattern matching @everyone and @here mentions. + * Uses a negative lookbehind for word characters to avoid false positives + * in email addresses (e.g. user@everyone.com should NOT be mutated). + * + * Discord treats @everyone and @here as case-sensitive — only exact + * lowercase forms trigger mass pings. @Everyone, @HERE, etc. are NOT + * parsed as mentions by Discord, so we intentionally omit the /i flag. + */ +const MENTION_PATTERN = /(? are NOT affected + * - Returns non-string inputs unchanged (null, undefined, numbers, etc.) + * + * @param {*} text - The text to sanitize + * @returns {*} The sanitized text, or the original value if not a string + */ +export function sanitizeMentions(text) { + if (typeof text !== 'string') { + return text; + } + + return text.replace(MENTION_PATTERN, `@${ZWS}$1`); +} + +/** + * Sanitize a plain embed data object's string fields. + * Sanitizes title, description, footer.text, author.name, + * and all fields[].name / fields[].value. + * + * @param {object} data - A plain embed data object + * @returns {object} A new object with sanitized string fields + */ +function sanitizeEmbedData(data) { + const result = { ...data }; + + result.title = sanitizeMentions(result.title); + result.description = sanitizeMentions(result.description); + + if (result.footer && typeof result.footer === 'object') { + result.footer = { ...result.footer, text: sanitizeMentions(result.footer.text) }; + } + + if (result.author && typeof result.author === 'object') { + result.author = { ...result.author, name: sanitizeMentions(result.author.name) }; + } + + if (Array.isArray(result.fields)) { + result.fields = result.fields.map((field) => ({ + ...field, + name: sanitizeMentions(field.name), + value: sanitizeMentions(field.value), + })); + } + + return result; +} + +/** + * Sanitize a single embed object's string fields. + * Handles both plain embed objects and EmbedBuilder instances + * (preserving the class prototype so methods like .toJSON() still work). + * + * @param {object} embed - A Discord embed object or EmbedBuilder + * @returns {object} A new embed with sanitized string fields + */ +function sanitizeEmbed(embed) { + if (!embed || typeof embed !== 'object') { + return embed; + } + + // EmbedBuilder instances store data in .data — sanitize that + // while preserving the prototype chain (e.g. .toJSON()). + if ('data' in embed && typeof embed.toJSON === 'function') { + const clone = Object.create(Object.getPrototypeOf(embed)); + Object.assign(clone, embed); + clone.data = sanitizeEmbedData(clone.data); + return clone; + } + + return sanitizeEmbedData(embed); +} + +/** + * Sanitize a single component object (button, select menu, etc.). + * Handles ActionRow containers recursively. + * + * @param {object} component - A Discord message component + * @returns {object} A new component with sanitized string fields + */ +function sanitizeComponent(component) { + if (!component || typeof component !== 'object') { + return component; + } + + const result = { ...component }; + + result.label = sanitizeMentions(result.label); + result.placeholder = sanitizeMentions(result.placeholder); + + if (Array.isArray(result.options)) { + result.options = result.options.map((opt) => ({ + ...opt, + label: sanitizeMentions(opt.label), + description: sanitizeMentions(opt.description), + })); + } + + // ActionRow: recurse into nested components + if (Array.isArray(result.components)) { + result.components = result.components.map(sanitizeComponent); + } + + return result; +} + +/** + * Sanitize the content, embed, and component fields of a message options object. + * If given a string, sanitizes it directly. + * If given an object, sanitizes content, embeds, and components. + * Returns other types unchanged. + * + * Defense-in-depth: sanitizes all user-visible text fields so raw + * @everyone/@here never appears, even though allowedMentions also + * prevents Discord from parsing them. + * + * @param {string|object|*} options - Message content or options object + * @returns {string|object|*} Sanitized version + */ +export function sanitizeMessageOptions(options) { + if (typeof options === 'string') { + return sanitizeMentions(options); + } + + if (options && typeof options === 'object') { + const result = { ...options }; + + if ('content' in result) { + result.content = sanitizeMentions(result.content); + } + + if (Array.isArray(result.embeds)) { + result.embeds = result.embeds.map(sanitizeEmbed); + } + + if (Array.isArray(result.components)) { + result.components = result.components.map(sanitizeComponent); + } + + return result; + } + + return options; +} diff --git a/src/utils/splitMessage.js b/src/utils/splitMessage.js index 668399636..5554e21ed 100644 --- a/src/utils/splitMessage.js +++ b/src/utils/splitMessage.js @@ -6,7 +6,7 @@ /** * Discord's maximum message length. */ -const DISCORD_MAX_LENGTH = 2000; +export const DISCORD_MAX_LENGTH = 2000; /** * Safe chunk size leaving room for potential overhead. diff --git a/tests/commands/ban.test.js b/tests/commands/ban.test.js index 91de2b39b..319beae65 100644 --- a/tests/commands/ban.test.js +++ b/tests/commands/ban.test.js @@ -1,5 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +vi.mock('../../src/utils/safeSend.js', () => ({ + safeSend: (ch, opts) => ch.send(opts), + safeReply: (t, opts) => t.reply(opts), + safeFollowUp: (t, opts) => t.followUp(opts), + safeEditReply: (t, opts) => t.editReply(opts), +})); vi.mock('../../src/modules/moderation.js', () => ({ createCase: vi.fn().mockResolvedValue({ case_number: 1, action: 'ban', id: 1 }), sendDmNotification: vi.fn().mockResolvedValue(undefined), diff --git a/tests/commands/case.test.js b/tests/commands/case.test.js index aae4c001b..08dd2a3e0 100644 --- a/tests/commands/case.test.js +++ b/tests/commands/case.test.js @@ -1,5 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +vi.mock('../../src/utils/safeSend.js', () => ({ + safeSend: (ch, opts) => ch.send(opts), + safeReply: (t, opts) => t.reply(opts), + safeFollowUp: (t, opts) => t.followUp(opts), + safeEditReply: (t, opts) => t.editReply(opts), +})); vi.mock('../../src/db.js', () => ({ getPool: vi.fn(), })); diff --git a/tests/commands/history.test.js b/tests/commands/history.test.js index 9bfb07928..665c5cbf5 100644 --- a/tests/commands/history.test.js +++ b/tests/commands/history.test.js @@ -1,5 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +vi.mock('../../src/utils/safeSend.js', () => ({ + safeSend: (ch, opts) => ch.send(opts), + safeReply: (t, opts) => t.reply(opts), + safeFollowUp: (t, opts) => t.followUp(opts), + safeEditReply: (t, opts) => t.editReply(opts), +})); vi.mock('../../src/db.js', () => ({ getPool: vi.fn(), })); diff --git a/tests/commands/kick.test.js b/tests/commands/kick.test.js index f96e1842f..43d6fa4db 100644 --- a/tests/commands/kick.test.js +++ b/tests/commands/kick.test.js @@ -1,5 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +vi.mock('../../src/utils/safeSend.js', () => ({ + safeSend: (ch, opts) => ch.send(opts), + safeReply: (t, opts) => t.reply(opts), + safeFollowUp: (t, opts) => t.followUp(opts), + safeEditReply: (t, opts) => t.editReply(opts), +})); vi.mock('../../src/modules/moderation.js', () => ({ createCase: vi.fn().mockResolvedValue({ case_number: 1, action: 'kick', id: 1 }), sendDmNotification: vi.fn().mockResolvedValue(undefined), diff --git a/tests/commands/lock.test.js b/tests/commands/lock.test.js index 9d71d39e0..592d2ae8e 100644 --- a/tests/commands/lock.test.js +++ b/tests/commands/lock.test.js @@ -1,6 +1,12 @@ import { ChannelType } from 'discord.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; +vi.mock('../../src/utils/safeSend.js', () => ({ + safeSend: (ch, opts) => ch.send(opts), + safeReply: (t, opts) => t.reply(opts), + safeFollowUp: (t, opts) => t.followUp(opts), + safeEditReply: (t, opts) => t.editReply(opts), +})); vi.mock('../../src/modules/moderation.js', () => ({ createCase: vi.fn().mockResolvedValue({ case_number: 1, action: 'lock', id: 1 }), sendModLogEmbed: vi.fn().mockResolvedValue(null), diff --git a/tests/commands/memory.test.js b/tests/commands/memory.test.js index 2fa8cb1d1..3422ec6ce 100644 --- a/tests/commands/memory.test.js +++ b/tests/commands/memory.test.js @@ -190,6 +190,14 @@ vi.mock('../../src/modules/optout.js', () => ({ toggleOptOut: vi.fn(() => ({ optedOut: true })), })); +// Mock safeSend wrappers — spies that delegate to the interaction methods +vi.mock('../../src/utils/safeSend.js', () => ({ + safeReply: vi.fn((target, options) => target.reply(options)), + safeEditReply: vi.fn((interaction, options) => interaction.editReply(options)), + safeFollowUp: vi.fn((interaction, options) => interaction.followUp(options)), + safeUpdate: vi.fn((interaction, options) => interaction.update(options)), +})); + // Mock logger vi.mock('../../src/logger.js', () => ({ info: vi.fn(), @@ -208,6 +216,7 @@ import { searchMemories, } from '../../src/modules/memory.js'; import { isOptedOut, toggleOptOut } from '../../src/modules/optout.js'; +import { safeEditReply, safeReply, safeUpdate } from '../../src/utils/safeSend.js'; /** * Create a mock interaction for memory command tests. @@ -964,4 +973,216 @@ describe('memory command', () => { ); }); }); + + describe('safeSend wrapper usage verification', () => { + it('should use safeReply for memory unavailable response', async () => { + checkAndRecoverMemory.mockReturnValue(false); + const interaction = createMockInteraction(); + + await execute(interaction); + + expect(safeReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ + content: expect.stringContaining('unavailable'), + ephemeral: true, + }), + ); + }); + + it('should use safeReply for optout response', async () => { + toggleOptOut.mockReturnValue({ optedOut: true }); + const interaction = createMockInteraction({ subcommand: 'optout' }); + + await execute(interaction); + + expect(safeReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ + content: expect.stringContaining('opted out'), + ephemeral: true, + }), + ); + }); + + it('should use safeEditReply for /memory view response', async () => { + getMemories.mockResolvedValue([{ id: 'mem-1', memory: 'Likes pizza' }]); + const interaction = createMockInteraction({ subcommand: 'view' }); + + await execute(interaction); + + expect(safeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ + content: expect.stringContaining('Likes pizza'), + }), + ); + }); + + it('should use safeReply for forget confirmation prompt', async () => { + const interaction = createMockInteraction({ subcommand: 'forget' }); + interaction._mockResponse.awaitMessageComponent.mockResolvedValue({ + customId: 'memory_forget_cancel', + update: vi.fn(), + }); + + await execute(interaction); + + expect(safeReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ + content: expect.stringContaining('Are you sure'), + components: expect.any(Array), + ephemeral: true, + }), + ); + }); + + it('should use safeEditReply for forget topic response', async () => { + searchMemories.mockResolvedValue({ + memories: [{ id: 'mem-1', memory: 'Test', score: 0.9 }], + relations: [], + }); + deleteMemory.mockResolvedValue(true); + const interaction = createMockInteraction({ subcommand: 'forget', topic: 'test' }); + + await execute(interaction); + + expect(safeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ + content: expect.stringContaining('1 memory'), + }), + ); + }); + + it('should use safeUpdate for forget confirm button interaction', async () => { + deleteAllMemories.mockResolvedValue(true); + const mockUpdate = vi.fn(); + const interaction = createMockInteraction({ subcommand: 'forget' }); + const buttonInteraction = { + customId: 'memory_forget_confirm', + update: mockUpdate, + }; + interaction._mockResponse.awaitMessageComponent.mockResolvedValue(buttonInteraction); + + await execute(interaction); + + expect(safeUpdate).toHaveBeenCalledWith( + buttonInteraction, + expect.objectContaining({ + content: expect.stringContaining('cleared'), + components: [], + }), + ); + }); + + it('should use safeUpdate for forget cancel button interaction', async () => { + const mockUpdate = vi.fn(); + const interaction = createMockInteraction({ subcommand: 'forget' }); + const buttonInteraction = { + customId: 'memory_forget_cancel', + update: mockUpdate, + }; + interaction._mockResponse.awaitMessageComponent.mockResolvedValue(buttonInteraction); + + await execute(interaction); + + expect(safeUpdate).toHaveBeenCalledWith( + buttonInteraction, + expect.objectContaining({ + content: expect.stringContaining('cancelled'), + components: [], + }), + ); + }); + + it('should use safeUpdate for admin clear confirm button interaction', async () => { + deleteAllMemories.mockResolvedValue(true); + const mockUpdate = vi.fn(); + const interaction = createMockInteraction({ + subcommand: 'clear', + subcommandGroup: 'admin', + targetUser: { id: '999', username: 'targetuser' }, + hasManageGuild: true, + }); + const buttonInteraction = { + customId: 'memory_admin_clear_confirm', + update: mockUpdate, + }; + interaction._mockResponse.awaitMessageComponent.mockResolvedValue(buttonInteraction); + + await execute(interaction); + + expect(safeUpdate).toHaveBeenCalledWith( + buttonInteraction, + expect.objectContaining({ + content: expect.stringContaining('targetuser'), + components: [], + }), + ); + }); + + it('should use safeUpdate for admin clear cancel button interaction', async () => { + const mockUpdate = vi.fn(); + const interaction = createMockInteraction({ + subcommand: 'clear', + subcommandGroup: 'admin', + targetUser: { id: '999', username: 'targetuser' }, + hasManageGuild: true, + }); + const buttonInteraction = { + customId: 'memory_admin_clear_cancel', + update: mockUpdate, + }; + interaction._mockResponse.awaitMessageComponent.mockResolvedValue(buttonInteraction); + + await execute(interaction); + + expect(safeUpdate).toHaveBeenCalledWith( + buttonInteraction, + expect.objectContaining({ + content: expect.stringContaining('cancelled'), + components: [], + }), + ); + }); + + it('should use safeReply for admin permission denial', async () => { + const interaction = createMockInteraction({ + subcommand: 'view', + subcommandGroup: 'admin', + targetUser: { id: '999', username: 'targetuser' }, + }); + + await execute(interaction); + + expect(safeReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ + content: expect.stringContaining('Manage Server'), + ephemeral: true, + }), + ); + }); + + it('should use safeEditReply for admin view response', async () => { + getMemories.mockResolvedValue([{ id: 'mem-1', memory: 'Admin test' }]); + const interaction = createMockInteraction({ + subcommand: 'view', + subcommandGroup: 'admin', + targetUser: { id: '999', username: 'targetuser' }, + hasManageGuild: true, + }); + + await execute(interaction); + + expect(safeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ + content: expect.stringContaining('Admin test'), + }), + ); + }); + }); }); diff --git a/tests/commands/modlog.test.js b/tests/commands/modlog.test.js index 8a117ebc2..882533f47 100644 --- a/tests/commands/modlog.test.js +++ b/tests/commands/modlog.test.js @@ -1,5 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +vi.mock('../../src/utils/safeSend.js', () => ({ + safeSend: (ch, opts) => ch.send(opts), + safeReply: (t, opts) => t.reply(opts), + safeFollowUp: (t, opts) => t.followUp(opts), + safeEditReply: (t, opts) => t.editReply(opts), +})); vi.mock('../../src/modules/config.js', () => ({ getConfig: vi.fn().mockReturnValue({ moderation: { diff --git a/tests/commands/ping.test.js b/tests/commands/ping.test.js index e53bef7cc..e0ce82138 100644 --- a/tests/commands/ping.test.js +++ b/tests/commands/ping.test.js @@ -1,6 +1,12 @@ import { describe, expect, it, vi } from 'vitest'; // Mock discord.js with proper class mocks +vi.mock('../../src/utils/safeSend.js', () => ({ + safeSend: (ch, opts) => ch.send(opts), + safeReply: (t, opts) => t.reply(opts), + safeFollowUp: (t, opts) => t.followUp(opts), + safeEditReply: (t, opts) => t.editReply(opts), +})); vi.mock('discord.js', () => { class MockSlashCommandBuilder { constructor() { diff --git a/tests/commands/purge.test.js b/tests/commands/purge.test.js index 36b2cffd4..a82903f74 100644 --- a/tests/commands/purge.test.js +++ b/tests/commands/purge.test.js @@ -1,6 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -// Mock logger +// Mock safeSend wrappers — passthrough to underlying methods for unit isolation +vi.mock('../../src/utils/safeSend.js', () => ({ + safeSend: (ch, opts) => ch.send(opts), + safeReply: (t, opts) => t.reply(opts), + safeFollowUp: (t, opts) => t.followUp(opts), + safeEditReply: (t, opts) => t.editReply(opts), +})); vi.mock('../../src/logger.js', () => ({ info: vi.fn(), error: vi.fn(), diff --git a/tests/commands/slowmode.test.js b/tests/commands/slowmode.test.js index 421c12b1d..186f228ea 100644 --- a/tests/commands/slowmode.test.js +++ b/tests/commands/slowmode.test.js @@ -1,5 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +vi.mock('../../src/utils/safeSend.js', () => ({ + safeSend: (ch, opts) => ch.send(opts), + safeReply: (t, opts) => t.reply(opts), + safeFollowUp: (t, opts) => t.followUp(opts), + safeEditReply: (t, opts) => t.editReply(opts), +})); vi.mock('../../src/utils/duration.js', () => ({ parseDuration: vi.fn(), formatDuration: vi.fn().mockImplementation((ms) => { diff --git a/tests/commands/softban.test.js b/tests/commands/softban.test.js index df63298f9..6aaea1229 100644 --- a/tests/commands/softban.test.js +++ b/tests/commands/softban.test.js @@ -1,5 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +vi.mock('../../src/utils/safeSend.js', () => ({ + safeSend: (ch, opts) => ch.send(opts), + safeReply: (t, opts) => t.reply(opts), + safeFollowUp: (t, opts) => t.followUp(opts), + safeEditReply: (t, opts) => t.editReply(opts), +})); vi.mock('../../src/modules/moderation.js', () => ({ createCase: vi.fn().mockResolvedValue({ case_number: 1, action: 'softban', id: 1 }), sendDmNotification: vi.fn().mockResolvedValue(undefined), diff --git a/tests/commands/tempban.test.js b/tests/commands/tempban.test.js index ea7b970be..4a3bc51dc 100644 --- a/tests/commands/tempban.test.js +++ b/tests/commands/tempban.test.js @@ -1,5 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +vi.mock('../../src/utils/safeSend.js', () => ({ + safeSend: (ch, opts) => ch.send(opts), + safeReply: (t, opts) => t.reply(opts), + safeFollowUp: (t, opts) => t.followUp(opts), + safeEditReply: (t, opts) => t.editReply(opts), +})); vi.mock('../../src/modules/moderation.js', () => ({ createCase: vi.fn().mockResolvedValue({ case_number: 1, action: 'tempban', id: 1 }), scheduleAction: vi.fn().mockResolvedValue({ id: 10 }), diff --git a/tests/commands/timeout.test.js b/tests/commands/timeout.test.js index f54a50114..f62712f02 100644 --- a/tests/commands/timeout.test.js +++ b/tests/commands/timeout.test.js @@ -1,5 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +vi.mock('../../src/utils/safeSend.js', () => ({ + safeSend: (ch, opts) => ch.send(opts), + safeReply: (t, opts) => t.reply(opts), + safeFollowUp: (t, opts) => t.followUp(opts), + safeEditReply: (t, opts) => t.editReply(opts), +})); vi.mock('../../src/modules/moderation.js', () => ({ createCase: vi.fn().mockResolvedValue({ case_number: 1, action: 'timeout', id: 1 }), sendDmNotification: vi.fn().mockResolvedValue(undefined), diff --git a/tests/commands/unban.test.js b/tests/commands/unban.test.js index 92c223475..b11b8a66a 100644 --- a/tests/commands/unban.test.js +++ b/tests/commands/unban.test.js @@ -1,5 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +vi.mock('../../src/utils/safeSend.js', () => ({ + safeSend: (ch, opts) => ch.send(opts), + safeReply: (t, opts) => t.reply(opts), + safeFollowUp: (t, opts) => t.followUp(opts), + safeEditReply: (t, opts) => t.editReply(opts), +})); vi.mock('../../src/modules/moderation.js', () => ({ createCase: vi.fn().mockResolvedValue({ case_number: 1, action: 'unban', id: 1 }), sendModLogEmbed: vi.fn().mockResolvedValue({ id: 'msg1' }), diff --git a/tests/commands/unlock.test.js b/tests/commands/unlock.test.js index 63ac320ec..538cab629 100644 --- a/tests/commands/unlock.test.js +++ b/tests/commands/unlock.test.js @@ -1,6 +1,12 @@ import { ChannelType } from 'discord.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; +vi.mock('../../src/utils/safeSend.js', () => ({ + safeSend: (ch, opts) => ch.send(opts), + safeReply: (t, opts) => t.reply(opts), + safeFollowUp: (t, opts) => t.followUp(opts), + safeEditReply: (t, opts) => t.editReply(opts), +})); vi.mock('../../src/modules/moderation.js', () => ({ createCase: vi.fn().mockResolvedValue({ case_number: 2, action: 'unlock', id: 2 }), sendModLogEmbed: vi.fn().mockResolvedValue(null), diff --git a/tests/commands/untimeout.test.js b/tests/commands/untimeout.test.js index 6ab2e02ab..3df746e42 100644 --- a/tests/commands/untimeout.test.js +++ b/tests/commands/untimeout.test.js @@ -1,5 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +vi.mock('../../src/utils/safeSend.js', () => ({ + safeSend: (ch, opts) => ch.send(opts), + safeReply: (t, opts) => t.reply(opts), + safeFollowUp: (t, opts) => t.followUp(opts), + safeEditReply: (t, opts) => t.editReply(opts), +})); vi.mock('../../src/modules/moderation.js', () => ({ createCase: vi.fn().mockResolvedValue({ case_number: 1, action: 'untimeout', id: 1 }), sendModLogEmbed: vi.fn().mockResolvedValue({ id: 'msg1' }), diff --git a/tests/commands/warn.test.js b/tests/commands/warn.test.js index 8d132ba5f..971b23046 100644 --- a/tests/commands/warn.test.js +++ b/tests/commands/warn.test.js @@ -1,5 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +vi.mock('../../src/utils/safeSend.js', () => ({ + safeSend: (ch, opts) => ch.send(opts), + safeReply: (t, opts) => t.reply(opts), + safeFollowUp: (t, opts) => t.followUp(opts), + safeEditReply: (t, opts) => t.editReply(opts), +})); vi.mock('../../src/modules/moderation.js', () => ({ createCase: vi.fn().mockResolvedValue({ case_number: 1, action: 'warn', id: 1 }), sendDmNotification: vi.fn().mockResolvedValue(undefined), diff --git a/tests/index.test.js b/tests/index.test.js index a2221a2a0..fcbc4e8b7 100644 --- a/tests/index.test.js +++ b/tests/index.test.js @@ -1,7 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +vi.mock('../src/utils/safeSend.js', () => ({ + safeSend: (ch, opts) => ch.send(opts), + safeReply: (t, opts) => t.reply(opts), + safeFollowUp: (t, opts) => t.followUp(opts), + safeEditReply: (t, opts) => t.editReply(opts), +})); const mocks = vi.hoisted(() => ({ client: null, + clientOptions: null, onHandlers: {}, onceHandlers: {}, processHandlers: {}, @@ -76,7 +83,7 @@ vi.mock('node:fs', () => ({ vi.mock('discord.js', () => { class Client { - constructor() { + constructor(options) { this.user = { id: 'bot-user-id', tag: 'Bot#0001' }; this.guilds = { cache: { size: 2 } }; this.ws = { ping: 12 }; @@ -84,6 +91,7 @@ vi.mock('discord.js', () => { this.login = vi.fn().mockResolvedValue('logged-in'); this.destroy = vi.fn(); mocks.client = this; + mocks.clientOptions = options; } once(event, cb) { @@ -296,6 +304,12 @@ describe('index.js', () => { delete process.env.DATABASE_URL; }); + it('should configure allowedMentions to only parse users (Issue #61)', async () => { + await importIndex({ token: 'abc', databaseUrl: null }); + expect(mocks.clientOptions).toBeDefined(); + expect(mocks.clientOptions.allowedMentions).toEqual({ parse: ['users'] }); + }); + it('should exit when DISCORD_TOKEN is missing', async () => { await expect(importIndex({ token: null, databaseUrl: null })).rejects.toThrow('process.exit:1'); expect(mocks.logger.error).toHaveBeenCalledWith('DISCORD_TOKEN not set'); diff --git a/tests/modules/chimeIn.test.js b/tests/modules/chimeIn.test.js index db7908d39..6e9fd2e41 100644 --- a/tests/modules/chimeIn.test.js +++ b/tests/modules/chimeIn.test.js @@ -1,6 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -// Mock logger +// Mock safeSend wrappers — passthrough to underlying methods for unit isolation +vi.mock('../../src/utils/safeSend.js', () => ({ + safeSend: (ch, opts) => ch.send(opts), + safeReply: (t, opts) => t.reply(opts), + safeFollowUp: (t, opts) => t.followUp(opts), + safeEditReply: (t, opts) => t.editReply(opts), +})); vi.mock('../../src/logger.js', () => ({ info: vi.fn(), error: vi.fn(), diff --git a/tests/modules/events.test.js b/tests/modules/events.test.js index 151e03558..cf3769ced 100644 --- a/tests/modules/events.test.js +++ b/tests/modules/events.test.js @@ -1,6 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -// Mock logger +// Mock safeSend wrappers — passthrough to underlying methods for unit isolation +vi.mock('../../src/utils/safeSend.js', () => ({ + safeSend: (ch, opts) => ch.send(opts), + safeReply: (t, opts) => t.reply(opts), + safeFollowUp: (t, opts) => t.followUp(opts), + safeEditReply: (t, opts) => t.editReply(opts), +})); vi.mock('../../src/logger.js', () => ({ info: vi.fn(), error: vi.fn(), diff --git a/tests/modules/welcome.test.js b/tests/modules/welcome.test.js index fccf86bbe..995c9ff5b 100644 --- a/tests/modules/welcome.test.js +++ b/tests/modules/welcome.test.js @@ -232,7 +232,10 @@ describe('sendWelcomeMessage', () => { }, }; await sendWelcomeMessage(member, client, config); - expect(mockSend).toHaveBeenCalledWith('Welcome <@123> to Test Server!'); + expect(mockSend).toHaveBeenCalledWith({ + content: 'Welcome <@123> to Test Server!', + allowedMentions: { parse: ['users'] }, + }); }); it('should send dynamic welcome message when enabled', async () => { @@ -264,7 +267,7 @@ describe('sendWelcomeMessage', () => { }; await sendWelcomeMessage(member, client, config); expect(mockSend).toHaveBeenCalled(); - const sentMessage = mockSend.mock.calls[0][0]; + const sentMessage = mockSend.mock.calls[0][0].content; expect(sentMessage).toContain('<@123>'); }); @@ -309,7 +312,7 @@ describe('sendWelcomeMessage', () => { }, }; await sendWelcomeMessage(member, client, config); - const sentMessage = mockSend.mock.calls[0][0]; + const sentMessage = mockSend.mock.calls[0][0].content; expect(sentMessage).toContain('#100'); expect(sentMessage).toContain('milestone'); }); @@ -324,7 +327,10 @@ describe('sendWelcomeMessage', () => { const client = { channels: { fetch: vi.fn().mockResolvedValue({ send: mockSend }) } }; const config = { welcome: { enabled: true, channelId: 'ch1' } }; await sendWelcomeMessage(member, client, config); - expect(mockSend).toHaveBeenCalledWith('Welcome, <@123>!'); + expect(mockSend).toHaveBeenCalledWith({ + content: 'Welcome, <@123>!', + allowedMentions: { parse: ['users'] }, + }); }); it('should send dynamic message with highlight channels', async () => { @@ -466,7 +472,7 @@ describe('sendWelcomeMessage', () => { }, }; await sendWelcomeMessage(member, client, config); - const msg = mockSend.mock.calls[0][0]; + const msg = mockSend.mock.calls[0][0].content; expect(msg).toContain('milestone'); }); }); diff --git a/tests/utils/safeSend.test.js b/tests/utils/safeSend.test.js new file mode 100644 index 000000000..43c2775f7 --- /dev/null +++ b/tests/utils/safeSend.test.js @@ -0,0 +1,461 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock logger to prevent file-system side effects in tests +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), +})); + +// Mock splitMessage — default to no splitting; individual tests override +vi.mock('../../src/utils/splitMessage.js', () => ({ + DISCORD_MAX_LENGTH: 2000, + needsSplitting: vi.fn().mockReturnValue(false), + splitMessage: vi.fn().mockReturnValue([]), +})); + +import { error as mockLogError, warn as mockLogWarn } from '../../src/logger.js'; +import { + safeEditReply, + safeFollowUp, + safeReply, + safeSend, + safeUpdate, +} from '../../src/utils/safeSend.js'; +import { needsSplitting, splitMessage } from '../../src/utils/splitMessage.js'; + +const ZWS = '\u200B'; +const SAFE_ALLOWED_MENTIONS = { parse: ['users'] }; + +// Clear all mocks between tests to prevent cross-test pollution +// of module-level mock functions (mockLogError, mockLogWarn, splitMessage mocks) +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('safeSend', () => { + let mockChannel; + + beforeEach(() => { + mockChannel = { + send: vi.fn().mockResolvedValue({ id: 'msg-1' }), + }; + }); + + it('should sanitize content and add allowedMentions for string input', async () => { + await safeSend(mockChannel, '@everyone hello'); + expect(mockChannel.send).toHaveBeenCalledWith({ + content: `@${ZWS}everyone hello`, + allowedMentions: SAFE_ALLOWED_MENTIONS, + }); + }); + + it('should sanitize content and add allowedMentions for object input', async () => { + await safeSend(mockChannel, { content: '@here world', embeds: [] }); + expect(mockChannel.send).toHaveBeenCalledWith({ + content: `@${ZWS}here world`, + embeds: [], + allowedMentions: SAFE_ALLOWED_MENTIONS, + }); + }); + + it('should override existing allowedMentions with safe defaults', async () => { + await safeSend(mockChannel, { + content: 'test', + allowedMentions: { parse: ['everyone', 'roles', 'users'] }, + }); + expect(mockChannel.send).toHaveBeenCalledWith({ + content: 'test', + allowedMentions: SAFE_ALLOWED_MENTIONS, + }); + }); + + it('should preserve normal user mentions in content', async () => { + await safeSend(mockChannel, '<@123456789> check this out'); + expect(mockChannel.send).toHaveBeenCalledWith({ + content: '<@123456789> check this out', + allowedMentions: SAFE_ALLOWED_MENTIONS, + }); + }); + + it('should return the result from channel.send', async () => { + const result = await safeSend(mockChannel, 'hello'); + expect(result).toEqual({ id: 'msg-1' }); + }); + + it('should sanitize embed fields in addition to content', async () => { + await safeSend(mockChannel, { + content: 'test', + embeds: [{ title: '@everyone alert', description: '@here check' }], + }); + expect(mockChannel.send).toHaveBeenCalledWith({ + content: 'test', + embeds: [{ title: `@${ZWS}everyone alert`, description: `@${ZWS}here check` }], + allowedMentions: SAFE_ALLOWED_MENTIONS, + }); + }); +}); + +describe('safeReply', () => { + let mockInteraction; + + beforeEach(() => { + mockInteraction = { + reply: vi.fn().mockResolvedValue(undefined), + }; + }); + + it('should sanitize content and add allowedMentions for string input', async () => { + await safeReply(mockInteraction, '@everyone check'); + expect(mockInteraction.reply).toHaveBeenCalledWith({ + content: `@${ZWS}everyone check`, + allowedMentions: SAFE_ALLOWED_MENTIONS, + }); + }); + + it('should sanitize content in object input', async () => { + await safeReply(mockInteraction, { content: '@here ping', ephemeral: true }); + expect(mockInteraction.reply).toHaveBeenCalledWith({ + content: `@${ZWS}here ping`, + ephemeral: true, + allowedMentions: SAFE_ALLOWED_MENTIONS, + }); + }); + + it('should handle options without content', async () => { + await safeReply(mockInteraction, { embeds: [{ title: 'test' }] }); + expect(mockInteraction.reply).toHaveBeenCalledWith({ + embeds: [{ title: 'test' }], + allowedMentions: SAFE_ALLOWED_MENTIONS, + }); + }); +}); + +describe('safeFollowUp', () => { + let mockInteraction; + + beforeEach(() => { + mockInteraction = { + followUp: vi.fn().mockResolvedValue({ id: 'msg-2' }), + }; + }); + + it('should sanitize content and add allowedMentions for string input', async () => { + await safeFollowUp(mockInteraction, '@everyone update'); + expect(mockInteraction.followUp).toHaveBeenCalledWith({ + content: `@${ZWS}everyone update`, + allowedMentions: SAFE_ALLOWED_MENTIONS, + }); + }); + + it('should sanitize content in object input', async () => { + await safeFollowUp(mockInteraction, { content: '@here news' }); + expect(mockInteraction.followUp).toHaveBeenCalledWith({ + content: `@${ZWS}here news`, + allowedMentions: SAFE_ALLOWED_MENTIONS, + }); + }); + + it('should return the result from interaction.followUp', async () => { + const result = await safeFollowUp(mockInteraction, 'hello'); + expect(result).toEqual({ id: 'msg-2' }); + }); +}); + +describe('safeEditReply', () => { + let mockInteraction; + + beforeEach(() => { + mockInteraction = { + editReply: vi.fn().mockResolvedValue({ id: 'msg-3' }), + }; + }); + + it('should sanitize content and add allowedMentions for string input', async () => { + await safeEditReply(mockInteraction, '@everyone edited'); + expect(mockInteraction.editReply).toHaveBeenCalledWith({ + content: `@${ZWS}everyone edited`, + allowedMentions: SAFE_ALLOWED_MENTIONS, + }); + }); + + it('should sanitize content in object input', async () => { + await safeEditReply(mockInteraction, { content: '@here updated' }); + expect(mockInteraction.editReply).toHaveBeenCalledWith({ + content: `@${ZWS}here updated`, + allowedMentions: SAFE_ALLOWED_MENTIONS, + }); + }); + + it('should return the result from interaction.editReply', async () => { + const result = await safeEditReply(mockInteraction, 'hello'); + expect(result).toEqual({ id: 'msg-3' }); + }); +}); + +describe('safeUpdate', () => { + let mockInteraction; + + beforeEach(() => { + mockInteraction = { + update: vi.fn().mockResolvedValue({ id: 'msg-6' }), + }; + }); + + it('should sanitize content and add allowedMentions for string input', async () => { + await safeUpdate(mockInteraction, '@everyone updated'); + expect(mockInteraction.update).toHaveBeenCalledWith({ + content: `@${ZWS}everyone updated`, + allowedMentions: SAFE_ALLOWED_MENTIONS, + }); + }); + + it('should sanitize content in object input', async () => { + await safeUpdate(mockInteraction, { content: '@here clicked', components: [] }); + expect(mockInteraction.update).toHaveBeenCalledWith({ + content: `@${ZWS}here clicked`, + components: [], + allowedMentions: SAFE_ALLOWED_MENTIONS, + }); + }); + + it('should return the result from interaction.update', async () => { + const result = await safeUpdate(mockInteraction, 'hello'); + expect(result).toEqual({ id: 'msg-6' }); + }); +}); + +describe('allowedMentions override enforcement', () => { + it('safeReply should override caller-supplied allowedMentions', async () => { + const mockTarget = { reply: vi.fn().mockResolvedValue(undefined) }; + await safeReply(mockTarget, { + content: 'test', + allowedMentions: { parse: ['everyone', 'roles', 'users'] }, + }); + expect(mockTarget.reply).toHaveBeenCalledWith( + expect.objectContaining({ allowedMentions: SAFE_ALLOWED_MENTIONS }), + ); + }); + + it('safeFollowUp should override caller-supplied allowedMentions', async () => { + const mockInteraction = { followUp: vi.fn().mockResolvedValue({ id: 'msg-4' }) }; + await safeFollowUp(mockInteraction, { + content: 'test', + allowedMentions: { parse: ['everyone'] }, + }); + expect(mockInteraction.followUp).toHaveBeenCalledWith( + expect.objectContaining({ allowedMentions: SAFE_ALLOWED_MENTIONS }), + ); + }); + + it('safeEditReply should override caller-supplied allowedMentions', async () => { + const mockInteraction = { editReply: vi.fn().mockResolvedValue({ id: 'msg-5' }) }; + await safeEditReply(mockInteraction, { + content: 'test', + allowedMentions: { parse: ['everyone', 'roles'] }, + }); + expect(mockInteraction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ allowedMentions: SAFE_ALLOWED_MENTIONS }), + ); + }); + + it('safeUpdate should override caller-supplied allowedMentions', async () => { + const mockInteraction = { update: vi.fn().mockResolvedValue({ id: 'msg-6' }) }; + await safeUpdate(mockInteraction, { + content: 'test', + allowedMentions: { parse: ['everyone', 'roles'] }, + }); + expect(mockInteraction.update).toHaveBeenCalledWith( + expect.objectContaining({ allowedMentions: SAFE_ALLOWED_MENTIONS }), + ); + }); +}); + +describe('splitMessage integration (channel.send only)', () => { + it('safeSend should split long content into multiple sends', async () => { + needsSplitting.mockReturnValueOnce(true); + splitMessage.mockReturnValueOnce(['chunk1', 'chunk2']); + const mockChannel = { send: vi.fn().mockResolvedValue({ id: 'msg' }) }; + + const result = await safeSend(mockChannel, 'a'.repeat(2500)); + expect(mockChannel.send).toHaveBeenCalledTimes(2); + expect(mockChannel.send).toHaveBeenCalledWith( + expect.objectContaining({ content: 'chunk1', allowedMentions: SAFE_ALLOWED_MENTIONS }), + ); + expect(mockChannel.send).toHaveBeenCalledWith( + expect.objectContaining({ content: 'chunk2', allowedMentions: SAFE_ALLOWED_MENTIONS }), + ); + expect(result).toHaveLength(2); + }); + + it('should only include embeds/components on the last chunk', async () => { + needsSplitting.mockReturnValueOnce(true); + splitMessage.mockReturnValueOnce(['chunk1', 'chunk2', 'chunk3']); + const mockChannel = { send: vi.fn().mockResolvedValue({ id: 'msg' }) }; + + await safeSend(mockChannel, { + content: 'a'.repeat(5000), + embeds: [{ title: 'test' }], + components: [{ type: 1 }], + }); + + expect(mockChannel.send).toHaveBeenCalledTimes(3); + + // First two chunks: content + allowedMentions only (no embeds, no components) + const call0 = mockChannel.send.mock.calls[0][0]; + expect(call0).toEqual({ content: 'chunk1', allowedMentions: SAFE_ALLOWED_MENTIONS }); + + const call1 = mockChannel.send.mock.calls[1][0]; + expect(call1).toEqual({ content: 'chunk2', allowedMentions: SAFE_ALLOWED_MENTIONS }); + + // Last chunk: full payload with embeds and components + const call2 = mockChannel.send.mock.calls[2][0]; + expect(call2).toEqual({ + content: 'chunk3', + embeds: [{ title: 'test' }], + components: [{ type: 1 }], + allowedMentions: SAFE_ALLOWED_MENTIONS, + }); + }); +}); + +describe('interaction truncation (reply/editReply/followUp)', () => { + const TRUNCATION_SUFFIX = '… [truncated]'; + + it('safeReply should truncate long content with indicator instead of splitting', async () => { + const longContent = 'x'.repeat(2500); + const mockTarget = { reply: vi.fn().mockResolvedValue({ id: 'msg' }) }; + + await safeReply(mockTarget, longContent); + + expect(mockTarget.reply).toHaveBeenCalledTimes(1); + const sentContent = mockTarget.reply.mock.calls[0][0].content; + expect(sentContent).toHaveLength(2000); + expect(sentContent.endsWith(TRUNCATION_SUFFIX)).toBe(true); + }); + + it('safeReply should log a warning when truncating', async () => { + const longContent = 'x'.repeat(2500); + const mockTarget = { reply: vi.fn().mockResolvedValue({ id: 'msg' }) }; + + await safeReply(mockTarget, longContent); + + expect(mockLogWarn).toHaveBeenCalledWith('Interaction content truncated', { + originalLength: 2500, + maxLength: 2000, + }); + }); + + it('safeFollowUp should truncate long content with indicator instead of splitting', async () => { + const longContent = 'y'.repeat(2500); + const mockInteraction = { followUp: vi.fn().mockResolvedValue({ id: 'msg' }) }; + + await safeFollowUp(mockInteraction, longContent); + + expect(mockInteraction.followUp).toHaveBeenCalledTimes(1); + const sentContent = mockInteraction.followUp.mock.calls[0][0].content; + expect(sentContent).toHaveLength(2000); + expect(sentContent.endsWith(TRUNCATION_SUFFIX)).toBe(true); + }); + + it('safeEditReply should truncate long content with indicator instead of splitting', async () => { + const longContent = 'z'.repeat(2500); + const mockInteraction = { editReply: vi.fn().mockResolvedValue({ id: 'msg' }) }; + + await safeEditReply(mockInteraction, longContent); + + expect(mockInteraction.editReply).toHaveBeenCalledTimes(1); + const sentContent = mockInteraction.editReply.mock.calls[0][0].content; + expect(sentContent).toHaveLength(2000); + expect(sentContent.endsWith(TRUNCATION_SUFFIX)).toBe(true); + }); + + it('safeReply should not truncate content within limit', async () => { + const shortContent = 'hello world'; + const mockTarget = { reply: vi.fn().mockResolvedValue({ id: 'msg' }) }; + + await safeReply(mockTarget, shortContent); + + expect(mockTarget.reply.mock.calls[0][0].content).toBe('hello world'); + }); + + it('safeUpdate should truncate long content with indicator instead of splitting', async () => { + const longContent = 'w'.repeat(2500); + const mockInteraction = { update: vi.fn().mockResolvedValue({ id: 'msg' }) }; + + await safeUpdate(mockInteraction, longContent); + + expect(mockInteraction.update).toHaveBeenCalledTimes(1); + const sentContent = mockInteraction.update.mock.calls[0][0].content; + expect(sentContent).toHaveLength(2000); + expect(sentContent.endsWith(TRUNCATION_SUFFIX)).toBe(true); + }); + + it('safeReply should handle non-string content unchanged', async () => { + const mockTarget = { reply: vi.fn().mockResolvedValue({ id: 'msg' }) }; + + await safeReply(mockTarget, { embeds: [{ title: 'test' }] }); + + expect(mockTarget.reply).toHaveBeenCalledWith( + expect.objectContaining({ embeds: [{ title: 'test' }] }), + ); + }); +}); + +describe('Winston error logging', () => { + it('safeSend should log error with stack trace and rethrow', async () => { + const err = new Error('send failed'); + const mockChannel = { send: vi.fn().mockRejectedValue(err) }; + + await expect(safeSend(mockChannel, 'test')).rejects.toThrow('send failed'); + expect(mockLogError).toHaveBeenCalledWith('safeSend failed', { + error: 'send failed', + stack: err.stack, + }); + }); + + it('safeReply should log error with stack trace and rethrow', async () => { + const err = new Error('reply failed'); + const mockTarget = { reply: vi.fn().mockRejectedValue(err) }; + + await expect(safeReply(mockTarget, 'test')).rejects.toThrow('reply failed'); + expect(mockLogError).toHaveBeenCalledWith('safeReply failed', { + error: 'reply failed', + stack: err.stack, + }); + }); + + it('safeFollowUp should log error with stack trace and rethrow', async () => { + const err = new Error('followUp failed'); + const mockInteraction = { followUp: vi.fn().mockRejectedValue(err) }; + + await expect(safeFollowUp(mockInteraction, 'test')).rejects.toThrow('followUp failed'); + expect(mockLogError).toHaveBeenCalledWith('safeFollowUp failed', { + error: 'followUp failed', + stack: err.stack, + }); + }); + + it('safeEditReply should log error with stack trace and rethrow', async () => { + const err = new Error('editReply failed'); + const mockInteraction = { editReply: vi.fn().mockRejectedValue(err) }; + + await expect(safeEditReply(mockInteraction, 'test')).rejects.toThrow('editReply failed'); + expect(mockLogError).toHaveBeenCalledWith('safeEditReply failed', { + error: 'editReply failed', + stack: err.stack, + }); + }); + + it('safeUpdate should log error with stack trace and rethrow', async () => { + const err = new Error('update failed'); + const mockInteraction = { update: vi.fn().mockRejectedValue(err) }; + + await expect(safeUpdate(mockInteraction, 'test')).rejects.toThrow('update failed'); + expect(mockLogError).toHaveBeenCalledWith('safeUpdate failed', { + error: 'update failed', + stack: err.stack, + }); + }); +}); diff --git a/tests/utils/sanitizeMentions.test.js b/tests/utils/sanitizeMentions.test.js new file mode 100644 index 000000000..fd60f3fb8 --- /dev/null +++ b/tests/utils/sanitizeMentions.test.js @@ -0,0 +1,362 @@ +import { describe, expect, it } from 'vitest'; +import { sanitizeMentions, sanitizeMessageOptions } from '../../src/utils/sanitizeMentions.js'; + +const ZWS = '\u200B'; + +describe('sanitizeMentions', () => { + describe('strips @everyone', () => { + it('should escape a standalone @everyone', () => { + expect(sanitizeMentions('@everyone')).toBe(`@${ZWS}everyone`); + }); + + it('should escape @everyone within a sentence', () => { + expect(sanitizeMentions('Hello @everyone how are you?')).toBe( + `Hello @${ZWS}everyone how are you?`, + ); + }); + + it('should escape multiple @everyone occurrences', () => { + expect(sanitizeMentions('@everyone and @everyone')).toBe( + `@${ZWS}everyone and @${ZWS}everyone`, + ); + }); + }); + + describe('strips @here', () => { + it('should escape a standalone @here', () => { + expect(sanitizeMentions('@here')).toBe(`@${ZWS}here`); + }); + + it('should escape @here within a sentence', () => { + expect(sanitizeMentions('Hey @here check this out')).toBe(`Hey @${ZWS}here check this out`); + }); + + it('should escape multiple @here occurrences', () => { + expect(sanitizeMentions('@here and @here')).toBe(`@${ZWS}here and @${ZWS}here`); + }); + }); + + describe('handles mixed mentions', () => { + it('should escape both @everyone and @here in the same message', () => { + expect(sanitizeMentions('@everyone and @here')).toBe(`@${ZWS}everyone and @${ZWS}here`); + }); + + it('should escape mentions alongside normal user mentions', () => { + const input = '<@123456789> said @everyone should join'; + const expected = `<@123456789> said @${ZWS}everyone should join`; + expect(sanitizeMentions(input)).toBe(expected); + }); + }); + + describe('preserves normal content', () => { + it('should not modify normal text', () => { + expect(sanitizeMentions('Hello world')).toBe('Hello world'); + }); + + it('should not modify user mentions', () => { + expect(sanitizeMentions('<@123456789>')).toBe('<@123456789>'); + }); + + it('should not modify role mentions', () => { + expect(sanitizeMentions('<@&987654321>')).toBe('<@&987654321>'); + }); + + it('should not modify channel mentions', () => { + expect(sanitizeMentions('<#123456789>')).toBe('<#123456789>'); + }); + + it('should not modify empty string', () => { + expect(sanitizeMentions('')).toBe(''); + }); + + it('should not modify email-like text', () => { + expect(sanitizeMentions('user@example.com')).toBe('user@example.com'); + }); + + it('should not modify email addresses containing @everyone', () => { + expect(sanitizeMentions('user@everyone.com')).toBe('user@everyone.com'); + }); + + it('should not modify email addresses containing @here', () => { + expect(sanitizeMentions('admin@here.org')).toBe('admin@here.org'); + }); + }); + + describe('handles non-string input', () => { + it('should return null unchanged', () => { + expect(sanitizeMentions(null)).toBe(null); + }); + + it('should return undefined unchanged', () => { + expect(sanitizeMentions(undefined)).toBe(undefined); + }); + + it('should return numbers unchanged', () => { + expect(sanitizeMentions(42)).toBe(42); + }); + + it('should return booleans unchanged', () => { + expect(sanitizeMentions(true)).toBe(true); + }); + + it('should return objects unchanged', () => { + const obj = { foo: 'bar' }; + expect(sanitizeMentions(obj)).toBe(obj); + }); + }); +}); + +describe('sanitizeMessageOptions', () => { + it('should sanitize a string argument', () => { + expect(sanitizeMessageOptions('@everyone hello')).toBe(`@${ZWS}everyone hello`); + }); + + it('should sanitize content in an options object', () => { + const result = sanitizeMessageOptions({ content: '@here world', ephemeral: true }); + expect(result).toEqual({ content: `@${ZWS}here world`, ephemeral: true }); + }); + + it('should not modify options without content', () => { + const options = { embeds: [{ title: 'test' }] }; + expect(sanitizeMessageOptions(options)).toEqual(options); + }); + + it('should handle null content in options', () => { + const result = sanitizeMessageOptions({ content: null, ephemeral: true }); + expect(result).toEqual({ content: null, ephemeral: true }); + }); + + it('should return null unchanged', () => { + expect(sanitizeMessageOptions(null)).toBe(null); + }); + + it('should return undefined unchanged', () => { + expect(sanitizeMessageOptions(undefined)).toBe(undefined); + }); + + it('should return numbers unchanged', () => { + expect(sanitizeMessageOptions(42)).toBe(42); + }); + + it('should not mutate the original options object', () => { + const original = { content: '@everyone', ephemeral: true }; + const result = sanitizeMessageOptions(original); + expect(original.content).toBe('@everyone'); + expect(result.content).toBe(`@${ZWS}everyone`); + }); + + it('should handle options without content field', () => { + const options = { embeds: [{ title: 'test' }] }; + const result = sanitizeMessageOptions(options); + expect(result).toEqual({ embeds: [{ title: 'test' }] }); + }); + + describe('embed sanitization', () => { + it('should sanitize embed title', () => { + const result = sanitizeMessageOptions({ + embeds: [{ title: '@everyone alert' }], + }); + expect(result.embeds[0].title).toBe(`@${ZWS}everyone alert`); + }); + + it('should sanitize embed description', () => { + const result = sanitizeMessageOptions({ + embeds: [{ description: 'Hey @here check this' }], + }); + expect(result.embeds[0].description).toBe(`Hey @${ZWS}here check this`); + }); + + it('should sanitize embed footer text', () => { + const result = sanitizeMessageOptions({ + embeds: [{ footer: { text: '@everyone was mentioned' } }], + }); + expect(result.embeds[0].footer.text).toBe(`@${ZWS}everyone was mentioned`); + }); + + it('should preserve other footer properties', () => { + const result = sanitizeMessageOptions({ + embeds: [{ footer: { text: '@everyone', icon_url: 'https://example.com/icon.png' } }], + }); + expect(result.embeds[0].footer.icon_url).toBe('https://example.com/icon.png'); + }); + + it('should sanitize embed author name', () => { + const result = sanitizeMessageOptions({ + embeds: [{ author: { name: '@here bot' } }], + }); + expect(result.embeds[0].author.name).toBe(`@${ZWS}here bot`); + }); + + it('should preserve other author properties', () => { + const result = sanitizeMessageOptions({ + embeds: [{ author: { name: '@here', url: 'https://example.com' } }], + }); + expect(result.embeds[0].author.url).toBe('https://example.com'); + }); + + it('should sanitize embed field names and values', () => { + const result = sanitizeMessageOptions({ + embeds: [ + { + fields: [ + { name: '@everyone field', value: '@here value', inline: true }, + { name: 'safe name', value: 'safe value' }, + ], + }, + ], + }); + expect(result.embeds[0].fields[0].name).toBe(`@${ZWS}everyone field`); + expect(result.embeds[0].fields[0].value).toBe(`@${ZWS}here value`); + expect(result.embeds[0].fields[0].inline).toBe(true); + expect(result.embeds[0].fields[1].name).toBe('safe name'); + expect(result.embeds[0].fields[1].value).toBe('safe value'); + }); + + it('should sanitize multiple embeds', () => { + const result = sanitizeMessageOptions({ + embeds: [{ title: '@everyone first' }, { description: '@here second' }], + }); + expect(result.embeds[0].title).toBe(`@${ZWS}everyone first`); + expect(result.embeds[1].description).toBe(`@${ZWS}here second`); + }); + + it('should handle embed with no sanitizable fields', () => { + const result = sanitizeMessageOptions({ + embeds: [{ color: 0xff0000, url: 'https://example.com' }], + }); + expect(result.embeds[0]).toEqual({ color: 0xff0000, url: 'https://example.com' }); + }); + + it('should not mutate the original embeds array', () => { + const original = { + embeds: [{ title: '@everyone', description: '@here' }], + }; + sanitizeMessageOptions(original); + expect(original.embeds[0].title).toBe('@everyone'); + expect(original.embeds[0].description).toBe('@here'); + }); + }); + + describe('component sanitization', () => { + it('should sanitize button labels', () => { + const result = sanitizeMessageOptions({ + components: [ + { + type: 1, + components: [{ type: 2, label: '@everyone click', style: 1 }], + }, + ], + }); + expect(result.components[0].components[0].label).toBe(`@${ZWS}everyone click`); + }); + + it('should sanitize select menu placeholders', () => { + const result = sanitizeMessageOptions({ + components: [ + { + type: 1, + components: [{ type: 3, placeholder: '@here select' }], + }, + ], + }); + expect(result.components[0].components[0].placeholder).toBe(`@${ZWS}here select`); + }); + + it('should sanitize select menu option labels and descriptions', () => { + const result = sanitizeMessageOptions({ + components: [ + { + type: 1, + components: [ + { + type: 3, + options: [ + { label: '@everyone opt', value: 'a', description: '@here desc' }, + { label: 'safe', value: 'b' }, + ], + }, + ], + }, + ], + }); + const opts = result.components[0].components[0].options; + expect(opts[0].label).toBe(`@${ZWS}everyone opt`); + expect(opts[0].description).toBe(`@${ZWS}here desc`); + expect(opts[0].value).toBe('a'); + expect(opts[1].label).toBe('safe'); + }); + + it('should not mutate the original components array', () => { + const original = { + components: [{ type: 1, components: [{ type: 2, label: '@everyone' }] }], + }; + sanitizeMessageOptions(original); + expect(original.components[0].components[0].label).toBe('@everyone'); + }); + }); +}); + +describe('sanitizeMentions edge cases', () => { + describe('email address false positives', () => { + it('should not mutate user@everyone.com', () => { + expect(sanitizeMentions('Contact user@everyone.com for help')).toBe( + 'Contact user@everyone.com for help', + ); + }); + + it('should not mutate admin@here.org', () => { + expect(sanitizeMentions('Email admin@here.org')).toBe('Email admin@here.org'); + }); + + it('should still escape standalone @everyone alongside an email', () => { + expect(sanitizeMentions('user@everyone.com said @everyone look')).toBe( + `user@everyone.com said @${ZWS}everyone look`, + ); + }); + }); + + describe('mentions inside code blocks', () => { + it('should escape @everyone inside inline code (no markdown awareness)', () => { + // sanitizeMentions operates on raw text — it doesn't parse markdown. + // Code blocks are rendered by Discord, not by our sanitizer. + expect(sanitizeMentions('`@everyone`')).toBe(`\`@${ZWS}everyone\``); + }); + + it('should escape @here inside a fenced code block', () => { + const input = '```\n@here\n```'; + expect(sanitizeMentions(input)).toBe(`\`\`\`\n@${ZWS}here\n\`\`\``); + }); + }); + + describe('double-sanitization idempotency', () => { + it('should be idempotent — sanitizing twice produces the same result', () => { + const once = sanitizeMentions('@everyone and @here'); + const twice = sanitizeMentions(once); + expect(twice).toBe(once); + }); + + it('should be idempotent for sanitizeMessageOptions', () => { + const once = sanitizeMessageOptions({ content: '@everyone test' }); + const twice = sanitizeMessageOptions(once); + expect(twice).toEqual(once); + }); + }); + + describe('multiple consecutive mentions', () => { + it('should escape back-to-back @everyone @here', () => { + expect(sanitizeMentions('@everyone @here')).toBe(`@${ZWS}everyone @${ZWS}here`); + }); + + it('should escape three consecutive @everyone mentions', () => { + expect(sanitizeMentions('@everyone @everyone @everyone')).toBe( + `@${ZWS}everyone @${ZWS}everyone @${ZWS}everyone`, + ); + }); + + it('should escape mentions on separate lines', () => { + expect(sanitizeMentions('@everyone\n@here\n@everyone')).toBe( + `@${ZWS}everyone\n@${ZWS}here\n@${ZWS}everyone`, + ); + }); + }); +});