Enhance bot functionality with new features and improvements

- Updated package dependencies to include `@sentry/node` for error tracking.
- Refactored command routing to incorporate user and guild blacklisting checks, improving command execution safety.
- Enhanced health server metrics to include queue waiting times, providing better insights into system performance.
- Introduced confirmation handling for guild backup commands, streamlining user interactions.
- Improved moderation commands with better error handling and case management, ensuring robust moderation capabilities.
- Added new duration parsing functions to handle timeout durations effectively, enhancing moderation features.
- Updated localization files to reflect new command structures and user guidance improvements.
This commit is contained in:
smueller
2026-07-23 09:53:44 +02:00
parent 4e135bcf43
commit ffaa1e26fd
31 changed files with 1831 additions and 363 deletions

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"css.lint.unknownAtRules": "ignore"
}

View File

@@ -14,9 +14,10 @@
"profile:banner": "tsx scripts/set-bot-banner.ts"
},
"dependencies": {
"@nexumi/shared": "workspace:*",
"@napi-rs/canvas": "^0.1.65",
"@nexumi/shared": "workspace:*",
"@prisma/client": "^5.22.0",
"@sentry/node": "^10.67.0",
"bullmq": "^5.34.0",
"discord.js": "^14.17.3",
"dotenv": "^16.4.7",

View File

@@ -9,7 +9,15 @@ import { env } from './env.js';
import { logger } from './logger.js';
import { getGuildLocale } from './i18n.js';
import { evaluateCommandGate, type CommandGateReason } from './command-gates.js';
import {
isGuildBlacklisted,
isModuleEnabled,
isUserBlacklisted,
moduleForCommand
} from './module-gates.js';
import { isMaintenanceMode } from './presence.js';
import { recordCommandMetric } from './metrics.js';
import { captureException } from './sentry.js';
import { moderationCommands } from './modules/moderation/commands.js';
import { automodCommands } from './modules/automod/commands.js';
import { welcomeCommands } from './modules/welcome/commands.js';
@@ -116,7 +124,27 @@ function gateMessage(locale: Locale, reason: CommandGateReason, retryAfterSecond
}
export async function routeCommand(interaction: ChatInputCommandInteraction, context: BotContext) {
const started = Date.now();
let ok = true;
const locale = await getGuildLocale(context.prisma, interaction.guildId);
try {
if (await isUserBlacklisted(context.prisma, interaction.user.id)) {
await interaction.reply({
content: t(locale, 'generic.userBlacklisted'),
ephemeral: true
});
return;
}
if (interaction.guildId && (await isGuildBlacklisted(context.prisma, interaction.guildId))) {
await interaction.reply({
content: t(locale, 'generic.guildBlacklisted'),
ephemeral: true
});
return;
}
const maintenance = await isMaintenanceMode(context);
const ownerIds = env.OWNER_USER_IDS ?? [];
if (maintenance.enabled && !ownerIds.includes(interaction.user.id)) {
@@ -133,6 +161,23 @@ export async function routeCommand(interaction: ChatInputCommandInteraction, con
return;
}
const moduleId = moduleForCommand(interaction.commandName);
if (moduleId && interaction.guildId) {
const enabled = await isModuleEnabled(
context.prisma,
context.redis,
interaction.guildId,
moduleId
);
if (!enabled) {
await interaction.reply({
content: t(locale, 'generic.moduleDisabled'),
ephemeral: true
});
return;
}
}
const gate = await evaluateCommandGate(interaction, context);
if (!gate.ok) {
await interaction.reply({
@@ -143,14 +188,45 @@ export async function routeCommand(interaction: ChatInputCommandInteraction, con
}
await command.execute(interaction, context);
} catch (error) {
ok = false;
captureException(error, {
command: interaction.commandName,
guildId: interaction.guildId,
userId: interaction.user.id
});
throw error;
} finally {
await recordCommandMetric(context.redis, {
ok,
durationMs: Date.now() - started
}).catch(() => undefined);
}
}
export async function routeContextMenu(
interaction: MessageContextMenuCommandInteraction,
context: BotContext
) {
const command = contextMenuMap.get(interaction.commandName);
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (await isUserBlacklisted(context.prisma, interaction.user.id)) {
await interaction.reply({
content: t(locale, 'generic.userBlacklisted'),
ephemeral: true
});
return;
}
if (interaction.guildId && (await isGuildBlacklisted(context.prisma, interaction.guildId))) {
await interaction.reply({
content: t(locale, 'generic.guildBlacklisted'),
ephemeral: true
});
return;
}
const command = contextMenuMap.get(interaction.commandName);
if (!command) {
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });
return;

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import type { Redis } from 'ioredis';
import {
deleteConfirmation,
getConfirmation,
setConfirmation,
takeConfirmation
} from './confirmation-store.js';
function createMemoryRedis(): Redis {
const store = new Map<string, string>();
return {
async set(key: string, value: string) {
store.set(key, value);
return 'OK';
},
async get(key: string) {
return store.get(key) ?? null;
},
async del(key: string) {
store.delete(key);
return 1;
}
} as unknown as Redis;
}
describe('confirmation-store', () => {
it('stores and retrieves pending confirmations', async () => {
const redis = createMemoryRedis();
await setConfirmation(redis, 'moderation', 'tok-1', { action: 'ban', userId: '1' }, 60);
await expect(getConfirmation(redis, 'moderation', 'tok-1')).resolves.toEqual({
action: 'ban',
userId: '1'
});
});
it('takeConfirmation deletes the entry', async () => {
const redis = createMemoryRedis();
await setConfirmation(redis, 'moderation', 'tok-2', { action: 'purge' }, 60);
await expect(takeConfirmation(redis, 'moderation', 'tok-2')).resolves.toEqual({
action: 'purge'
});
await expect(getConfirmation(redis, 'moderation', 'tok-2')).resolves.toBeNull();
});
it('deleteConfirmation removes pending state', async () => {
const redis = createMemoryRedis();
await setConfirmation(redis, 'guildbackup', 'tok-3', { action: 'delete' }, 60);
await deleteConfirmation(redis, 'guildbackup', 'tok-3');
await expect(getConfirmation(redis, 'guildbackup', 'tok-3')).resolves.toBeNull();
});
});

View File

@@ -0,0 +1,57 @@
import type { Redis } from 'ioredis';
function key(namespace: string, token: string): string {
return `confirm:${namespace}:${token}`;
}
export async function setConfirmation<T extends object>(
redis: Redis,
namespace: string,
token: string,
entry: T,
ttlSeconds: number
): Promise<void> {
await redis.set(key(namespace, token), JSON.stringify(entry), 'EX', ttlSeconds);
}
export async function getConfirmation<T>(
redis: Redis,
namespace: string,
token: string
): Promise<T | null> {
const raw = await redis.get(key(namespace, token));
if (!raw) {
return null;
}
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
export async function deleteConfirmation(
redis: Redis,
namespace: string,
token: string
): Promise<void> {
await redis.del(key(namespace, token));
}
export async function takeConfirmation<T>(
redis: Redis,
namespace: string,
token: string
): Promise<T | null> {
const k = key(namespace, token);
const raw = await redis.get(k);
if (!raw) {
return null;
}
await redis.del(k);
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}

View File

@@ -19,6 +19,7 @@ const EnvSchema = z.object({
BACKUP_CRON: z.string().default('0 3 * * *'),
BACKUP_DIR: z.string().default('/backups'),
METRICS_TOKEN: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
SENTRY_DSN: z.preprocess(emptyToUndefined, z.string().url().optional()),
HEALTH_PORT: z.coerce.number().int().positive().default(8080),
PUBLIC_BASE_URL: z.string().url().default('http://localhost:8080'),
TWITCH_CLIENT_ID: z.preprocess(emptyToUndefined, z.string().min(1).optional()),

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { formatPrometheus, type MetricsSnapshot } from './metrics.js';
describe('confirmation custom_id matchers', () => {
it('detects moderation confirm/cancel prefixes', () => {
const isMod = (id: string) => id.startsWith('mod:confirm:') || id.startsWith('mod:cancel:');
expect(isMod('mod:confirm:abc')).toBe(true);
expect(isMod('mod:cancel:abc')).toBe(true);
expect(isMod('other')).toBe(false);
});
it('detects guildbackup confirm/cancel prefixes', () => {
const isBackup = (id: string) =>
id.startsWith('gbackup:confirm:delete:') ||
id.startsWith('gbackup:confirm:restore:') ||
id.startsWith('gbackup:cancel:');
expect(isBackup('gbackup:confirm:delete:abc')).toBe(true);
expect(isBackup('gbackup:confirm:restore:abc')).toBe(true);
expect(isBackup('gbackup:cancel:abc')).toBe(true);
expect(isBackup('mod:confirm:abc')).toBe(false);
});
});
describe('formatPrometheus', () => {
it('emits core counters and shard gauges', () => {
const snapshot: MetricsSnapshot = {
up: 1,
commandsTotal: 10,
commandsErrors: 2,
commandDurationSumMs: 500,
commandDurationCount: 10,
eventsTotal: 100,
shards: [{ id: 0, ping: 42, guilds: 5, updatedAt: Date.now() }],
queueWaiting: 3
};
const body = formatPrometheus(snapshot);
expect(body).toContain('nexumi_up 1');
expect(body).toContain('nexumi_commands_total 10');
expect(body).toContain('nexumi_commands_errors_total 2');
expect(body).toContain('nexumi_guilds{shard="0"} 5');
expect(body).toContain('nexumi_shard_ping_ms{shard="0"} 42');
expect(body).toContain('nexumi_queue_waiting 3');
});
});

View File

@@ -6,7 +6,17 @@ import {
getCaptchaChallenge,
hashCaptchaAnswer
} from './modules/verification/service.js';
import { verificationQueue } from './queues.js';
import {
automodQueue,
backupQueue,
birthdayQueue,
feedsQueue,
presenceQueue,
retentionQueue,
ticketQueue,
verificationQueue
} from './queues.js';
import { collectMetricsSnapshot, formatPrometheus } from './metrics.js';
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
@@ -99,6 +109,19 @@ async function handleCaptchaPost(req: IncomingMessage, res: ServerResponse): Pro
res.end('<html><body style="font-family:sans-serif;background:#111827;color:#f9fafb;display:flex;justify-content:center;align-items:center;min-height:100vh"><div><h1>Verified</h1><p>You can return to Discord.</p></div></body></html>');
}
async function approximateQueueWaiting(): Promise<number> {
const counts = await Promise.all([
automodQueue.getWaitingCount(),
backupQueue.getWaitingCount(),
birthdayQueue.getWaitingCount(),
feedsQueue.getWaitingCount(),
presenceQueue.getWaitingCount(),
retentionQueue.getWaitingCount(),
ticketQueue.getWaitingCount()
]);
return counts.reduce((sum, n) => sum + n, 0);
}
export function startHealthServer(): void {
const server = createServer(async (req, res) => {
if (!req.url) {
@@ -121,8 +144,16 @@ export function startHealthServer(): void {
res.end('Unauthorized');
return;
}
try {
const snapshot = await collectMetricsSnapshot(redis);
snapshot.queueWaiting = await approximateQueueWaiting();
res.statusCode = 200;
res.end('# Prometheus metrics scaffold\nnexumi_up 1\n');
res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
res.end(formatPrometheus(snapshot));
} catch {
res.statusCode = 500;
res.end('metrics error');
}
return;
}

141
apps/bot/src/hierarchy.ts Normal file
View File

@@ -0,0 +1,141 @@
import {
type ChatInputCommandInteraction,
type GuildMember,
type Interaction
} from 'discord.js';
import type { Locale } from '@nexumi/shared';
import { t } from '@nexumi/shared';
export type HierarchyAction = 'ban' | 'kick' | 'timeout' | 'nick';
export type HierarchyOk = { ok: true; member: GuildMember | null };
export type HierarchyDenied = { ok: false };
/**
* Validates that the moderator and bot can act on the target member.
* Replies ephemerally on denial. For bans, `member` may be null if the user
* already left the guild (ban-by-id is still allowed).
*/
export async function assertModerableMember(
interaction: ChatInputCommandInteraction,
targetUserId: string,
locale: Locale,
action: HierarchyAction
): Promise<HierarchyOk | HierarchyDenied> {
const guild = interaction.guild;
if (!guild) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return { ok: false };
}
if (targetUserId === interaction.user.id) {
await interaction.reply({ content: t(locale, 'moderation.hierarchy.self'), ephemeral: true });
return { ok: false };
}
if (targetUserId === guild.ownerId) {
await interaction.reply({ content: t(locale, 'moderation.hierarchy.owner'), ephemeral: true });
return { ok: false };
}
let member: GuildMember | null = null;
try {
member = await guild.members.fetch(targetUserId);
} catch {
if (action === 'ban') {
return { ok: true, member: null };
}
await interaction.reply({ content: t(locale, 'moderation.hierarchy.notMember'), ephemeral: true });
return { ok: false };
}
const moderator =
interaction.member && 'roles' in interaction.member
? (interaction.member as GuildMember)
: await guild.members.fetch(interaction.user.id);
if (guild.ownerId !== moderator.id) {
if (member.roles.highest.position >= moderator.roles.highest.position) {
await interaction.reply({
content: t(locale, 'moderation.hierarchy.moderator'),
ephemeral: true
});
return { ok: false };
}
}
const botOk =
action === 'ban'
? member.bannable
: action === 'kick'
? member.kickable
: action === 'timeout'
? member.moderatable
: member.manageable;
if (!botOk) {
await interaction.reply({
content: t(locale, 'moderation.hierarchy.bot'),
ephemeral: true
});
return { ok: false };
}
return { ok: true, member };
}
async function replyHierarchyForButton(interaction: Interaction, locale: Locale, key: string): Promise<void> {
if (!interaction.isRepliable()) {
return;
}
const content = t(locale, key);
if (interaction.isButton()) {
await interaction.update({ content, components: [] });
return;
}
if (interaction.replied || interaction.deferred) {
await interaction.followUp({ content, ephemeral: true });
} else {
await interaction.reply({ content, ephemeral: true });
}
}
export async function assertBannableForConfirm(
interaction: Interaction,
locale: Locale,
targetUserId: string
): Promise<boolean> {
const guild = interaction.guild;
if (!guild) {
await replyHierarchyForButton(interaction, locale, 'generic.guildOnly');
return false;
}
if (targetUserId === interaction.user.id) {
await replyHierarchyForButton(interaction, locale, 'moderation.hierarchy.self');
return false;
}
if (targetUserId === guild.ownerId) {
await replyHierarchyForButton(interaction, locale, 'moderation.hierarchy.owner');
return false;
}
try {
const member = await guild.members.fetch(targetUserId);
if (!member.bannable) {
await replyHierarchyForButton(interaction, locale, 'moderation.hierarchy.bot');
return false;
}
const moderator = await guild.members.fetch(interaction.user.id);
if (
guild.ownerId !== moderator.id &&
member.roles.highest.position >= moderator.roles.highest.position
) {
await replyHierarchyForButton(interaction, locale, 'moderation.hierarchy.moderator');
return false;
}
} catch {
// Target left the guild — ban by id is still fine.
}
return true;
}

View File

@@ -14,64 +14,30 @@ import { registerCommands, routeCommand, routeContextMenu } from './commands.js'
import { ensureRecurringJobs, startWorkers } from './jobs.js';
import type { BotContext } from './types.js';
import { startHealthServer } from './health.js';
import {
handleModerationConfirmation,
isModerationConfirmation
} from './modules/moderation/confirmations.js';
import { getGuildLocale } from './i18n.js';
import { t } from '@nexumi/shared';
import { handleAutoModMessage } from './modules/automod/actions.js';
import { registerLoggingEvents } from './modules/logging/events.js';
import { registerWelcomeEvents } from './modules/welcome/events.js';
import { registerVerificationEvents } from './modules/verification/events.js';
import { handleVerificationButton } from './modules/verification/handlers.js';
import { isVerificationButton } from './modules/verification/service.js';
import { registerLevelingEvents } from './modules/leveling/events.js';
import {
handleBlackjackButton,
isBlackjackButton
} from './modules/economy/blackjack-buttons.js';
import {
handleEmbedModal,
handlePollButton,
isEmbedModal,
isPollButton,
registerUtilityEvents
} from './modules/utility/index.js';
import { handleFunButton, isFunButton } from './modules/fun/index.js';
import { handleGiveawayButton, isGiveawayButton } from './modules/giveaways/index.js';
import {
handleTicketInteraction,
isTicketInteraction,
registerTicketEvents
} from './modules/tickets/index.js';
import {
handleSelfRoleInteraction,
isSelfRoleInteraction,
registerSelfRoleEvents
} from './modules/selfroles/index.js';
import { registerUtilityEvents } from './modules/utility/index.js';
import { registerTicketEvents } from './modules/tickets/index.js';
import { registerSelfRoleEvents } from './modules/selfroles/index.js';
import { registerTagEvents } from './modules/tags/index.js';
import { registerStarboardEvents } from './modules/starboard/index.js';
import {
handleSuggestionButton,
isSuggestionButton
} from './modules/suggestions/index.js';
import {
handleTempVoiceInteraction,
isTempVoiceInteraction,
isTempVoiceModal,
registerTempVoiceEvents
} from './modules/tempvoice/index.js';
import { registerTempVoiceEvents } from './modules/tempvoice/index.js';
import { registerStatsEvents } from './modules/stats/index.js';
import { handleGuildBackupButton, isGuildBackupButton } from './modules/guildbackup/index.js';
import {
handleComponentsV2Interaction,
isComponentsV2Interaction
} from './modules/components-v2/index.js';
import { routeComponentInteraction } from './interaction-registry.js';
import { isPrimaryShard, runOncePerCluster } from './shard-lifecycle.js';
import { isGuildBlacklisted, isUserBlacklisted } from './module-gates.js';
import { publishShardMetrics, recordEventMetric } from './metrics.js';
import { captureException, initSentry } from './sentry.js';
const isShard = process.argv.includes('--shard');
if (!isShard) {
initSentry('bot-manager');
startHealthServer();
const manager = new ShardingManager(new URL('./index.js', import.meta.url).pathname, {
token: env.BOT_TOKEN,
@@ -79,8 +45,25 @@ if (!isShard) {
shardArgs: ['--shard']
});
manager.on('shardCreate', (shard) => logger.info({ shardId: shard.id }, 'Shard spawned'));
// Register slash commands once from the manager (not once per shard).
try {
await runOncePerCluster(
'nexumi:lock:register-commands',
300,
async () => {
await registerCommands();
},
'Command registration'
);
} catch (error) {
logger.error({ error }, 'Failed to register application commands from manager');
captureException(error, { phase: 'registerCommands' });
}
await manager.spawn();
} else {
initSentry('bot-shard');
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
@@ -103,7 +86,7 @@ if (!isShard) {
const context: BotContext = { client, prisma, redis };
startWorkers(context);
await ensureRecurringJobs();
registerLoggingEvents(context);
registerWelcomeEvents(context);
registerVerificationEvents(context);
@@ -117,12 +100,24 @@ if (!isShard) {
registerStatsEvents(context);
client.on(Events.ClientReady, async () => {
logger.info({ tag: client.user?.tag }, 'Bot ready');
logger.info({ tag: client.user?.tag, shards: client.shard?.ids }, 'Bot ready');
if (isPrimaryShard(client)) {
try {
await registerCommands();
await runOncePerCluster(
'nexumi:lock:ensure-recurring-jobs',
120,
async () => {
await ensureRecurringJobs();
},
'Recurring job registration'
);
} catch (error) {
logger.error({ error }, 'Failed to register application commands');
logger.error({ error }, 'Failed to ensure recurring jobs');
captureException(error, { phase: 'ensureRecurringJobs' });
}
}
try {
const { applyBotPresence, publishBotStatus } = await import('./presence.js');
await applyBotPresence(context);
@@ -130,18 +125,61 @@ if (!isShard) {
} catch (error) {
logger.warn({ error }, 'Failed to apply initial bot presence/status');
}
try {
await publishShardMetrics(context.redis, client);
} catch (error) {
logger.warn({ error }, 'Failed to publish initial shard metrics');
}
});
client.on(Events.GuildCreate, async (guild) => {
try {
if (await isGuildBlacklisted(context.prisma, guild.id)) {
logger.info({ guildId: guild.id }, 'Leaving blacklisted guild');
await guild.leave();
}
} catch (error) {
logger.error({ error, guildId: guild.id }, 'GuildCreate blacklist check failed');
captureException(error, { guildId: guild.id });
}
});
setInterval(() => {
publishShardMetrics(context.redis, client).catch(() => undefined);
}, 30_000);
client.on(Events.MessageCreate, async (message) => {
try {
await recordEventMetric(context.redis);
if (message.author.bot) return;
if (await isUserBlacklisted(context.prisma, message.author.id)) return;
if (message.guildId && (await isGuildBlacklisted(context.prisma, message.guildId))) return;
await handleAutoModMessage(context, message);
} catch (error) {
logger.error({ error, messageId: message.id }, 'AutoMod message handler failed');
captureException(error, { messageId: message.id });
}
});
client.on(Events.InteractionCreate, async (interaction: Interaction) => {
try {
if (
interaction.user &&
(await isUserBlacklisted(context.prisma, interaction.user.id)) &&
!interaction.isChatInputCommand() &&
!interaction.isMessageContextMenuCommand()
) {
if (interaction.isRepliable() && !interaction.replied && !interaction.deferred) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
await interaction.reply({
content: t(locale, 'generic.userBlacklisted'),
ephemeral: true
});
}
return;
}
if (interaction.isChatInputCommand()) {
await routeCommand(interaction, context);
return;
@@ -152,93 +190,23 @@ if (!isShard) {
return;
}
if (interaction.isButton() && isModerationConfirmation(interaction.customId)) {
await handleModerationConfirmation(interaction, context);
return;
}
if (interaction.isButton() && isVerificationButton(interaction.customId)) {
await handleVerificationButton(interaction, context);
return;
}
if (interaction.isButton() && isBlackjackButton(interaction.customId)) {
await handleBlackjackButton(interaction, context);
return;
}
if (interaction.isButton() && isPollButton(interaction.customId)) {
await handlePollButton(interaction, context);
return;
}
if (interaction.isButton() && isFunButton(interaction.customId)) {
await handleFunButton(interaction, context);
return;
}
if (interaction.isButton() && isGiveawayButton(interaction.customId)) {
await handleGiveawayButton(interaction, context);
return;
}
if (
(interaction.isButton() ||
interaction.isStringSelectMenu() ||
interaction.isModalSubmit()) &&
isTicketInteraction(interaction.customId)
) {
await handleTicketInteraction(interaction, context);
return;
}
if (
(interaction.isButton() || interaction.isStringSelectMenu()) &&
isSelfRoleInteraction(interaction.customId)
) {
await handleSelfRoleInteraction(interaction, context);
return;
}
if (interaction.isButton() && isSuggestionButton(interaction.customId)) {
await handleSuggestionButton(interaction, context);
return;
}
if (interaction.isButton() && isTempVoiceInteraction(interaction.customId)) {
await handleTempVoiceInteraction(interaction, context);
return;
}
if (interaction.isModalSubmit() && isTempVoiceModal(interaction.customId)) {
await handleTempVoiceInteraction(interaction, context);
return;
}
if (interaction.isButton() && isGuildBackupButton(interaction.customId)) {
await handleGuildBackupButton(interaction, context);
return;
}
if (interaction.isModalSubmit() && isEmbedModal(interaction.customId)) {
await handleEmbedModal(interaction, context);
return;
}
if (
(interaction.isButton() ||
interaction.isButton() ||
interaction.isStringSelectMenu() ||
interaction.isUserSelectMenu() ||
interaction.isRoleSelectMenu() ||
interaction.isChannelSelectMenu() ||
interaction.isMentionableSelectMenu()) &&
isComponentsV2Interaction(interaction.customId)
interaction.isMentionableSelectMenu() ||
interaction.isModalSubmit()
) {
await handleComponentsV2Interaction(interaction, context);
return;
await routeComponentInteraction(interaction, context);
}
} catch (error) {
logger.error({ error }, 'Interaction handling failed');
captureException(error, {
interactionType: interaction.type,
guildId: interaction.guildId
});
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const message = t(locale, 'generic.error');
if (interaction.isRepliable()) {

View File

@@ -0,0 +1,154 @@
import type {
ButtonInteraction,
ChannelSelectMenuInteraction,
MentionableSelectMenuInteraction,
ModalSubmitInteraction,
RoleSelectMenuInteraction,
StringSelectMenuInteraction,
UserSelectMenuInteraction
} from 'discord.js';
import type { BotContext } from './types.js';
import {
handleModerationConfirmation,
isModerationConfirmation
} from './modules/moderation/confirmations.js';
import { handleVerificationButton } from './modules/verification/handlers.js';
import { isVerificationButton } from './modules/verification/service.js';
import {
handleBlackjackButton,
isBlackjackButton
} from './modules/economy/blackjack-buttons.js';
import {
handleEmbedModal,
handlePollButton,
isEmbedModal,
isPollButton
} from './modules/utility/index.js';
import { handleFunButton, isFunButton } from './modules/fun/index.js';
import { handleGiveawayButton, isGiveawayButton } from './modules/giveaways/index.js';
import { handleTicketInteraction, isTicketInteraction } from './modules/tickets/index.js';
import {
handleSelfRoleInteraction,
isSelfRoleInteraction
} from './modules/selfroles/index.js';
import {
handleSuggestionButton,
isSuggestionButton
} from './modules/suggestions/index.js';
import {
handleTempVoiceInteraction,
isTempVoiceInteraction,
isTempVoiceModal
} from './modules/tempvoice/index.js';
import { handleGuildBackupButton, isGuildBackupButton } from './modules/guildbackup/index.js';
import {
handleComponentsV2Interaction,
isComponentsV2Interaction
} from './modules/components-v2/index.js';
type AnyComponentInteraction =
| ButtonInteraction
| StringSelectMenuInteraction
| UserSelectMenuInteraction
| RoleSelectMenuInteraction
| ChannelSelectMenuInteraction
| MentionableSelectMenuInteraction
| ModalSubmitInteraction;
type InteractionHandler = {
match: (customId: string) => boolean;
handle: (interaction: AnyComponentInteraction, context: BotContext) => Promise<void>;
};
function asButton(interaction: AnyComponentInteraction): ButtonInteraction {
return interaction as ButtonInteraction;
}
function asModal(interaction: AnyComponentInteraction): ModalSubmitInteraction {
return interaction as ModalSubmitInteraction;
}
const handlers: InteractionHandler[] = [
{
match: isModerationConfirmation,
handle: (interaction, context) => handleModerationConfirmation(asButton(interaction), context)
},
{
match: isVerificationButton,
handle: (interaction, context) => handleVerificationButton(asButton(interaction), context)
},
{
match: isBlackjackButton,
handle: (interaction, context) => handleBlackjackButton(asButton(interaction), context)
},
{
match: isPollButton,
handle: (interaction, context) => handlePollButton(asButton(interaction), context)
},
{
match: isFunButton,
handle: (interaction, context) => handleFunButton(asButton(interaction), context)
},
{
match: isGiveawayButton,
handle: (interaction, context) => handleGiveawayButton(asButton(interaction), context)
},
{
match: isTicketInteraction,
handle: (interaction, context) =>
handleTicketInteraction(
interaction as Parameters<typeof handleTicketInteraction>[0],
context
)
},
{
match: isSelfRoleInteraction,
handle: (interaction, context) =>
handleSelfRoleInteraction(
interaction as Parameters<typeof handleSelfRoleInteraction>[0],
context
)
},
{
match: isSuggestionButton,
handle: (interaction, context) => handleSuggestionButton(asButton(interaction), context)
},
{
match: (customId) => isTempVoiceInteraction(customId) || isTempVoiceModal(customId),
handle: (interaction, context) =>
handleTempVoiceInteraction(
interaction as Parameters<typeof handleTempVoiceInteraction>[0],
context
)
},
{
match: isGuildBackupButton,
handle: (interaction, context) => handleGuildBackupButton(asButton(interaction), context)
},
{
match: isEmbedModal,
handle: (interaction, context) => handleEmbedModal(asModal(interaction), context)
},
{
match: isComponentsV2Interaction,
handle: (interaction, context) =>
handleComponentsV2Interaction(
interaction as Parameters<typeof handleComponentsV2Interaction>[0],
context
)
}
];
export async function routeComponentInteraction(
interaction: AnyComponentInteraction,
context: BotContext
): Promise<boolean> {
const customId = interaction.customId;
for (const handler of handlers) {
if (handler.match(customId)) {
await handler.handle(interaction, context);
return true;
}
}
return false;
}

View File

@@ -119,12 +119,70 @@ export function startWorkers(context: BotContext): Worker[] {
return;
}
const guild = await context.client.guilds.fetch(job.data.guildId);
const { guildId, userId, reason } = job.data;
const guild = context.client.guilds.cache.get(guildId);
if (!guild) {
if (!context.client.shard) {
throw new Error(`Guild ${guildId} not available for temp ban expiration`);
}
const results = await context.client.shard.broadcastEval(
async (c, payload) => {
const g = c.guilds.cache.get(payload.guildId);
if (!g) {
return false;
}
const me = await g.members.fetchMe();
if (!me.permissions.has(PermissionFlagsBits.BanMembers)) {
throw new Error('Missing BanMembers permission for temp ban expiration');
}
try {
await g.members.unban(payload.userId, payload.reason ?? 'Temporary ban expired');
} catch (error) {
const code =
typeof error === 'object' && error && 'code' in error
? Number((error as { code: unknown }).code)
: 0;
// Unknown ban (10026) — already unbanned
if (code === 10026) {
return true;
}
throw error;
}
return true;
},
{ context: { guildId, userId, reason } }
);
if (!results.some(Boolean)) {
throw new Error(`Guild ${guildId} not found on any shard for temp ban expiration`);
}
} else {
const me = await guild.members.fetchMe();
if (!me.permissions.has(PermissionFlagsBits.BanMembers)) {
throw new Error('Missing BanMembers permission for temp ban expiration');
}
await guild.members.unban(job.data.userId, job.data.reason ?? 'Temporary ban expired');
try {
await guild.members.unban(userId, reason ?? 'Temporary ban expired');
} catch (error) {
const code =
typeof error === 'object' && error && 'code' in error
? Number((error as { code: unknown }).code)
: 0;
if (code !== 10026) {
throw error;
}
}
}
const { createCase } = await import('./modules/moderation/service.js');
await createCase(context, {
guildId,
targetUserId: userId,
moderatorId: context.client.user?.id ?? 'system',
action: 'UNBAN',
reason: reason ?? 'Temporary ban expired',
source: 'job',
metadata: { tempBanExpire: true }
});
},
{ connection: redis }
);

129
apps/bot/src/metrics.ts Normal file
View File

@@ -0,0 +1,129 @@
import type { Redis } from 'ioredis';
import type { Client } from 'discord.js';
const KEY_COMMANDS_TOTAL = 'nexumi:metrics:commands_total';
const KEY_COMMANDS_ERRORS = 'nexumi:metrics:commands_errors';
const KEY_COMMAND_DURATION_SUM = 'nexumi:metrics:command_duration_ms_sum';
const KEY_COMMAND_DURATION_COUNT = 'nexumi:metrics:command_duration_ms_count';
const KEY_EVENTS_TOTAL = 'nexumi:metrics:events_total';
const shardKey = (id: number) => `nexumi:metrics:shard:${id}`;
export async function recordCommandMetric(
redis: Redis,
opts: { ok: boolean; durationMs: number }
): Promise<void> {
const multi = redis.multi();
multi.incr(KEY_COMMANDS_TOTAL);
if (!opts.ok) {
multi.incr(KEY_COMMANDS_ERRORS);
}
multi.incrbyfloat(KEY_COMMAND_DURATION_SUM, opts.durationMs);
multi.incr(KEY_COMMAND_DURATION_COUNT);
await multi.exec();
}
export async function recordEventMetric(redis: Redis): Promise<void> {
await redis.incr(KEY_EVENTS_TOTAL);
}
export async function publishShardMetrics(redis: Redis, client: Client): Promise<void> {
const ids = client.shard?.ids ?? [0];
const ping = client.ws.ping;
const guilds = client.guilds.cache.size;
const payload = JSON.stringify({
ping,
guilds,
updatedAt: Date.now()
});
await Promise.all(ids.map((id) => redis.set(shardKey(id), payload, 'EX', 120)));
}
export type MetricsSnapshot = {
up: 1;
commandsTotal: number;
commandsErrors: number;
commandDurationSumMs: number;
commandDurationCount: number;
eventsTotal: number;
shards: Array<{ id: number; ping: number; guilds: number; updatedAt: number }>;
queueWaiting?: number;
};
export async function collectMetricsSnapshot(redis: Redis): Promise<MetricsSnapshot> {
const [commandsTotal, commandsErrors, durationSum, durationCount, eventsTotal, shardKeys] =
await Promise.all([
redis.get(KEY_COMMANDS_TOTAL),
redis.get(KEY_COMMANDS_ERRORS),
redis.get(KEY_COMMAND_DURATION_SUM),
redis.get(KEY_COMMAND_DURATION_COUNT),
redis.get(KEY_EVENTS_TOTAL),
redis.keys('nexumi:metrics:shard:*')
]);
const shards: MetricsSnapshot['shards'] = [];
if (shardKeys.length > 0) {
const values = await redis.mget(...shardKeys);
for (let i = 0; i < shardKeys.length; i++) {
const id = Number(shardKeys[i]!.replace('nexumi:metrics:shard:', ''));
const raw = values[i];
if (!raw || Number.isNaN(id)) continue;
try {
const parsed = JSON.parse(raw) as { ping: number; guilds: number; updatedAt: number };
shards.push({ id, ping: parsed.ping, guilds: parsed.guilds, updatedAt: parsed.updatedAt });
} catch {
// ignore malformed
}
}
shards.sort((a, b) => a.id - b.id);
}
return {
up: 1,
commandsTotal: Number(commandsTotal ?? 0),
commandsErrors: Number(commandsErrors ?? 0),
commandDurationSumMs: Number(durationSum ?? 0),
commandDurationCount: Number(durationCount ?? 0),
eventsTotal: Number(eventsTotal ?? 0),
shards
};
}
export function formatPrometheus(snapshot: MetricsSnapshot): string {
const lines: string[] = [
'# HELP nexumi_up 1 if the metrics endpoint is healthy',
'# TYPE nexumi_up gauge',
`nexumi_up ${snapshot.up}`,
'# HELP nexumi_commands_total Total slash commands handled',
'# TYPE nexumi_commands_total counter',
`nexumi_commands_total ${snapshot.commandsTotal}`,
'# HELP nexumi_commands_errors_total Total slash command failures',
'# TYPE nexumi_commands_errors_total counter',
`nexumi_commands_errors_total ${snapshot.commandsErrors}`,
'# HELP nexumi_command_duration_ms_sum Sum of command durations in milliseconds',
'# TYPE nexumi_command_duration_ms_sum counter',
`nexumi_command_duration_ms_sum ${snapshot.commandDurationSumMs}`,
'# HELP nexumi_command_duration_ms_count Count of timed commands',
'# TYPE nexumi_command_duration_ms_count counter',
`nexumi_command_duration_ms_count ${snapshot.commandDurationCount}`,
'# HELP nexumi_events_total Approximate event throughput counter',
'# TYPE nexumi_events_total counter',
`nexumi_events_total ${snapshot.eventsTotal}`,
'# HELP nexumi_guilds Guild count per shard',
'# TYPE nexumi_guilds gauge',
'# HELP nexumi_shard_ping_ms Websocket heartbeat latency per shard',
'# TYPE nexumi_shard_ping_ms gauge'
];
for (const shard of snapshot.shards) {
lines.push(`nexumi_guilds{shard="${shard.id}"} ${shard.guilds}`);
lines.push(`nexumi_shard_ping_ms{shard="${shard.id}"} ${shard.ping}`);
}
if (snapshot.queueWaiting !== undefined) {
lines.push('# HELP nexumi_queue_waiting Approximate waiting jobs across known queues');
lines.push('# TYPE nexumi_queue_waiting gauge');
lines.push(`nexumi_queue_waiting ${snapshot.queueWaiting}`);
}
return `${lines.join('\n')}\n`;
}

View File

@@ -0,0 +1,33 @@
import { createHash } from 'node:crypto';
import { describe, expect, it } from 'vitest';
import { moduleForCommand } from './module-gates.js';
describe('moduleForCommand', () => {
it('maps moderation commands', () => {
expect(moduleForCommand('ban')).toBe('moderation');
expect(moduleForCommand('warn')).toBe('moderation');
expect(moduleForCommand('case')).toBe('moderation');
});
it('maps redis-only and prisma modules', () => {
expect(moduleForCommand('giveaway')).toBe('giveaways');
expect(moduleForCommand('tag')).toBe('tags');
expect(moduleForCommand('backup')).toBe('guildbackup');
expect(moduleForCommand('economy' as string)).toBeNull();
expect(moduleForCommand('balance')).toBe('economy');
});
it('leaves core commands unmapped', () => {
expect(moduleForCommand('help')).toBeNull();
expect(moduleForCommand('info')).toBeNull();
expect(moduleForCommand('gdpr')).toBeNull();
});
});
describe('feature flag rollout hashing', () => {
it('is stable for the same guild/key pair', () => {
const a = createHash('sha256').update('moderation:guild-1').digest().readUInt32BE(0) % 100;
const b = createHash('sha256').update('moderation:guild-1').digest().readUInt32BE(0) % 100;
expect(a).toBe(b);
});
});

View File

@@ -0,0 +1,275 @@
import type { DashboardModuleId } from '@nexumi/shared';
import type { PrismaClient } from '@prisma/client';
import type { Redis } from 'ioredis';
import { createHash } from 'node:crypto';
const REDIS_ONLY_MODULES = new Set<DashboardModuleId>([
'giveaways',
'tags',
'selfroles',
'feeds',
'scheduler',
'guildbackup',
'messages'
]);
/** Slash command name → dashboard module. Core/GDPR/utility (except gated) omit. */
const COMMAND_MODULE_MAP: Record<string, DashboardModuleId> = {
ban: 'moderation',
unban: 'moderation',
kick: 'moderation',
timeout: 'moderation',
untimeout: 'moderation',
warn: 'moderation',
purge: 'moderation',
slowmode: 'moderation',
lock: 'moderation',
unlock: 'moderation',
nick: 'moderation',
case: 'moderation',
modnote: 'moderation',
automod: 'automod',
welcome: 'welcome',
verify: 'verification',
rank: 'leveling',
leaderboard: 'leveling',
xp: 'leveling',
balance: 'economy',
daily: 'economy',
weekly: 'economy',
work: 'economy',
pay: 'economy',
gamble: 'economy',
slots: 'economy',
blackjack: 'economy',
coinflip: 'economy',
shop: 'economy',
inventory: 'economy',
eco: 'economy',
'8ball': 'fun',
dice: 'fun',
rps: 'fun',
choose: 'fun',
trivia: 'fun',
tictactoe: 'fun',
connect4: 'fun',
hangman: 'fun',
meme: 'fun',
cat: 'fun',
dog: 'fun',
giveaway: 'giveaways',
ticket: 'tickets',
selfroles: 'selfroles',
tag: 'tags',
starboard: 'starboard',
suggest: 'suggestions',
suggestion: 'suggestions',
birthday: 'birthdays',
voice: 'tempvoice',
invites: 'stats',
stats: 'stats',
feeds: 'feeds',
schedule: 'scheduler',
announce: 'scheduler',
backup: 'guildbackup',
embed: 'messages'
};
function redisModulesKey(guildId: string): string {
return `dashboard:modules:${guildId}`;
}
function funConfigKey(guildId: string): string {
return `fun:config:${guildId}`;
}
function hashPercent(input: string): number {
const digest = createHash('sha256').update(input).digest();
return digest.readUInt32BE(0) % 100;
}
export function moduleForCommand(commandName: string): DashboardModuleId | null {
return COMMAND_MODULE_MAP[commandName] ?? null;
}
export async function isFeatureFlagEnabled(
prisma: PrismaClient,
key: string,
guildId: string | null
): Promise<boolean> {
const flag = await prisma.featureFlag.findUnique({ where: { key } });
if (!flag) {
return true;
}
if (guildId && flag.whitelistGuildIds.includes(guildId)) {
return true;
}
if (!flag.enabled) {
return false;
}
if (flag.rolloutPercent >= 100) {
return true;
}
if (flag.rolloutPercent <= 0 || !guildId) {
return false;
}
return hashPercent(`${flag.key}:${guildId}`) < flag.rolloutPercent;
}
async function isRedisModuleEnabled(
redis: Redis,
guildId: string,
moduleId: DashboardModuleId
): Promise<boolean> {
const value = await redis.hget(redisModulesKey(guildId), moduleId);
if (value === null) {
return true;
}
return value === 'true';
}
async function isFunEnabled(redis: Redis, guildId: string): Promise<boolean> {
const raw = await redis.get(funConfigKey(guildId));
if (!raw) {
return true;
}
try {
const parsed = JSON.parse(raw) as { enabled?: boolean };
return parsed.enabled ?? true;
} catch {
return true;
}
}
export async function isModuleEnabled(
prisma: PrismaClient,
redis: Redis,
guildId: string,
moduleId: DashboardModuleId
): Promise<boolean> {
const flagOk = await isFeatureFlagEnabled(prisma, moduleId, guildId);
if (!flagOk) {
return false;
}
if (REDIS_ONLY_MODULES.has(moduleId)) {
return isRedisModuleEnabled(redis, guildId, moduleId);
}
switch (moduleId) {
case 'moderation': {
const settings = await prisma.guildSettings.findUnique({
where: { guildId },
select: { moderationEnabled: true }
});
return settings?.moderationEnabled ?? true;
}
case 'automod': {
const config = await prisma.autoModConfig.findUnique({
where: { guildId },
select: { enabled: true }
});
return config?.enabled ?? true;
}
case 'logging': {
const config = await prisma.loggingConfig.findUnique({
where: { guildId },
select: { enabled: true }
});
return config?.enabled ?? true;
}
case 'welcome': {
const config = await prisma.welcomeConfig.findUnique({
where: { guildId },
select: { welcomeEnabled: true, leaveEnabled: true }
});
if (!config) {
return true;
}
return Boolean(config.welcomeEnabled || config.leaveEnabled);
}
case 'verification': {
const config = await prisma.verificationConfig.findUnique({
where: { guildId },
select: { enabled: true }
});
return config?.enabled ?? false;
}
case 'leveling': {
const config = await prisma.levelingConfig.findUnique({
where: { guildId },
select: { enabled: true }
});
return config?.enabled ?? true;
}
case 'economy': {
const config = await prisma.economyConfig.findUnique({
where: { guildId },
select: { enabled: true }
});
return config?.enabled ?? true;
}
case 'fun':
return isFunEnabled(redis, guildId);
case 'tickets': {
const config = await prisma.ticketConfig.findUnique({
where: { guildId },
select: { enabled: true }
});
return config?.enabled ?? true;
}
case 'starboard': {
const config = await prisma.starboardConfig.findUnique({
where: { guildId },
select: { enabled: true }
});
return config?.enabled ?? false;
}
case 'suggestions': {
const config = await prisma.suggestionConfig.findUnique({
where: { guildId },
select: { enabled: true }
});
return config?.enabled ?? true;
}
case 'birthdays': {
const config = await prisma.birthdayConfig.findUnique({
where: { guildId },
select: { enabled: true }
});
return config?.enabled ?? true;
}
case 'tempvoice': {
const config = await prisma.tempVoiceConfig.findUnique({
where: { guildId },
select: { enabled: true }
});
return config?.enabled ?? false;
}
case 'stats': {
const config = await prisma.statsConfig.findUnique({
where: { guildId },
select: { enabled: true }
});
return config?.enabled ?? false;
}
default:
return true;
}
}
export async function isUserBlacklisted(prisma: PrismaClient, userId: string): Promise<boolean> {
const row = await prisma.globalUserBlacklist.findUnique({
where: { userId },
select: { id: true }
});
return Boolean(row);
}
export async function isGuildBlacklisted(prisma: PrismaClient, guildId: string): Promise<boolean> {
const row = await prisma.guildBlacklist.findUnique({
where: { guildId },
select: { id: true }
});
return Boolean(row);
}

View File

@@ -154,7 +154,7 @@ const backupCommand: SlashCommand = {
return;
}
await promptDeleteConfirmation(interaction, locale, backupId);
await promptDeleteConfirmation(interaction, context, locale, backupId);
return;
}
@@ -195,7 +195,7 @@ const backupCommand: SlashCommand = {
return;
}
await promptRestoreConfirmation(interaction, locale, backupId);
await promptRestoreConfirmation(interaction, context, locale, backupId);
}
}
};

View File

@@ -9,6 +9,12 @@ import { randomUUID } from 'node:crypto';
import { type Locale, t, tf } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import {
deleteConfirmation,
getConfirmation,
setConfirmation,
takeConfirmation
} from '../../confirmation-store.js';
import {
deleteGuildBackup,
enqueueGuildBackupRestore,
@@ -22,7 +28,8 @@ import {
const DELETE_CONFIRM_PREFIX = 'gbackup:confirm:delete:';
const RESTORE_CONFIRM_PREFIX = 'gbackup:confirm:restore:';
const CANCEL_PREFIX = 'gbackup:cancel:';
const TTL_MS = 120_000;
const NAMESPACE = 'guildbackup';
const TTL_SECONDS = 120;
type PendingDelete = {
action: 'delete';
@@ -30,7 +37,6 @@ type PendingDelete = {
userId: string;
guildId: string;
locale: Locale;
expiresAt: number;
};
type PendingRestore = {
@@ -40,22 +46,10 @@ type PendingRestore = {
userId: string;
guildId: string;
locale: Locale;
expiresAt: number;
};
type PendingEntry = PendingDelete | PendingRestore;
const pending = new Map<string, PendingEntry>();
function pruneExpired(): void {
const now = Date.now();
for (const [token, entry] of pending) {
if (entry.expiresAt <= now) {
pending.delete(token);
}
}
}
function buildRows(token: string, locale: Locale, confirmPrefix: string): ActionRowBuilder<ButtonBuilder>[] {
return [
new ActionRowBuilder<ButtonBuilder>().addComponents(
@@ -73,19 +67,24 @@ function buildRows(token: string, locale: Locale, confirmPrefix: string): Action
export async function promptDeleteConfirmation(
interaction: ChatInputCommandInteraction,
context: BotContext,
locale: Locale,
backupId: string
): Promise<void> {
pruneExpired();
const token = randomUUID();
pending.set(token, {
await setConfirmation(
context.redis,
NAMESPACE,
token,
{
action: 'delete',
backupId,
userId: interaction.user.id,
guildId: interaction.guildId!,
locale,
expiresAt: Date.now() + TTL_MS
});
locale
} satisfies PendingDelete,
TTL_SECONDS
);
await interaction.reply({
content: tf(locale, 'guildbackup.confirm.delete', { id: backupId }),
@@ -96,20 +95,25 @@ export async function promptDeleteConfirmation(
export async function promptRestoreConfirmation(
interaction: ChatInputCommandInteraction,
context: BotContext,
locale: Locale,
backupId: string
): Promise<void> {
pruneExpired();
const token = randomUUID();
pending.set(token, {
await setConfirmation(
context.redis,
NAMESPACE,
token,
{
action: 'restore',
step: 1,
backupId,
userId: interaction.user.id,
guildId: interaction.guildId!,
locale,
expiresAt: Date.now() + TTL_MS
});
locale
} satisfies PendingRestore,
TTL_SECONDS
);
await interaction.reply({
content: tf(locale, 'guildbackup.confirm.restore.step1', { id: backupId }),
@@ -122,8 +126,6 @@ export async function handleGuildBackupButton(
interaction: ButtonInteraction,
context: BotContext
): Promise<void> {
pruneExpired();
const customId = interaction.customId;
const isDeleteConfirm = customId.startsWith(DELETE_CONFIRM_PREFIX);
const isRestoreConfirm = customId.startsWith(RESTORE_CONFIRM_PREFIX);
@@ -141,48 +143,57 @@ export async function handleGuildBackupButton(
: CANCEL_PREFIX.length
);
const entry = pending.get(token);
if (!entry) {
const peeked = await getConfirmation<PendingEntry>(context.redis, NAMESPACE, token);
if (!peeked) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
await interaction.reply({ content: t(locale, 'guildbackup.confirm.expired'), ephemeral: true });
return;
}
if (interaction.user.id !== entry.userId) {
if (interaction.user.id !== peeked.userId) {
await interaction.reply({
content: t(entry.locale, 'guildbackup.confirm.unauthorized'),
content: t(peeked.locale, 'guildbackup.confirm.unauthorized'),
ephemeral: true
});
return;
}
if (isCancel) {
pending.delete(token);
await deleteConfirmation(context.redis, NAMESPACE, token);
await interaction.update({
content: t(entry.locale, 'guildbackup.confirm.cancelled'),
content: t(peeked.locale, 'guildbackup.confirm.cancelled'),
components: []
});
return;
}
if (entry.action === 'delete') {
pending.delete(token);
if (peeked.action === 'delete') {
const entry = await takeConfirmation<PendingDelete>(context.redis, NAMESPACE, token);
if (!entry) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
await interaction.reply({ content: t(locale, 'guildbackup.confirm.expired'), ephemeral: true });
return;
}
await executeDelete(interaction, context, entry);
return;
}
if (entry.step === 1) {
entry.step = 2;
entry.expiresAt = Date.now() + TTL_MS;
pending.set(token, entry);
if (peeked.step === 1) {
const next: PendingRestore = { ...peeked, step: 2 };
await setConfirmation(context.redis, NAMESPACE, token, next, TTL_SECONDS);
await interaction.update({
content: tf(entry.locale, 'guildbackup.confirm.restore.step2', { id: entry.backupId }),
components: buildRows(token, entry.locale, RESTORE_CONFIRM_PREFIX)
content: tf(peeked.locale, 'guildbackup.confirm.restore.step2', { id: peeked.backupId }),
components: buildRows(token, peeked.locale, RESTORE_CONFIRM_PREFIX)
});
return;
}
pending.delete(token);
const entry = await takeConfirmation<PendingRestore>(context.redis, NAMESPACE, token);
if (!entry) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
await interaction.reply({ content: t(locale, 'guildbackup.confirm.expired'), ephemeral: true });
return;
}
await executeRestore(interaction, context, entry);
}

View File

@@ -1,8 +1,9 @@
import { TextChannel, type ButtonInteraction } from 'discord.js';
import { type Locale, t } from '@nexumi/shared';
import { type Locale, t, tf } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { assertBannableForConfirm } from '../../hierarchy.js';
import { createCase, scheduleTempBanExpire } from './service.js';
import { parseDuration } from './duration.js';
import { tryParseDuration } from './duration.js';
type BanPayload = {
userId: string;
@@ -36,8 +37,24 @@ export async function executeBan(
return;
}
if (!(await assertBannableForConfirm(interaction, locale, payload.userId))) {
return;
}
let durationMs: number | null = null;
if (payload.duration) {
durationMs = tryParseDuration(payload.duration);
if (durationMs === null || durationMs <= 0) {
await interaction.update({
content: t(locale, 'moderation.duration.invalid'),
components: []
});
return;
}
}
await guild.members.ban(payload.userId, { reason: payload.reason });
await createCase(context, {
const modCase = await createCase(context, {
guildId: guild.id,
targetUserId: payload.userId,
moderatorId: interaction.user.id,
@@ -47,21 +64,17 @@ export async function executeBan(
source: 'button_confirmation',
metadata: {
confirmed: true,
duration: payload.duration
duration: payload.duration,
durationMs
}
});
if (payload.duration) {
await scheduleTempBanExpire(
guild.id,
payload.userId,
parseDuration(payload.duration),
payload.reason
);
if (durationMs !== null) {
await scheduleTempBanExpire(guild.id, payload.userId, durationMs, payload.reason);
}
await interaction.update({
content: t(locale, 'moderation.success'),
content: tf(locale, 'moderation.success.case', { caseNumber: modCase.caseNumber }),
components: []
});
}
@@ -97,7 +110,7 @@ export async function executePurge(
deletedCount = deleted.size;
}
await createCase(context, {
const modCase = await createCase(context, {
guildId: guild.id,
targetUserId: interaction.user.id,
moderatorId: interaction.user.id,
@@ -122,7 +135,10 @@ export async function executePurge(
});
await interaction.update({
content: t(locale, 'moderation.success'),
content: tf(locale, 'moderation.purge.done', {
deletedCount,
caseNumber: modCase.caseNumber
}),
components: []
});
}
@@ -143,7 +159,7 @@ export async function executeCaseDelete(
where: { guildId_caseNumber: { guildId, caseNumber: payload.caseNumber } }
});
if (!existing) {
if (!existing || existing.deletedAt) {
await interaction.update({
content: t(locale, 'moderation.case.notFound'),
components: []
@@ -156,7 +172,7 @@ export async function executeCaseDelete(
data: { deletedAt: new Date() }
});
await createCase(context, {
const modCase = await createCase(context, {
guildId,
targetUserId: existing.targetUserId,
moderatorId: interaction.user.id,
@@ -172,7 +188,7 @@ export async function executeCaseDelete(
});
await interaction.update({
content: t(locale, 'moderation.success'),
content: tf(locale, 'moderation.success.case', { caseNumber: modCase.caseNumber }),
components: []
});
}

View File

@@ -1,4 +1,5 @@
import {
PermissionFlagsBits,
SlashCommandBuilder,
type SlashCommandStringOption,
type SlashCommandUserOption
@@ -26,7 +27,9 @@ function reasonOpt(o: SlashCommandStringOption, required = false) {
}
export const banCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('ban'),
new SlashCommandBuilder()
.setName('ban')
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers),
'moderation.ban.description'
)
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
@@ -36,7 +39,9 @@ export const banCommandData = applyCommandDescription(
);
export const unbanCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('unban'),
new SlashCommandBuilder()
.setName('unban')
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers),
'moderation.unban.description'
)
.addStringOption((o) =>
@@ -45,14 +50,18 @@ export const unbanCommandData = applyCommandDescription(
.addStringOption((o) => reasonOpt(o));
export const kickCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('kick'),
new SlashCommandBuilder()
.setName('kick')
.setDefaultMemberPermissions(PermissionFlagsBits.KickMembers),
'moderation.kick.description'
)
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
.addStringOption((o) => reasonOpt(o));
export const timeoutCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('timeout'),
new SlashCommandBuilder()
.setName('timeout')
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
'moderation.timeout.description'
)
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
@@ -62,14 +71,18 @@ export const timeoutCommandData = applyCommandDescription(
.addStringOption((o) => reasonOpt(o));
export const untimeoutCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('untimeout'),
new SlashCommandBuilder()
.setName('untimeout')
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
'moderation.untimeout.description'
)
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
.addStringOption((o) => reasonOpt(o));
export const warnCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('warn'),
new SlashCommandBuilder()
.setName('warn')
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
'moderation.warn.description'
)
.addSubcommand((s) =>
@@ -123,7 +136,9 @@ export const warnCommandData = applyCommandDescription(
);
export const purgeCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('purge'),
new SlashCommandBuilder()
.setName('purge')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages),
'moderation.purge.description'
)
.addIntegerOption((o) =>
@@ -138,24 +153,32 @@ export const purgeCommandData = applyCommandDescription(
.addStringOption((o) => applyOptionDescription(o.setName('regex'), 'moderation.purge.options.regex'));
export const slowmodeCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('slowmode'),
new SlashCommandBuilder()
.setName('slowmode')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels),
'moderation.slowmode.description'
).addIntegerOption((o) =>
applyOptionDescription(o.setName('seconds').setRequired(true).setMinValue(0).setMaxValue(21600), 'moderation.slowmode.options.seconds')
);
export const lockCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('lock'),
new SlashCommandBuilder()
.setName('lock')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels),
'moderation.lock.description'
);
export const unlockCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('unlock'),
new SlashCommandBuilder()
.setName('unlock')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels),
'moderation.unlock.description'
);
export const nickCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('nick'),
new SlashCommandBuilder()
.setName('nick')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageNicknames),
'moderation.nick.description'
)
.addSubcommand((s) =>
@@ -172,7 +195,9 @@ export const nickCommandData = applyCommandDescription(
);
export const caseCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('case'),
new SlashCommandBuilder()
.setName('case')
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
'moderation.case.description'
)
.addSubcommand((s) =>
@@ -196,7 +221,9 @@ export const caseCommandData = applyCommandDescription(
);
export const modNoteCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('modnote'),
new SlashCommandBuilder()
.setName('modnote')
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
'moderation.modnote.description'
)
.addSubcommand((s) =>

View File

@@ -2,14 +2,14 @@ import {
ChannelType,
PermissionFlagsBits,
TextChannel,
type ChatInputCommandInteraction,
type GuildMember
type ChatInputCommandInteraction
} from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { requirePermission } from '../../permissions.js';
import { assertModerableMember } from '../../hierarchy.js';
import { applyWarnEscalation, createCase } from './service.js';
import { parseDuration } from './duration.js';
import { parseDuration, parseTimeoutDuration, tryParseDuration } from './duration.js';
import { getGuildLocale } from '../../i18n.js';
import { promptDestructiveConfirmation } from './confirmations.js';
import {
@@ -51,6 +51,17 @@ async function ensureMod(
return requirePermission(interaction, permission, locale);
}
async function replySuccessCase(
interaction: ChatInputCommandInteraction,
locale: 'de' | 'en',
caseNumber: number
): Promise<void> {
await interaction.reply({
content: tf(locale, 'moderation.success.case', { caseNumber }),
ephemeral: true
});
}
const banCommand: SlashCommand = {
data: banCommandData,
async execute(interaction, context) {
@@ -59,7 +70,22 @@ const banCommand: SlashCommand = {
const user = interaction.options.getUser('user', true);
const reason = reasonFrom(interaction, locale);
const duration = interaction.options.getString('duration');
await promptDestructiveConfirmation(interaction, locale, {
if (duration) {
const durationMs = tryParseDuration(duration);
if (durationMs === null || durationMs <= 0) {
await interaction.reply({
content: t(locale, 'moderation.duration.invalid'),
ephemeral: true
});
return;
}
}
const hierarchy = await assertModerableMember(interaction, user.id, locale, 'ban');
if (!hierarchy.ok) return;
await promptDestructiveConfirmation(interaction, context, locale, {
action: 'ban',
payload: {
userId: user.id,
@@ -78,7 +104,7 @@ const unbanCommand: SlashCommand = {
const userId = interaction.options.getString('user_id', true);
const reason = reasonFrom(interaction, locale);
await interaction.guild!.members.unban(userId, reason);
await createCase(context, {
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: userId,
moderatorId: interaction.user.id,
@@ -86,7 +112,7 @@ const unbanCommand: SlashCommand = {
action: 'UNBAN',
reason
});
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
await replySuccessCase(interaction, locale, modCase.caseNumber);
}
};
@@ -97,9 +123,10 @@ const kickCommand: SlashCommand = {
if (!(await ensureMod(interaction, PermissionFlagsBits.KickMembers, locale))) return;
const user = interaction.options.getUser('user', true);
const reason = reasonFrom(interaction, locale);
const member = (await interaction.guild!.members.fetch(user.id)) as GuildMember;
await member.kick(reason);
await createCase(context, {
const hierarchy = await assertModerableMember(interaction, user.id, locale, 'kick');
if (!hierarchy.ok || !hierarchy.member) return;
await hierarchy.member.kick(reason);
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: user.id,
moderatorId: interaction.user.id,
@@ -107,7 +134,7 @@ const kickCommand: SlashCommand = {
action: 'KICK',
reason
});
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
await replySuccessCase(interaction, locale, modCase.caseNumber);
}
};
@@ -117,11 +144,21 @@ const timeoutCommand: SlashCommand = {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return;
const user = interaction.options.getUser('user', true);
const duration = parseDuration(interaction.options.getString('duration', true));
let duration: number;
try {
duration = parseTimeoutDuration(interaction.options.getString('duration', true));
} catch {
await interaction.reply({
content: t(locale, 'moderation.duration.invalidTimeout'),
ephemeral: true
});
return;
}
const reason = reasonFrom(interaction, locale);
const member = await interaction.guild!.members.fetch(user.id);
await member.timeout(duration, reason);
await createCase(context, {
const hierarchy = await assertModerableMember(interaction, user.id, locale, 'timeout');
if (!hierarchy.ok || !hierarchy.member) return;
await hierarchy.member.timeout(duration, reason);
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: user.id,
moderatorId: interaction.user.id,
@@ -130,7 +167,7 @@ const timeoutCommand: SlashCommand = {
reason,
metadata: { durationMs: duration }
});
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
await replySuccessCase(interaction, locale, modCase.caseNumber);
}
};
@@ -141,9 +178,10 @@ const untimeoutCommand: SlashCommand = {
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return;
const user = interaction.options.getUser('user', true);
const reason = reasonFrom(interaction, locale);
const member = await interaction.guild!.members.fetch(user.id);
await member.timeout(null, reason);
await createCase(context, {
const hierarchy = await assertModerableMember(interaction, user.id, locale, 'timeout');
if (!hierarchy.ok || !hierarchy.member) return;
await hierarchy.member.timeout(null, reason);
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: user.id,
moderatorId: interaction.user.id,
@@ -151,7 +189,7 @@ const untimeoutCommand: SlashCommand = {
action: 'UN_TIMEOUT',
reason
});
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
await replySuccessCase(interaction, locale, modCase.caseNumber);
}
};
@@ -166,7 +204,19 @@ const warnCommand: SlashCommand = {
const action = interaction.options.getString('action', true);
const durationInput = interaction.options.getString('duration');
const reason = interaction.options.getString('reason');
const durationMs = durationInput ? parseDuration(durationInput) : null;
let durationMs: number | null = null;
if (durationInput) {
try {
durationMs =
action === 'TIMEOUT' ? parseTimeoutDuration(durationInput) : parseDuration(durationInput);
} catch {
await interaction.reply({
content: t(locale, 'moderation.duration.invalid'),
ephemeral: true
});
return;
}
}
if (action === 'TIMEOUT' && !durationMs) {
await interaction.reply({
content: t(locale, 'moderation.escalation.durationRequired'),
@@ -267,7 +317,8 @@ const warnCommand: SlashCommand = {
interaction,
context,
user.id,
interaction.user.id
interaction.user.id,
locale
);
await interaction.reply({
content: escalationResult
@@ -300,8 +351,18 @@ const warnCommand: SlashCommand = {
if (sub === 'remove') {
const warningId = interaction.options.getString('warning_id', true);
const warning = await context.prisma.warning.delete({ where: { id: warningId } });
await createCase(context, {
const warning = await context.prisma.warning.findFirst({
where: { id: warningId, guildId: interaction.guildId! }
});
if (!warning) {
await interaction.reply({
content: t(locale, 'moderation.warning.notFound'),
ephemeral: true
});
return;
}
await context.prisma.warning.delete({ where: { id: warning.id } });
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: warning.userId,
moderatorId: interaction.user.id,
@@ -309,13 +370,15 @@ const warnCommand: SlashCommand = {
action: 'WARN_REMOVE',
reason: `Warning ${warningId} removed`
});
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
await replySuccessCase(interaction, locale, modCase.caseNumber);
return;
}
const user = interaction.options.getUser('user', true);
await context.prisma.warning.deleteMany({ where: { guildId: interaction.guildId!, userId: user.id } });
await createCase(context, {
await context.prisma.warning.deleteMany({
where: { guildId: interaction.guildId!, userId: user.id }
});
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: user.id,
moderatorId: interaction.user.id,
@@ -323,7 +386,7 @@ const warnCommand: SlashCommand = {
action: 'WARN_CLEAR',
reason: 'Warnings cleared'
});
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
await replySuccessCase(interaction, locale, modCase.caseNumber);
}
};
@@ -352,7 +415,7 @@ const purgeCommand: SlashCommand = {
return;
}
await promptDestructiveConfirmation(interaction, locale, {
await promptDestructiveConfirmation(interaction, context, locale, {
action: 'purge',
payload: {
channelId: interaction.channel.id,
@@ -376,7 +439,7 @@ const slowmodeCommand: SlashCommand = {
if (interaction.channel instanceof TextChannel) {
await interaction.channel.setRateLimitPerUser(seconds);
}
await createCase(context, {
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: interaction.user.id,
moderatorId: interaction.user.id,
@@ -384,7 +447,7 @@ const slowmodeCommand: SlashCommand = {
action: 'SLOWMODE',
reason: `Set slowmode to ${seconds}s`
});
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
await replySuccessCase(interaction, locale, modCase.caseNumber);
}
};
@@ -398,7 +461,7 @@ const lockCommand: SlashCommand = {
SendMessages: false
});
}
await createCase(context, {
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: interaction.user.id,
moderatorId: interaction.user.id,
@@ -406,7 +469,7 @@ const lockCommand: SlashCommand = {
action: 'LOCK',
reason: 'Channel locked'
});
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
await replySuccessCase(interaction, locale, modCase.caseNumber);
}
};
@@ -420,7 +483,7 @@ const unlockCommand: SlashCommand = {
SendMessages: null
});
}
await createCase(context, {
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: interaction.user.id,
moderatorId: interaction.user.id,
@@ -428,7 +491,7 @@ const unlockCommand: SlashCommand = {
action: 'UNLOCK',
reason: 'Channel unlocked'
});
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
await replySuccessCase(interaction, locale, modCase.caseNumber);
}
};
@@ -439,11 +502,13 @@ const nickCommand: SlashCommand = {
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageNicknames, locale))) return;
const sub = interaction.options.getSubcommand();
const user = interaction.options.getUser('user', true);
const member = await interaction.guild!.members.fetch(user.id);
const hierarchy = await assertModerableMember(interaction, user.id, locale, 'nick');
if (!hierarchy.ok || !hierarchy.member) return;
const member = hierarchy.member;
if (sub === 'set') {
const nickname = interaction.options.getString('nickname', true);
await member.setNickname(nickname);
await createCase(context, {
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: user.id,
moderatorId: interaction.user.id,
@@ -451,9 +516,10 @@ const nickCommand: SlashCommand = {
action: 'NICK_SET',
reason: nickname
});
await replySuccessCase(interaction, locale, modCase.caseNumber);
} else {
await member.setNickname(null);
await createCase(context, {
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: user.id,
moderatorId: interaction.user.id,
@@ -461,8 +527,8 @@ const nickCommand: SlashCommand = {
action: 'NICK_RESET',
reason: 'Nickname reset'
});
await replySuccessCase(interaction, locale, modCase.caseNumber);
}
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
}
};
@@ -474,8 +540,12 @@ const caseCommand: SlashCommand = {
const sub = interaction.options.getSubcommand();
const caseNumber = interaction.options.getInteger('id', true);
if (sub === 'view') {
const found = await context.prisma.case.findUnique({
where: { guildId_caseNumber: { guildId: interaction.guildId!, caseNumber } }
const found = await context.prisma.case.findFirst({
where: {
guildId: interaction.guildId!,
caseNumber,
deletedAt: null
}
});
await interaction.reply({
content: found ? JSON.stringify(found, null, 2) : t(locale, 'moderation.case.notFound'),
@@ -485,14 +555,31 @@ const caseCommand: SlashCommand = {
}
if (sub === 'edit') {
const reason = interaction.options.getString('reason', true);
await context.prisma.case.update({
where: { guildId_caseNumber: { guildId: interaction.guildId!, caseNumber } },
data: { reason }
const existing = await context.prisma.case.findFirst({
where: {
guildId: interaction.guildId!,
caseNumber,
deletedAt: null
}
});
if (!existing) {
await interaction.reply({
content: t(locale, 'moderation.case.notFound'),
ephemeral: true
});
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
return;
}
await promptDestructiveConfirmation(interaction, locale, {
await context.prisma.case.update({
where: { id: existing.id },
data: { reason }
});
await interaction.reply({
content: tf(locale, 'moderation.success.case', { caseNumber }),
ephemeral: true
});
return;
}
await promptDestructiveConfirmation(interaction, context, locale, {
action: 'case_delete',
payload: { caseNumber }
});
@@ -517,7 +604,7 @@ const modNoteCommand: SlashCommand = {
note
}
});
await createCase(context, {
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: user.id,
moderatorId: interaction.user.id,
@@ -525,7 +612,7 @@ const modNoteCommand: SlashCommand = {
action: 'MODNOTE_ADD',
reason: note
});
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
await replySuccessCase(interaction, locale, modCase.caseNumber);
return;
}
const notes = await context.prisma.modNote.findMany({

View File

@@ -9,11 +9,18 @@ import { randomUUID } from 'node:crypto';
import { type Locale, t, tf } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import {
deleteConfirmation,
getConfirmation,
setConfirmation,
takeConfirmation
} from '../../confirmation-store.js';
import { executeBan, executeCaseDelete, executePurge } from './actions.js';
const CONFIRM_PREFIX = 'mod:confirm:';
const CANCEL_PREFIX = 'mod:cancel:';
const TTL_MS = 60_000;
const NAMESPACE = 'moderation';
const TTL_SECONDS = 60;
type BanPayload = {
userId: string;
@@ -44,20 +51,8 @@ type PendingEntry = PendingConfirmation & {
moderatorId: string;
guildId: string;
locale: Locale;
expiresAt: number;
};
const pending = new Map<string, PendingEntry>();
function pruneExpired(): void {
const now = Date.now();
for (const [token, entry] of pending) {
if (entry.expiresAt <= now) {
pending.delete(token);
}
}
}
function confirmationMessage(locale: Locale, entry: PendingConfirmation): string {
if (entry.action === 'ban') {
return tf(locale, 'moderation.confirm.ban', {
@@ -89,18 +84,23 @@ function buildRows(token: string, locale: Locale): ActionRowBuilder<ButtonBuilde
export async function promptDestructiveConfirmation(
interaction: ChatInputCommandInteraction,
context: BotContext,
locale: Locale,
entry: PendingConfirmation
): Promise<void> {
pruneExpired();
const token = randomUUID();
pending.set(token, {
await setConfirmation(
context.redis,
NAMESPACE,
token,
{
...entry,
moderatorId: interaction.user.id,
guildId: interaction.guildId!,
locale,
expiresAt: Date.now() + TTL_MS
});
locale
} satisfies PendingEntry,
TTL_SECONDS
);
await interaction.reply({
content: confirmationMessage(locale, entry),
@@ -113,8 +113,6 @@ export async function handleModerationConfirmation(
interaction: ButtonInteraction,
context: BotContext
): Promise<void> {
pruneExpired();
const customId = interaction.customId;
const isConfirm = customId.startsWith(CONFIRM_PREFIX);
const isCancel = customId.startsWith(CANCEL_PREFIX);
@@ -123,31 +121,37 @@ export async function handleModerationConfirmation(
}
const token = customId.slice(isConfirm ? CONFIRM_PREFIX.length : CANCEL_PREFIX.length);
const entry = pending.get(token);
if (!entry) {
const peeked = await getConfirmation<PendingEntry>(context.redis, NAMESPACE, token);
if (!peeked) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
await interaction.reply({ content: t(locale, 'moderation.confirm.expired'), ephemeral: true });
return;
}
if (interaction.user.id !== entry.moderatorId) {
if (interaction.user.id !== peeked.moderatorId) {
await interaction.reply({
content: t(entry.locale, 'moderation.confirm.unauthorized'),
content: t(peeked.locale, 'moderation.confirm.unauthorized'),
ephemeral: true
});
return;
}
pending.delete(token);
if (isCancel) {
await deleteConfirmation(context.redis, NAMESPACE, token);
await interaction.update({
content: t(entry.locale, 'moderation.confirm.cancelled'),
content: t(peeked.locale, 'moderation.confirm.cancelled'),
components: []
});
return;
}
const entry = await takeConfirmation<PendingEntry>(context.redis, NAMESPACE, token);
if (!entry) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
await interaction.reply({ content: t(locale, 'moderation.confirm.expired'), ephemeral: true });
return;
}
if (entry.action === 'ban') {
await executeBan(interaction, context, entry.locale, entry.payload);
return;

View File

@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest';
import { parseDuration } from './duration.js';
import {
MAX_TIMEOUT_MS,
parseDuration,
parseTimeoutDuration,
tryParseDuration
} from './duration.js';
describe('parseDuration', () => {
it('parses minutes', () => {
@@ -14,3 +19,27 @@ describe('parseDuration', () => {
expect(() => parseDuration('tomorrow')).toThrow();
});
});
describe('parseTimeoutDuration', () => {
it('accepts durations within Discord limit', () => {
expect(parseTimeoutDuration('1d')).toBe(86_400_000);
});
it('rejects durations over 28 days', () => {
expect(() => parseTimeoutDuration('30d')).toThrow(/28/);
});
it('exposes MAX_TIMEOUT_MS as 28 days', () => {
expect(MAX_TIMEOUT_MS).toBe(28 * 24 * 60 * 60 * 1000);
});
});
describe('tryParseDuration', () => {
it('returns null for invalid input', () => {
expect(tryParseDuration('nope')).toBeNull();
});
it('returns ms for valid input', () => {
expect(tryParseDuration('10s')).toBe(10_000);
});
});

View File

@@ -1,10 +1,39 @@
export const MAX_TIMEOUT_MS = 28 * 24 * 60 * 60 * 1000;
export class DurationParseError extends Error {
constructor(message: string) {
super(message);
this.name = 'DurationParseError';
}
}
export function parseDuration(input: string): number {
const m = input.match(/^(\d+)([smhd])$/i);
if (!m) {
throw new Error('Duration must be like 30m, 12h, 7d');
throw new DurationParseError('Duration must be like 30m, 12h, 7d');
}
const amount = Number(m[1]);
const unit = m[2].toLowerCase();
const unit = m[2]!.toLowerCase();
const factor = unit === 's' ? 1000 : unit === 'm' ? 60000 : unit === 'h' ? 3600000 : 86400000;
return amount * factor;
}
/** Parse a duration and enforce Discord's 28-day timeout maximum. */
export function parseTimeoutDuration(input: string): number {
const ms = parseDuration(input);
if (ms <= 0) {
throw new DurationParseError('Duration must be positive');
}
if (ms > MAX_TIMEOUT_MS) {
throw new DurationParseError('Timeout duration cannot exceed 28 days');
}
return ms;
}
export function tryParseDuration(input: string): number | null {
try {
return parseDuration(input);
} catch {
return null;
}
}

View File

@@ -2,7 +2,8 @@ import { moderationQueue } from '../../queues.js';
import type { BotContext } from '../../types.js';
import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js';
import type { Prisma } from '@prisma/client';
import { buildCaseMetadata, type CaseAuditSource } from '@nexumi/shared';
import { buildCaseMetadata, type CaseAuditSource, t, type Locale } from '@nexumi/shared';
import { MAX_TIMEOUT_MS } from './duration.js';
type CreateCaseInput = {
guildId: string;
@@ -22,18 +23,23 @@ export async function createCase(context: BotContext, input: CreateCaseInput) {
create: { id: input.guildId }
});
const lastCase = await context.prisma.case.findFirst({
where: { guildId: input.guildId },
orderBy: { caseNumber: 'desc' }
});
const metadata = buildCaseMetadata({
source: input.source ?? 'slash_command',
channelId: input.channelId,
extras: input.metadata
});
return context.prisma.case.create({
let created = null;
for (let attempt = 0; attempt < 5; attempt++) {
try {
created = await context.prisma.$transaction(
async (tx) => {
const lastCase = await tx.case.findFirst({
where: { guildId: input.guildId },
orderBy: { caseNumber: 'desc' },
select: { caseNumber: true }
});
return tx.case.create({
data: {
guildId: input.guildId,
caseNumber: (lastCase?.caseNumber ?? 0) + 1,
@@ -43,7 +49,29 @@ export async function createCase(context: BotContext, input: CreateCaseInput) {
reason: input.reason,
metadata: metadata as Prisma.InputJsonValue
}
}).then(async (created) => {
});
},
{ isolationLevel: 'Serializable' }
);
break;
} catch (error) {
const code =
typeof error === 'object' && error && 'code' in error
? String((error as { code: unknown }).code)
: '';
if (code !== 'P2002' && attempt === 4) {
throw error;
}
if (attempt === 4) {
throw error;
}
}
}
if (!created) {
throw new Error('Failed to create case');
}
const { logModAction } = await import('../logging/events.js');
await logModAction(context, {
guildId: input.guildId,
@@ -54,7 +82,10 @@ export async function createCase(context: BotContext, input: CreateCaseInput) {
reason: input.reason
});
return created;
});
}
export function tempBanJobId(guildId: string, userId: string): string {
return `tempBan:${guildId}:${userId}`;
}
export async function scheduleTempBanExpire(
@@ -63,10 +94,17 @@ export async function scheduleTempBanExpire(
durationMs: number,
reason?: string
) {
const jobId = tempBanJobId(guildId, userId);
const existing = await moderationQueue.getJob(jobId);
if (existing) {
await existing.remove();
}
await moderationQueue.add(
'tempBanExpire',
{ guildId, userId, reason: reason ?? null },
{
jobId,
delay: durationMs,
removeOnComplete: 1000,
removeOnFail: 500
@@ -78,7 +116,8 @@ export async function applyWarnEscalation(
interaction: ChatInputCommandInteraction,
context: BotContext,
userId: string,
moderatorId: string
moderatorId: string,
locale: Locale
): Promise<string | null> {
const guildId = interaction.guildId;
if (!guildId || !interaction.guild) {
@@ -98,16 +137,25 @@ export async function applyWarnEscalation(
}
const member = await interaction.guild.members.fetch(userId);
const reason = rule.reason ?? `Auto escalation at ${warningCount} warnings`;
const reason =
rule.reason ??
t(locale, 'moderation.escalation.defaultReason').replace('{warnCount}', String(warningCount));
if (rule.action === 'TIMEOUT') {
if (!rule.durationMs) {
return `Escalation rule for ${warningCount} warns skipped (missing duration).`;
return t(locale, 'moderation.escalation.skippedDuration').replace(
'{warnCount}',
String(warningCount)
);
}
if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.ModerateMembers)) {
return `Escalation rule for ${warningCount} warns skipped (missing bot permissions).`;
return t(locale, 'moderation.escalation.skippedPermission').replace(
'{warnCount}',
String(warningCount)
);
}
await member.timeout(rule.durationMs, reason);
const durationMs = Math.min(rule.durationMs, MAX_TIMEOUT_MS);
await member.timeout(durationMs, reason);
await createCase(context, {
guildId,
targetUserId: userId,
@@ -116,14 +164,20 @@ export async function applyWarnEscalation(
reason,
channelId: interaction.channelId ?? undefined,
source: 'escalation',
metadata: { escalation: true, durationMs: rule.durationMs, warningCount }
metadata: { escalation: true, durationMs, warningCount }
});
return `Auto escalation applied: timeout (${rule.durationMs} ms).`;
return t(locale, 'moderation.escalation.appliedTimeout').replace(
'{durationMs}',
String(durationMs)
);
}
if (rule.action === 'KICK') {
if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.KickMembers)) {
return `Escalation rule for ${warningCount} warns skipped (missing bot permissions).`;
return t(locale, 'moderation.escalation.skippedPermission').replace(
'{warnCount}',
String(warningCount)
);
}
await member.kick(reason);
await createCase(context, {
@@ -136,11 +190,14 @@ export async function applyWarnEscalation(
source: 'escalation',
metadata: { escalation: true, warningCount }
});
return 'Auto escalation applied: kick.';
return t(locale, 'moderation.escalation.appliedKick');
}
if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.BanMembers)) {
return `Escalation rule for ${warningCount} warns skipped (missing bot permissions).`;
return t(locale, 'moderation.escalation.skippedPermission').replace(
'{warnCount}',
String(warningCount)
);
}
await interaction.guild.members.ban(userId, { reason });
await createCase(context, {
@@ -153,5 +210,5 @@ export async function applyWarnEscalation(
source: 'escalation',
metadata: { escalation: true, warningCount }
});
return 'Auto escalation applied: ban.';
return t(locale, 'moderation.escalation.appliedBan');
}

36
apps/bot/src/sentry.ts Normal file
View File

@@ -0,0 +1,36 @@
import * as Sentry from '@sentry/node';
import { env } from './env.js';
import { logger } from './logger.js';
let initialized = false;
export function initSentry(service: 'bot-manager' | 'bot-shard' | 'webui'): void {
if (initialized || !env.SENTRY_DSN) {
return;
}
Sentry.init({
dsn: env.SENTRY_DSN,
environment: env.NODE_ENV,
release: process.env.npm_package_version
? `nexumi@${process.env.npm_package_version}`
: 'nexumi@0.1.0',
tracesSampleRate: env.NODE_ENV === 'production' ? 0.1 : 1.0,
initialScope: {
tags: { service }
}
});
initialized = true;
logger.info({ service }, 'Sentry initialized');
}
export function captureException(error: unknown, context?: Record<string, unknown>): void {
if (!env.SENTRY_DSN) {
return;
}
Sentry.withScope((scope) => {
if (context) {
scope.setExtras(context);
}
Sentry.captureException(error);
});
}

View File

@@ -0,0 +1,37 @@
import type { Client } from 'discord.js';
import { redis } from './redis.js';
import { logger } from './logger.js';
/**
* Returns true when this process owns shard 0 (or has no shard manager = single process).
*/
export function isPrimaryShard(client: Client): boolean {
const ids = client.shard?.ids;
if (!ids || ids.length === 0) {
return true;
}
return ids.includes(0);
}
/**
* Acquires a short-lived Redis lock. Returns true if this caller owns the lock.
*/
export async function acquireOnceLock(lockKey: string, ttlSeconds: number): Promise<boolean> {
const result = await redis.set(lockKey, String(Date.now()), 'EX', ttlSeconds, 'NX');
return result === 'OK';
}
export async function runOncePerCluster(
lockKey: string,
ttlSeconds: number,
fn: () => Promise<void>,
label: string
): Promise<boolean> {
const acquired = await acquireOnceLock(lockKey, ttlSeconds);
if (!acquired) {
logger.info({ lockKey }, `${label} skipped (another process holds the lock)`);
return false;
}
await fn();
return true;
}

View File

@@ -23,6 +23,7 @@
"@radix-ui/react-separator": "^1.1.12",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-switch": "^1.3.4",
"@sentry/node": "^10.67.0",
"bullmq": "^5.34.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View File

@@ -0,0 +1,6 @@
export async function register(): Promise<void> {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { initWebuiSentry } = await import('./lib/sentry');
initWebuiSentry();
}
}

View File

@@ -0,0 +1,32 @@
import * as Sentry from '@sentry/node';
import { env } from './env';
let initialized = false;
export function initWebuiSentry(): void {
if (initialized || !env.SENTRY_DSN) {
return;
}
Sentry.init({
dsn: env.SENTRY_DSN,
environment: env.NODE_ENV,
release: 'nexumi-webui@0.1.0',
tracesSampleRate: env.NODE_ENV === 'production' ? 0.1 : 1.0,
initialScope: {
tags: { service: 'webui' }
}
});
initialized = true;
}
export function captureWebuiException(error: unknown, context?: Record<string, unknown>): void {
if (!env.SENTRY_DSN) {
return;
}
Sentry.withScope((scope) => {
if (context) {
scope.setExtras(context);
}
Sentry.captureException(error);
});
}

View File

@@ -195,6 +195,31 @@
- Modal-only Components, File-Component, Premium-Buttons (nicht im MVP)
- Pixelgenaue Discord-Vorschau im Dashboard
## Post-Phase Harden (Sharding / Gates / Observability) (Status: implementiert)
### Abgeschlossen (Code)
- Confirmations (Moderation + Guildbackup) in Redis mit TTL statt Prozess-`Map`
- Command-Registrierung einmal im Manager (Redis-Lock); Recurring Jobs einmal (Primary-Shard + Lock)
- Modul-Toggles + FeatureFlags + User-/Guild-Blacklists im Bot erzwungen (`routeCommand`, Events, GuildCreate leave)
- Moderation: Hierarchy-Checks, Duration vor Ban-Confirm, Timeout-Cap 28d, warn remove guild-scoped, atomare Case-Nummern, Temp-Ban Job-Dedup + UNBAN-Case, `setDefaultMemberPermissions`
- Sentry (`@sentry/node`) in Bot + WebUI (`instrumentation.ts`); echte Prometheus-Metrics (`/metrics`)
- Interaction-Registry statt if-Kette; Unit-Tests für Duration, Confirm-Store, Module-Gates, Metrics
### Manuell testen
- [ ] Ban mit Dauer: Confirm auf anderem Shard / nach Restart innerhalb TTL
- [ ] Dashboard: Modul Moderation aus → `/ban` antwortet „Modul deaktiviert“
- [ ] Owner: User-Blacklist → Interactions blockiert; Guild-Blacklist → Bot verlässt Server
- [ ] `/metrics` mit Bearer-Token: Counters + Shard-Ping
- [ ] Temp-Ban ablaufen → Unban + Case `UNBAN`
### Bewusst offen
- Musik- und KI-Modul (nur auf Zuruf)
- Premium-Zahlungsanbindung
- P4-UX (Server-Lock, Ban deleteMessageSeconds) bewusst nachgelagert
## Nächster geplanter Schritt
- Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy``main` mergen (nur auf Freigabe).

View File

@@ -59,6 +59,9 @@ const de: Dictionary = {
'generic.commandChannelBlocked': 'Dieser Befehl ist in diesem Kanal nicht erlaubt.',
'generic.commandRoleBlocked': 'Du darfst diesen Befehl mit deinen Rollen nicht nutzen.',
'generic.commandCooldown': 'Bitte warte noch {seconds} Sekunden, bevor du diesen Befehl erneut nutzt.',
'generic.moduleDisabled': 'Dieses Modul ist auf diesem Server deaktiviert.',
'generic.userBlacklisted': 'Du darfst Nexumi derzeit nicht nutzen.',
'generic.guildBlacklisted': 'Dieser Server darf Nexumi derzeit nicht nutzen.',
'core.help.title': 'Nexumi Hilfe',
'core.help.subtitle': 'Commands nach Modul. Optional: `/help module:<name>`.',
'core.help.moduleLine': '**{module}**: {commands}',
@@ -111,10 +114,23 @@ const de: Dictionary = {
'gdpr.delete.doneBody':
'Gelöscht/anonymisiert: {warnings} Verwarnungen, {levels} Level-Einträge, {economy} Economy-Einträge, {cases} Cases anonymisiert.',
'moderation.success': 'Aktion erfolgreich ausgeführt.',
'moderation.success.case': 'Aktion erfolgreich ausgeführt (Fall #{caseNumber}).',
'moderation.duration.invalid': 'Ungültige Dauer. Nutze z. B. 30m, 12h oder 7d.',
'moderation.duration.invalidTimeout':
'Ungültige Timeout-Dauer. Nutze z. B. 30m oder 1d (max. 28 Tage).',
'moderation.hierarchy.self': 'Du kannst diese Aktion nicht auf dich selbst anwenden.',
'moderation.hierarchy.owner': 'Der Server-Owner kann nicht moderiert werden.',
'moderation.hierarchy.moderator':
'Du kannst keine Mitglieder moderieren, die gleich oder höher in der Rollenhierarchie stehen.',
'moderation.hierarchy.bot':
'Der Bot kann dieses Mitglied wegen der Rollenhierarchie oder fehlender Rechte nicht moderieren.',
'moderation.hierarchy.notMember': 'Dieses Mitglied ist nicht auf dem Server.',
'moderation.reason.none': 'Kein Grund angegeben',
'moderation.warning.created': 'Verwarnung erstellt: {warningId}',
'moderation.warning.none': 'Keine Verwarnungen vorhanden.',
'moderation.warning.defaultReason': 'Kein Grund',
'moderation.warning.notFound': 'Verwarnung nicht gefunden.',
'moderation.purge.done': '{deletedCount} Nachrichten gelöscht (Fall #{caseNumber}).',
'moderation.note.none': 'Keine Notizen vorhanden.',
'moderation.case.notFound': 'Fall nicht gefunden.',
'moderation.escalation.durationRequired':
@@ -124,6 +140,14 @@ const de: Dictionary = {
'Eskalationsregel gespeichert: {warnCount} Verwarnungen => {action}{duration}.',
'moderation.escalation.removed':
'Eskalationsregel für {warnCount} Verwarnungen entfernt.',
'moderation.escalation.defaultReason': 'Auto-Eskalation bei {warnCount} Verwarnungen',
'moderation.escalation.skippedDuration':
'Eskalationsregel für {warnCount} Verwarnungen übersprungen (fehlende Dauer).',
'moderation.escalation.skippedPermission':
'Eskalationsregel für {warnCount} Verwarnungen übersprungen (fehlende Bot-Rechte).',
'moderation.escalation.appliedTimeout': 'Auto-Eskalation angewendet: Timeout ({durationMs} ms).',
'moderation.escalation.appliedKick': 'Auto-Eskalation angewendet: Kick.',
'moderation.escalation.appliedBan': 'Auto-Eskalation angewendet: Ban.',
'moderation.confirm.button.confirm': 'Bestätigen',
'moderation.confirm.button.cancel': 'Abbrechen',
'moderation.confirm.permanent': 'dauerhaft',
@@ -612,6 +636,9 @@ const en: Dictionary = {
'generic.commandChannelBlocked': 'This command is not allowed in this channel.',
'generic.commandRoleBlocked': 'You are not allowed to use this command with your roles.',
'generic.commandCooldown': 'Please wait {seconds} more seconds before using this command again.',
'generic.moduleDisabled': 'This module is disabled on this server.',
'generic.userBlacklisted': 'You are not allowed to use Nexumi right now.',
'generic.guildBlacklisted': 'This server is not allowed to use Nexumi right now.',
'core.help.title': 'Nexumi Help',
'core.help.subtitle': 'Commands by module. Optional: `/help module:<name>`.',
'core.help.moduleLine': '**{module}**: {commands}',
@@ -664,10 +691,23 @@ const en: Dictionary = {
'gdpr.delete.doneBody':
'Deleted/anonymized: {warnings} warnings, {levels} level rows, {economy} economy rows, {cases} cases anonymized.',
'moderation.success': 'Action executed successfully.',
'moderation.success.case': 'Action executed successfully (case #{caseNumber}).',
'moderation.duration.invalid': 'Invalid duration. Use e.g. 30m, 12h, or 7d.',
'moderation.duration.invalidTimeout':
'Invalid timeout duration. Use e.g. 30m or 1d (max 28 days).',
'moderation.hierarchy.self': 'You cannot apply this action to yourself.',
'moderation.hierarchy.owner': 'The server owner cannot be moderated.',
'moderation.hierarchy.moderator':
'You cannot moderate members with equal or higher roles.',
'moderation.hierarchy.bot':
'The bot cannot moderate this member due to role hierarchy or missing permissions.',
'moderation.hierarchy.notMember': 'That member is not on this server.',
'moderation.reason.none': 'No reason provided',
'moderation.warning.created': 'Warning created: {warningId}',
'moderation.warning.none': 'No warnings.',
'moderation.warning.defaultReason': 'No reason',
'moderation.warning.notFound': 'Warning not found.',
'moderation.purge.done': 'Deleted {deletedCount} messages (case #{caseNumber}).',
'moderation.note.none': 'No notes.',
'moderation.case.notFound': 'Case not found.',
'moderation.escalation.durationRequired': 'Duration is required for TIMEOUT escalation.',
@@ -675,6 +715,14 @@ const en: Dictionary = {
'moderation.escalation.saved':
'Escalation rule saved: {warnCount} warnings => {action}{duration}.',
'moderation.escalation.removed': 'Escalation rule removed for {warnCount} warnings.',
'moderation.escalation.defaultReason': 'Auto escalation at {warnCount} warnings',
'moderation.escalation.skippedDuration':
'Escalation rule for {warnCount} warns skipped (missing duration).',
'moderation.escalation.skippedPermission':
'Escalation rule for {warnCount} warns skipped (missing bot permissions).',
'moderation.escalation.appliedTimeout': 'Auto escalation applied: timeout ({durationMs} ms).',
'moderation.escalation.appliedKick': 'Auto escalation applied: kick.',
'moderation.escalation.appliedBan': 'Auto escalation applied: ban.',
'moderation.confirm.button.confirm': 'Confirm',
'moderation.confirm.button.cancel': 'Cancel',
'moderation.confirm.permanent': 'permanent',