Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
78cd350
feat: hard gate to prevent @here and @everyone mentions (#61)
BillChirico Feb 16, 2026
d95bb30
docs: add safeSend.js and sanitizeMentions.js to AGENTS.md key files
BillChirico Feb 16, 2026
d7b03ee
refactor: migrate all callsites to safeSend wrappers
BillChirico Feb 16, 2026
a741bff
Merge branch 'main' into feat/mention-gate
BillChirico Feb 16, 2026
4fe486c
fix: add .catch on error-path safeEditReply in history command
BillChirico Feb 16, 2026
25341d8
docs: document safeReply compatibility with Message objects
BillChirico Feb 16, 2026
4824494
feat: add splitMessage integration and Winston error logging to safe …
BillChirico Feb 16, 2026
6ba4681
docs: document Discord case-sensitivity for @everyone/@here mentions
BillChirico Feb 16, 2026
ca5985e
fix: update stale 'Mock logger' comments to describe safeSend mocks
BillChirico Feb 16, 2026
a759af2
test: add allowedMentions override, split, and error logging tests
BillChirico Feb 16, 2026
f18bf1e
fix: truncate interaction replies instead of splitting for Discord AP…
BillChirico Feb 16, 2026
06836a5
fix: migrate memory.js to safeSend wrappers — close injection vector
BillChirico Feb 16, 2026
4076bbf
fix: prevent email false positives in mention sanitization regex
BillChirico Feb 16, 2026
2785af5
docs: document intentional role mention blocking and opt-in path
BillChirico Feb 16, 2026
15853d6
fix: only attach embeds/components/files to last chunk when splitting
BillChirico Feb 16, 2026
aec7f53
fix: migrate buttonInteraction.update() calls to safeUpdate wrapper i…
BillChirico Feb 16, 2026
5bb5d52
fix: format safeSend.test.js import for Biome compliance
github-actions[bot] Feb 16, 2026
3893085
refactor: address PR #62 round 6 review comments
BillChirico Feb 16, 2026
efc7332
fix: sanitize embed and component string fields in sanitizeMessageOpt…
BillChirico Feb 16, 2026
893b18e
fix: add global beforeEach to clear mocks in safeSend tests
BillChirico Feb 16, 2026
b10c839
fix: resolve merge conflicts with main — use checkAndRecoverMemory + …
BillChirico Feb 16, 2026
6bb5eb7
fix: biome formatting in sanitizeMentions tests
github-actions[bot] Feb 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
13 changes: 8 additions & 5 deletions src/commands/ban.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
sendModLogEmbed,
shouldSendDm,
} from '../modules/moderation.js';
import { safeEditReply } from '../utils/safeSend.js';

export const data = new SlashCommandBuilder()
.setName('ban')
Expand Down Expand Up @@ -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')) {
Expand All @@ -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(() => {});
}
}
19 changes: 10 additions & 9 deletions src/commands/case.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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.');
}
}

Expand All @@ -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] });
}

/**
Expand Down Expand Up @@ -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) => {
Expand All @@ -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] });
}

/**
Expand All @@ -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];
Expand Down Expand Up @@ -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}.`);
}

/**
Expand All @@ -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', {
Expand All @@ -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}).`);
}
23 changes: 12 additions & 11 deletions src/commands/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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,
});
Expand All @@ -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,
});
Expand Down Expand Up @@ -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 });
}
}
}
Expand All @@ -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 });
}
}
}
7 changes: 4 additions & 3 deletions src/commands/history.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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) => {
Expand Down Expand Up @@ -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(() => {});
}
}
15 changes: 9 additions & 6 deletions src/commands/kick.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
sendModLogEmbed,
shouldSendDm,
} from '../modules/moderation.js';
import { safeEditReply } from '../utils/safeSend.js';

export const data = new SlashCommandBuilder()
.setName('kick')
Expand All @@ -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')) {
Expand All @@ -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(() => {});
}
}
14 changes: 8 additions & 6 deletions src/commands/lock.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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, {
Expand All @@ -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, {
Expand All @@ -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(() => {});
}
}
Loading
Loading