deploy #1

Merged
smueller merged 30 commits from deploy into main 2026-07-24 08:41:09 +00:00
31 changed files with 1831 additions and 363 deletions
Showing only changes of commit ffaa1e26fd - Show all commits

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" "profile:banner": "tsx scripts/set-bot-banner.ts"
}, },
"dependencies": { "dependencies": {
"@nexumi/shared": "workspace:*",
"@napi-rs/canvas": "^0.1.65", "@napi-rs/canvas": "^0.1.65",
"@nexumi/shared": "workspace:*",
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"@sentry/node": "^10.67.0",
"bullmq": "^5.34.0", "bullmq": "^5.34.0",
"discord.js": "^14.17.3", "discord.js": "^14.17.3",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",

View File

@@ -9,7 +9,15 @@ import { env } from './env.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
import { getGuildLocale } from './i18n.js'; import { getGuildLocale } from './i18n.js';
import { evaluateCommandGate, type CommandGateReason } from './command-gates.js'; import { evaluateCommandGate, type CommandGateReason } from './command-gates.js';
import {
isGuildBlacklisted,
isModuleEnabled,
isUserBlacklisted,
moduleForCommand
} from './module-gates.js';
import { isMaintenanceMode } from './presence.js'; import { isMaintenanceMode } from './presence.js';
import { recordCommandMetric } from './metrics.js';
import { captureException } from './sentry.js';
import { moderationCommands } from './modules/moderation/commands.js'; import { moderationCommands } from './modules/moderation/commands.js';
import { automodCommands } from './modules/automod/commands.js'; import { automodCommands } from './modules/automod/commands.js';
import { welcomeCommands } from './modules/welcome/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) { export async function routeCommand(interaction: ChatInputCommandInteraction, context: BotContext) {
const started = Date.now();
let ok = true;
const locale = await getGuildLocale(context.prisma, interaction.guildId); 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 maintenance = await isMaintenanceMode(context);
const ownerIds = env.OWNER_USER_IDS ?? []; const ownerIds = env.OWNER_USER_IDS ?? [];
if (maintenance.enabled && !ownerIds.includes(interaction.user.id)) { if (maintenance.enabled && !ownerIds.includes(interaction.user.id)) {
@@ -133,6 +161,23 @@ export async function routeCommand(interaction: ChatInputCommandInteraction, con
return; 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); const gate = await evaluateCommandGate(interaction, context);
if (!gate.ok) { if (!gate.ok) {
await interaction.reply({ await interaction.reply({
@@ -143,14 +188,45 @@ export async function routeCommand(interaction: ChatInputCommandInteraction, con
} }
await command.execute(interaction, context); 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( export async function routeContextMenu(
interaction: MessageContextMenuCommandInteraction, interaction: MessageContextMenuCommandInteraction,
context: BotContext context: BotContext
) { ) {
const command = contextMenuMap.get(interaction.commandName);
const locale = await getGuildLocale(context.prisma, interaction.guildId); 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) { if (!command) {
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true }); await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });
return; 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_CRON: z.string().default('0 3 * * *'),
BACKUP_DIR: z.string().default('/backups'), BACKUP_DIR: z.string().default('/backups'),
METRICS_TOKEN: z.preprocess(emptyToUndefined, z.string().min(1).optional()), 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), HEALTH_PORT: z.coerce.number().int().positive().default(8080),
PUBLIC_BASE_URL: z.string().url().default('http://localhost:8080'), PUBLIC_BASE_URL: z.string().url().default('http://localhost:8080'),
TWITCH_CLIENT_ID: z.preprocess(emptyToUndefined, z.string().min(1).optional()), 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, getCaptchaChallenge,
hashCaptchaAnswer hashCaptchaAnswer
} from './modules/verification/service.js'; } 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> { function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => { 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>'); 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 { export function startHealthServer(): void {
const server = createServer(async (req, res) => { const server = createServer(async (req, res) => {
if (!req.url) { if (!req.url) {
@@ -121,8 +144,16 @@ export function startHealthServer(): void {
res.end('Unauthorized'); res.end('Unauthorized');
return; return;
} }
try {
const snapshot = await collectMetricsSnapshot(redis);
snapshot.queueWaiting = await approximateQueueWaiting();
res.statusCode = 200; 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; 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 { ensureRecurringJobs, startWorkers } from './jobs.js';
import type { BotContext } from './types.js'; import type { BotContext } from './types.js';
import { startHealthServer } from './health.js'; import { startHealthServer } from './health.js';
import {
handleModerationConfirmation,
isModerationConfirmation
} from './modules/moderation/confirmations.js';
import { getGuildLocale } from './i18n.js'; import { getGuildLocale } from './i18n.js';
import { t } from '@nexumi/shared'; import { t } from '@nexumi/shared';
import { handleAutoModMessage } from './modules/automod/actions.js'; import { handleAutoModMessage } from './modules/automod/actions.js';
import { registerLoggingEvents } from './modules/logging/events.js'; import { registerLoggingEvents } from './modules/logging/events.js';
import { registerWelcomeEvents } from './modules/welcome/events.js'; import { registerWelcomeEvents } from './modules/welcome/events.js';
import { registerVerificationEvents } from './modules/verification/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 { registerLevelingEvents } from './modules/leveling/events.js';
import { import { registerUtilityEvents } from './modules/utility/index.js';
handleBlackjackButton, import { registerTicketEvents } from './modules/tickets/index.js';
isBlackjackButton import { registerSelfRoleEvents } from './modules/selfroles/index.js';
} 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 { registerTagEvents } from './modules/tags/index.js'; import { registerTagEvents } from './modules/tags/index.js';
import { registerStarboardEvents } from './modules/starboard/index.js'; import { registerStarboardEvents } from './modules/starboard/index.js';
import { import { registerTempVoiceEvents } from './modules/tempvoice/index.js';
handleSuggestionButton,
isSuggestionButton
} from './modules/suggestions/index.js';
import {
handleTempVoiceInteraction,
isTempVoiceInteraction,
isTempVoiceModal,
registerTempVoiceEvents
} from './modules/tempvoice/index.js';
import { registerStatsEvents } from './modules/stats/index.js'; import { registerStatsEvents } from './modules/stats/index.js';
import { handleGuildBackupButton, isGuildBackupButton } from './modules/guildbackup/index.js'; import { routeComponentInteraction } from './interaction-registry.js';
import { import { isPrimaryShard, runOncePerCluster } from './shard-lifecycle.js';
handleComponentsV2Interaction, import { isGuildBlacklisted, isUserBlacklisted } from './module-gates.js';
isComponentsV2Interaction import { publishShardMetrics, recordEventMetric } from './metrics.js';
} from './modules/components-v2/index.js'; import { captureException, initSentry } from './sentry.js';
const isShard = process.argv.includes('--shard'); const isShard = process.argv.includes('--shard');
if (!isShard) { if (!isShard) {
initSentry('bot-manager');
startHealthServer(); startHealthServer();
const manager = new ShardingManager(new URL('./index.js', import.meta.url).pathname, { const manager = new ShardingManager(new URL('./index.js', import.meta.url).pathname, {
token: env.BOT_TOKEN, token: env.BOT_TOKEN,
@@ -79,8 +45,25 @@ if (!isShard) {
shardArgs: ['--shard'] shardArgs: ['--shard']
}); });
manager.on('shardCreate', (shard) => logger.info({ shardId: shard.id }, 'Shard spawned')); 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(); await manager.spawn();
} else { } else {
initSentry('bot-shard');
const client = new Client({ const client = new Client({
intents: [ intents: [
GatewayIntentBits.Guilds, GatewayIntentBits.Guilds,
@@ -103,7 +86,7 @@ if (!isShard) {
const context: BotContext = { client, prisma, redis }; const context: BotContext = { client, prisma, redis };
startWorkers(context); startWorkers(context);
await ensureRecurringJobs();
registerLoggingEvents(context); registerLoggingEvents(context);
registerWelcomeEvents(context); registerWelcomeEvents(context);
registerVerificationEvents(context); registerVerificationEvents(context);
@@ -117,12 +100,24 @@ if (!isShard) {
registerStatsEvents(context); registerStatsEvents(context);
client.on(Events.ClientReady, async () => { 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 { try {
await registerCommands(); await runOncePerCluster(
'nexumi:lock:ensure-recurring-jobs',
120,
async () => {
await ensureRecurringJobs();
},
'Recurring job registration'
);
} catch (error) { } catch (error) {
logger.error({ error }, 'Failed to register application commands'); logger.error({ error }, 'Failed to ensure recurring jobs');
captureException(error, { phase: 'ensureRecurringJobs' });
} }
}
try { try {
const { applyBotPresence, publishBotStatus } = await import('./presence.js'); const { applyBotPresence, publishBotStatus } = await import('./presence.js');
await applyBotPresence(context); await applyBotPresence(context);
@@ -130,18 +125,61 @@ if (!isShard) {
} catch (error) { } catch (error) {
logger.warn({ error }, 'Failed to apply initial bot presence/status'); 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) => { client.on(Events.MessageCreate, async (message) => {
try { 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); await handleAutoModMessage(context, message);
} catch (error) { } catch (error) {
logger.error({ error, messageId: message.id }, 'AutoMod message handler failed'); logger.error({ error, messageId: message.id }, 'AutoMod message handler failed');
captureException(error, { messageId: message.id });
} }
}); });
client.on(Events.InteractionCreate, async (interaction: Interaction) => { client.on(Events.InteractionCreate, async (interaction: Interaction) => {
try { 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()) { if (interaction.isChatInputCommand()) {
await routeCommand(interaction, context); await routeCommand(interaction, context);
return; return;
@@ -152,93 +190,23 @@ if (!isShard) {
return; 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 ( if (
(interaction.isButton() || 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.isStringSelectMenu() || interaction.isStringSelectMenu() ||
interaction.isUserSelectMenu() || interaction.isUserSelectMenu() ||
interaction.isRoleSelectMenu() || interaction.isRoleSelectMenu() ||
interaction.isChannelSelectMenu() || interaction.isChannelSelectMenu() ||
interaction.isMentionableSelectMenu()) && interaction.isMentionableSelectMenu() ||
isComponentsV2Interaction(interaction.customId) interaction.isModalSubmit()
) { ) {
await handleComponentsV2Interaction(interaction, context); await routeComponentInteraction(interaction, context);
return;
} }
} catch (error) { } catch (error) {
logger.error({ error }, 'Interaction handling failed'); logger.error({ error }, 'Interaction handling failed');
captureException(error, {
interactionType: interaction.type,
guildId: interaction.guildId
});
const locale = await getGuildLocale(context.prisma, interaction.guildId); const locale = await getGuildLocale(context.prisma, interaction.guildId);
const message = t(locale, 'generic.error'); const message = t(locale, 'generic.error');
if (interaction.isRepliable()) { 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; 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(); const me = await guild.members.fetchMe();
if (!me.permissions.has(PermissionFlagsBits.BanMembers)) { if (!me.permissions.has(PermissionFlagsBits.BanMembers)) {
throw new Error('Missing BanMembers permission for temp ban expiration'); 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 } { 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; return;
} }
await promptDeleteConfirmation(interaction, locale, backupId); await promptDeleteConfirmation(interaction, context, locale, backupId);
return; return;
} }
@@ -195,7 +195,7 @@ const backupCommand: SlashCommand = {
return; 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 Locale, t, tf } from '@nexumi/shared';
import type { BotContext } from '../../types.js'; import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js'; import { getGuildLocale } from '../../i18n.js';
import {
deleteConfirmation,
getConfirmation,
setConfirmation,
takeConfirmation
} from '../../confirmation-store.js';
import { import {
deleteGuildBackup, deleteGuildBackup,
enqueueGuildBackupRestore, enqueueGuildBackupRestore,
@@ -22,7 +28,8 @@ import {
const DELETE_CONFIRM_PREFIX = 'gbackup:confirm:delete:'; const DELETE_CONFIRM_PREFIX = 'gbackup:confirm:delete:';
const RESTORE_CONFIRM_PREFIX = 'gbackup:confirm:restore:'; const RESTORE_CONFIRM_PREFIX = 'gbackup:confirm:restore:';
const CANCEL_PREFIX = 'gbackup:cancel:'; const CANCEL_PREFIX = 'gbackup:cancel:';
const TTL_MS = 120_000; const NAMESPACE = 'guildbackup';
const TTL_SECONDS = 120;
type PendingDelete = { type PendingDelete = {
action: 'delete'; action: 'delete';
@@ -30,7 +37,6 @@ type PendingDelete = {
userId: string; userId: string;
guildId: string; guildId: string;
locale: Locale; locale: Locale;
expiresAt: number;
}; };
type PendingRestore = { type PendingRestore = {
@@ -40,22 +46,10 @@ type PendingRestore = {
userId: string; userId: string;
guildId: string; guildId: string;
locale: Locale; locale: Locale;
expiresAt: number;
}; };
type PendingEntry = PendingDelete | PendingRestore; 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>[] { function buildRows(token: string, locale: Locale, confirmPrefix: string): ActionRowBuilder<ButtonBuilder>[] {
return [ return [
new ActionRowBuilder<ButtonBuilder>().addComponents( new ActionRowBuilder<ButtonBuilder>().addComponents(
@@ -73,19 +67,24 @@ function buildRows(token: string, locale: Locale, confirmPrefix: string): Action
export async function promptDeleteConfirmation( export async function promptDeleteConfirmation(
interaction: ChatInputCommandInteraction, interaction: ChatInputCommandInteraction,
context: BotContext,
locale: Locale, locale: Locale,
backupId: string backupId: string
): Promise<void> { ): Promise<void> {
pruneExpired();
const token = randomUUID(); const token = randomUUID();
pending.set(token, { await setConfirmation(
context.redis,
NAMESPACE,
token,
{
action: 'delete', action: 'delete',
backupId, backupId,
userId: interaction.user.id, userId: interaction.user.id,
guildId: interaction.guildId!, guildId: interaction.guildId!,
locale, locale
expiresAt: Date.now() + TTL_MS } satisfies PendingDelete,
}); TTL_SECONDS
);
await interaction.reply({ await interaction.reply({
content: tf(locale, 'guildbackup.confirm.delete', { id: backupId }), content: tf(locale, 'guildbackup.confirm.delete', { id: backupId }),
@@ -96,20 +95,25 @@ export async function promptDeleteConfirmation(
export async function promptRestoreConfirmation( export async function promptRestoreConfirmation(
interaction: ChatInputCommandInteraction, interaction: ChatInputCommandInteraction,
context: BotContext,
locale: Locale, locale: Locale,
backupId: string backupId: string
): Promise<void> { ): Promise<void> {
pruneExpired();
const token = randomUUID(); const token = randomUUID();
pending.set(token, { await setConfirmation(
context.redis,
NAMESPACE,
token,
{
action: 'restore', action: 'restore',
step: 1, step: 1,
backupId, backupId,
userId: interaction.user.id, userId: interaction.user.id,
guildId: interaction.guildId!, guildId: interaction.guildId!,
locale, locale
expiresAt: Date.now() + TTL_MS } satisfies PendingRestore,
}); TTL_SECONDS
);
await interaction.reply({ await interaction.reply({
content: tf(locale, 'guildbackup.confirm.restore.step1', { id: backupId }), content: tf(locale, 'guildbackup.confirm.restore.step1', { id: backupId }),
@@ -122,8 +126,6 @@ export async function handleGuildBackupButton(
interaction: ButtonInteraction, interaction: ButtonInteraction,
context: BotContext context: BotContext
): Promise<void> { ): Promise<void> {
pruneExpired();
const customId = interaction.customId; const customId = interaction.customId;
const isDeleteConfirm = customId.startsWith(DELETE_CONFIRM_PREFIX); const isDeleteConfirm = customId.startsWith(DELETE_CONFIRM_PREFIX);
const isRestoreConfirm = customId.startsWith(RESTORE_CONFIRM_PREFIX); const isRestoreConfirm = customId.startsWith(RESTORE_CONFIRM_PREFIX);
@@ -141,48 +143,57 @@ export async function handleGuildBackupButton(
: CANCEL_PREFIX.length : CANCEL_PREFIX.length
); );
const entry = pending.get(token); const peeked = await getConfirmation<PendingEntry>(context.redis, NAMESPACE, token);
if (!entry) { if (!peeked) {
const locale = await getGuildLocale(context.prisma, interaction.guildId); const locale = await getGuildLocale(context.prisma, interaction.guildId);
await interaction.reply({ content: t(locale, 'guildbackup.confirm.expired'), ephemeral: true }); await interaction.reply({ content: t(locale, 'guildbackup.confirm.expired'), ephemeral: true });
return; return;
} }
if (interaction.user.id !== entry.userId) { if (interaction.user.id !== peeked.userId) {
await interaction.reply({ await interaction.reply({
content: t(entry.locale, 'guildbackup.confirm.unauthorized'), content: t(peeked.locale, 'guildbackup.confirm.unauthorized'),
ephemeral: true ephemeral: true
}); });
return; return;
} }
if (isCancel) { if (isCancel) {
pending.delete(token); await deleteConfirmation(context.redis, NAMESPACE, token);
await interaction.update({ await interaction.update({
content: t(entry.locale, 'guildbackup.confirm.cancelled'), content: t(peeked.locale, 'guildbackup.confirm.cancelled'),
components: [] components: []
}); });
return; return;
} }
if (entry.action === 'delete') { if (peeked.action === 'delete') {
pending.delete(token); 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); await executeDelete(interaction, context, entry);
return; return;
} }
if (entry.step === 1) { if (peeked.step === 1) {
entry.step = 2; const next: PendingRestore = { ...peeked, step: 2 };
entry.expiresAt = Date.now() + TTL_MS; await setConfirmation(context.redis, NAMESPACE, token, next, TTL_SECONDS);
pending.set(token, entry);
await interaction.update({ await interaction.update({
content: tf(entry.locale, 'guildbackup.confirm.restore.step2', { id: entry.backupId }), content: tf(peeked.locale, 'guildbackup.confirm.restore.step2', { id: peeked.backupId }),
components: buildRows(token, entry.locale, RESTORE_CONFIRM_PREFIX) components: buildRows(token, peeked.locale, RESTORE_CONFIRM_PREFIX)
}); });
return; 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); await executeRestore(interaction, context, entry);
} }

View File

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

View File

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

View File

@@ -2,14 +2,14 @@ import {
ChannelType, ChannelType,
PermissionFlagsBits, PermissionFlagsBits,
TextChannel, TextChannel,
type ChatInputCommandInteraction, type ChatInputCommandInteraction
type GuildMember
} from 'discord.js'; } from 'discord.js';
import { t, tf } from '@nexumi/shared'; import { t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js'; import type { SlashCommand } from '../../types.js';
import { requirePermission } from '../../permissions.js'; import { requirePermission } from '../../permissions.js';
import { assertModerableMember } from '../../hierarchy.js';
import { applyWarnEscalation, createCase } from './service.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 { getGuildLocale } from '../../i18n.js';
import { promptDestructiveConfirmation } from './confirmations.js'; import { promptDestructiveConfirmation } from './confirmations.js';
import { import {
@@ -51,6 +51,17 @@ async function ensureMod(
return requirePermission(interaction, permission, locale); 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 = { const banCommand: SlashCommand = {
data: banCommandData, data: banCommandData,
async execute(interaction, context) { async execute(interaction, context) {
@@ -59,7 +70,22 @@ const banCommand: SlashCommand = {
const user = interaction.options.getUser('user', true); const user = interaction.options.getUser('user', true);
const reason = reasonFrom(interaction, locale); const reason = reasonFrom(interaction, locale);
const duration = interaction.options.getString('duration'); 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', action: 'ban',
payload: { payload: {
userId: user.id, userId: user.id,
@@ -78,7 +104,7 @@ const unbanCommand: SlashCommand = {
const userId = interaction.options.getString('user_id', true); const userId = interaction.options.getString('user_id', true);
const reason = reasonFrom(interaction, locale); const reason = reasonFrom(interaction, locale);
await interaction.guild!.members.unban(userId, reason); await interaction.guild!.members.unban(userId, reason);
await createCase(context, { const modCase = await createCase(context, {
guildId: interaction.guildId!, guildId: interaction.guildId!,
targetUserId: userId, targetUserId: userId,
moderatorId: interaction.user.id, moderatorId: interaction.user.id,
@@ -86,7 +112,7 @@ const unbanCommand: SlashCommand = {
action: 'UNBAN', action: 'UNBAN',
reason 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; if (!(await ensureMod(interaction, PermissionFlagsBits.KickMembers, locale))) return;
const user = interaction.options.getUser('user', true); const user = interaction.options.getUser('user', true);
const reason = reasonFrom(interaction, locale); const reason = reasonFrom(interaction, locale);
const member = (await interaction.guild!.members.fetch(user.id)) as GuildMember; const hierarchy = await assertModerableMember(interaction, user.id, locale, 'kick');
await member.kick(reason); if (!hierarchy.ok || !hierarchy.member) return;
await createCase(context, { await hierarchy.member.kick(reason);
const modCase = await createCase(context, {
guildId: interaction.guildId!, guildId: interaction.guildId!,
targetUserId: user.id, targetUserId: user.id,
moderatorId: interaction.user.id, moderatorId: interaction.user.id,
@@ -107,7 +134,7 @@ const kickCommand: SlashCommand = {
action: 'KICK', action: 'KICK',
reason 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); const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return; if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return;
const user = interaction.options.getUser('user', true); 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 reason = reasonFrom(interaction, locale);
const member = await interaction.guild!.members.fetch(user.id); const hierarchy = await assertModerableMember(interaction, user.id, locale, 'timeout');
await member.timeout(duration, reason); if (!hierarchy.ok || !hierarchy.member) return;
await createCase(context, { await hierarchy.member.timeout(duration, reason);
const modCase = await createCase(context, {
guildId: interaction.guildId!, guildId: interaction.guildId!,
targetUserId: user.id, targetUserId: user.id,
moderatorId: interaction.user.id, moderatorId: interaction.user.id,
@@ -130,7 +167,7 @@ const timeoutCommand: SlashCommand = {
reason, reason,
metadata: { durationMs: duration } 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; if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return;
const user = interaction.options.getUser('user', true); const user = interaction.options.getUser('user', true);
const reason = reasonFrom(interaction, locale); const reason = reasonFrom(interaction, locale);
const member = await interaction.guild!.members.fetch(user.id); const hierarchy = await assertModerableMember(interaction, user.id, locale, 'timeout');
await member.timeout(null, reason); if (!hierarchy.ok || !hierarchy.member) return;
await createCase(context, { await hierarchy.member.timeout(null, reason);
const modCase = await createCase(context, {
guildId: interaction.guildId!, guildId: interaction.guildId!,
targetUserId: user.id, targetUserId: user.id,
moderatorId: interaction.user.id, moderatorId: interaction.user.id,
@@ -151,7 +189,7 @@ const untimeoutCommand: SlashCommand = {
action: 'UN_TIMEOUT', action: 'UN_TIMEOUT',
reason 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 action = interaction.options.getString('action', true);
const durationInput = interaction.options.getString('duration'); const durationInput = interaction.options.getString('duration');
const reason = interaction.options.getString('reason'); 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) { if (action === 'TIMEOUT' && !durationMs) {
await interaction.reply({ await interaction.reply({
content: t(locale, 'moderation.escalation.durationRequired'), content: t(locale, 'moderation.escalation.durationRequired'),
@@ -267,7 +317,8 @@ const warnCommand: SlashCommand = {
interaction, interaction,
context, context,
user.id, user.id,
interaction.user.id interaction.user.id,
locale
); );
await interaction.reply({ await interaction.reply({
content: escalationResult content: escalationResult
@@ -300,8 +351,18 @@ const warnCommand: SlashCommand = {
if (sub === 'remove') { if (sub === 'remove') {
const warningId = interaction.options.getString('warning_id', true); const warningId = interaction.options.getString('warning_id', true);
const warning = await context.prisma.warning.delete({ where: { id: warningId } }); const warning = await context.prisma.warning.findFirst({
await createCase(context, { 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!, guildId: interaction.guildId!,
targetUserId: warning.userId, targetUserId: warning.userId,
moderatorId: interaction.user.id, moderatorId: interaction.user.id,
@@ -309,13 +370,15 @@ const warnCommand: SlashCommand = {
action: 'WARN_REMOVE', action: 'WARN_REMOVE',
reason: `Warning ${warningId} removed` reason: `Warning ${warningId} removed`
}); });
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true }); await replySuccessCase(interaction, locale, modCase.caseNumber);
return; return;
} }
const user = interaction.options.getUser('user', true); const user = interaction.options.getUser('user', true);
await context.prisma.warning.deleteMany({ where: { guildId: interaction.guildId!, userId: user.id } }); await context.prisma.warning.deleteMany({
await createCase(context, { where: { guildId: interaction.guildId!, userId: user.id }
});
const modCase = await createCase(context, {
guildId: interaction.guildId!, guildId: interaction.guildId!,
targetUserId: user.id, targetUserId: user.id,
moderatorId: interaction.user.id, moderatorId: interaction.user.id,
@@ -323,7 +386,7 @@ const warnCommand: SlashCommand = {
action: 'WARN_CLEAR', action: 'WARN_CLEAR',
reason: 'Warnings cleared' 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; return;
} }
await promptDestructiveConfirmation(interaction, locale, { await promptDestructiveConfirmation(interaction, context, locale, {
action: 'purge', action: 'purge',
payload: { payload: {
channelId: interaction.channel.id, channelId: interaction.channel.id,
@@ -376,7 +439,7 @@ const slowmodeCommand: SlashCommand = {
if (interaction.channel instanceof TextChannel) { if (interaction.channel instanceof TextChannel) {
await interaction.channel.setRateLimitPerUser(seconds); await interaction.channel.setRateLimitPerUser(seconds);
} }
await createCase(context, { const modCase = await createCase(context, {
guildId: interaction.guildId!, guildId: interaction.guildId!,
targetUserId: interaction.user.id, targetUserId: interaction.user.id,
moderatorId: interaction.user.id, moderatorId: interaction.user.id,
@@ -384,7 +447,7 @@ const slowmodeCommand: SlashCommand = {
action: 'SLOWMODE', action: 'SLOWMODE',
reason: `Set slowmode to ${seconds}s` 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 SendMessages: false
}); });
} }
await createCase(context, { const modCase = await createCase(context, {
guildId: interaction.guildId!, guildId: interaction.guildId!,
targetUserId: interaction.user.id, targetUserId: interaction.user.id,
moderatorId: interaction.user.id, moderatorId: interaction.user.id,
@@ -406,7 +469,7 @@ const lockCommand: SlashCommand = {
action: 'LOCK', action: 'LOCK',
reason: 'Channel locked' 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 SendMessages: null
}); });
} }
await createCase(context, { const modCase = await createCase(context, {
guildId: interaction.guildId!, guildId: interaction.guildId!,
targetUserId: interaction.user.id, targetUserId: interaction.user.id,
moderatorId: interaction.user.id, moderatorId: interaction.user.id,
@@ -428,7 +491,7 @@ const unlockCommand: SlashCommand = {
action: 'UNLOCK', action: 'UNLOCK',
reason: 'Channel unlocked' 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; if (!(await ensureMod(interaction, PermissionFlagsBits.ManageNicknames, locale))) return;
const sub = interaction.options.getSubcommand(); const sub = interaction.options.getSubcommand();
const user = interaction.options.getUser('user', true); 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') { if (sub === 'set') {
const nickname = interaction.options.getString('nickname', true); const nickname = interaction.options.getString('nickname', true);
await member.setNickname(nickname); await member.setNickname(nickname);
await createCase(context, { const modCase = await createCase(context, {
guildId: interaction.guildId!, guildId: interaction.guildId!,
targetUserId: user.id, targetUserId: user.id,
moderatorId: interaction.user.id, moderatorId: interaction.user.id,
@@ -451,9 +516,10 @@ const nickCommand: SlashCommand = {
action: 'NICK_SET', action: 'NICK_SET',
reason: nickname reason: nickname
}); });
await replySuccessCase(interaction, locale, modCase.caseNumber);
} else { } else {
await member.setNickname(null); await member.setNickname(null);
await createCase(context, { const modCase = await createCase(context, {
guildId: interaction.guildId!, guildId: interaction.guildId!,
targetUserId: user.id, targetUserId: user.id,
moderatorId: interaction.user.id, moderatorId: interaction.user.id,
@@ -461,8 +527,8 @@ const nickCommand: SlashCommand = {
action: 'NICK_RESET', action: 'NICK_RESET',
reason: 'Nickname 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 sub = interaction.options.getSubcommand();
const caseNumber = interaction.options.getInteger('id', true); const caseNumber = interaction.options.getInteger('id', true);
if (sub === 'view') { if (sub === 'view') {
const found = await context.prisma.case.findUnique({ const found = await context.prisma.case.findFirst({
where: { guildId_caseNumber: { guildId: interaction.guildId!, caseNumber } } where: {
guildId: interaction.guildId!,
caseNumber,
deletedAt: null
}
}); });
await interaction.reply({ await interaction.reply({
content: found ? JSON.stringify(found, null, 2) : t(locale, 'moderation.case.notFound'), content: found ? JSON.stringify(found, null, 2) : t(locale, 'moderation.case.notFound'),
@@ -485,14 +555,31 @@ const caseCommand: SlashCommand = {
} }
if (sub === 'edit') { if (sub === 'edit') {
const reason = interaction.options.getString('reason', true); const reason = interaction.options.getString('reason', true);
await context.prisma.case.update({ const existing = await context.prisma.case.findFirst({
where: { guildId_caseNumber: { guildId: interaction.guildId!, caseNumber } }, where: {
data: { reason } 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; 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', action: 'case_delete',
payload: { caseNumber } payload: { caseNumber }
}); });
@@ -517,7 +604,7 @@ const modNoteCommand: SlashCommand = {
note note
} }
}); });
await createCase(context, { const modCase = await createCase(context, {
guildId: interaction.guildId!, guildId: interaction.guildId!,
targetUserId: user.id, targetUserId: user.id,
moderatorId: interaction.user.id, moderatorId: interaction.user.id,
@@ -525,7 +612,7 @@ const modNoteCommand: SlashCommand = {
action: 'MODNOTE_ADD', action: 'MODNOTE_ADD',
reason: note reason: note
}); });
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true }); await replySuccessCase(interaction, locale, modCase.caseNumber);
return; return;
} }
const notes = await context.prisma.modNote.findMany({ 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 Locale, t, tf } from '@nexumi/shared';
import type { BotContext } from '../../types.js'; import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js'; import { getGuildLocale } from '../../i18n.js';
import {
deleteConfirmation,
getConfirmation,
setConfirmation,
takeConfirmation
} from '../../confirmation-store.js';
import { executeBan, executeCaseDelete, executePurge } from './actions.js'; import { executeBan, executeCaseDelete, executePurge } from './actions.js';
const CONFIRM_PREFIX = 'mod:confirm:'; const CONFIRM_PREFIX = 'mod:confirm:';
const CANCEL_PREFIX = 'mod:cancel:'; const CANCEL_PREFIX = 'mod:cancel:';
const TTL_MS = 60_000; const NAMESPACE = 'moderation';
const TTL_SECONDS = 60;
type BanPayload = { type BanPayload = {
userId: string; userId: string;
@@ -44,20 +51,8 @@ type PendingEntry = PendingConfirmation & {
moderatorId: string; moderatorId: string;
guildId: string; guildId: string;
locale: Locale; 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 { function confirmationMessage(locale: Locale, entry: PendingConfirmation): string {
if (entry.action === 'ban') { if (entry.action === 'ban') {
return tf(locale, 'moderation.confirm.ban', { return tf(locale, 'moderation.confirm.ban', {
@@ -89,18 +84,23 @@ function buildRows(token: string, locale: Locale): ActionRowBuilder<ButtonBuilde
export async function promptDestructiveConfirmation( export async function promptDestructiveConfirmation(
interaction: ChatInputCommandInteraction, interaction: ChatInputCommandInteraction,
context: BotContext,
locale: Locale, locale: Locale,
entry: PendingConfirmation entry: PendingConfirmation
): Promise<void> { ): Promise<void> {
pruneExpired();
const token = randomUUID(); const token = randomUUID();
pending.set(token, { await setConfirmation(
context.redis,
NAMESPACE,
token,
{
...entry, ...entry,
moderatorId: interaction.user.id, moderatorId: interaction.user.id,
guildId: interaction.guildId!, guildId: interaction.guildId!,
locale, locale
expiresAt: Date.now() + TTL_MS } satisfies PendingEntry,
}); TTL_SECONDS
);
await interaction.reply({ await interaction.reply({
content: confirmationMessage(locale, entry), content: confirmationMessage(locale, entry),
@@ -113,8 +113,6 @@ export async function handleModerationConfirmation(
interaction: ButtonInteraction, interaction: ButtonInteraction,
context: BotContext context: BotContext
): Promise<void> { ): Promise<void> {
pruneExpired();
const customId = interaction.customId; const customId = interaction.customId;
const isConfirm = customId.startsWith(CONFIRM_PREFIX); const isConfirm = customId.startsWith(CONFIRM_PREFIX);
const isCancel = customId.startsWith(CANCEL_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 token = customId.slice(isConfirm ? CONFIRM_PREFIX.length : CANCEL_PREFIX.length);
const entry = pending.get(token); const peeked = await getConfirmation<PendingEntry>(context.redis, NAMESPACE, token);
if (!entry) { if (!peeked) {
const locale = await getGuildLocale(context.prisma, interaction.guildId); const locale = await getGuildLocale(context.prisma, interaction.guildId);
await interaction.reply({ content: t(locale, 'moderation.confirm.expired'), ephemeral: true }); await interaction.reply({ content: t(locale, 'moderation.confirm.expired'), ephemeral: true });
return; return;
} }
if (interaction.user.id !== entry.moderatorId) { if (interaction.user.id !== peeked.moderatorId) {
await interaction.reply({ await interaction.reply({
content: t(entry.locale, 'moderation.confirm.unauthorized'), content: t(peeked.locale, 'moderation.confirm.unauthorized'),
ephemeral: true ephemeral: true
}); });
return; return;
} }
pending.delete(token);
if (isCancel) { if (isCancel) {
await deleteConfirmation(context.redis, NAMESPACE, token);
await interaction.update({ await interaction.update({
content: t(entry.locale, 'moderation.confirm.cancelled'), content: t(peeked.locale, 'moderation.confirm.cancelled'),
components: [] components: []
}); });
return; 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') { if (entry.action === 'ban') {
await executeBan(interaction, context, entry.locale, entry.payload); await executeBan(interaction, context, entry.locale, entry.payload);
return; return;

View File

@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { parseDuration } from './duration.js'; import {
MAX_TIMEOUT_MS,
parseDuration,
parseTimeoutDuration,
tryParseDuration
} from './duration.js';
describe('parseDuration', () => { describe('parseDuration', () => {
it('parses minutes', () => { it('parses minutes', () => {
@@ -14,3 +19,27 @@ describe('parseDuration', () => {
expect(() => parseDuration('tomorrow')).toThrow(); 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 { export function parseDuration(input: string): number {
const m = input.match(/^(\d+)([smhd])$/i); const m = input.match(/^(\d+)([smhd])$/i);
if (!m) { 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 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; const factor = unit === 's' ? 1000 : unit === 'm' ? 60000 : unit === 'h' ? 3600000 : 86400000;
return amount * factor; 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 type { BotContext } from '../../types.js';
import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js'; import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js';
import type { Prisma } from '@prisma/client'; 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 = { type CreateCaseInput = {
guildId: string; guildId: string;
@@ -22,18 +23,23 @@ export async function createCase(context: BotContext, input: CreateCaseInput) {
create: { id: input.guildId } create: { id: input.guildId }
}); });
const lastCase = await context.prisma.case.findFirst({
where: { guildId: input.guildId },
orderBy: { caseNumber: 'desc' }
});
const metadata = buildCaseMetadata({ const metadata = buildCaseMetadata({
source: input.source ?? 'slash_command', source: input.source ?? 'slash_command',
channelId: input.channelId, channelId: input.channelId,
extras: input.metadata 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: { data: {
guildId: input.guildId, guildId: input.guildId,
caseNumber: (lastCase?.caseNumber ?? 0) + 1, caseNumber: (lastCase?.caseNumber ?? 0) + 1,
@@ -43,7 +49,29 @@ export async function createCase(context: BotContext, input: CreateCaseInput) {
reason: input.reason, reason: input.reason,
metadata: metadata as Prisma.InputJsonValue 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'); const { logModAction } = await import('../logging/events.js');
await logModAction(context, { await logModAction(context, {
guildId: input.guildId, guildId: input.guildId,
@@ -54,7 +82,10 @@ export async function createCase(context: BotContext, input: CreateCaseInput) {
reason: input.reason reason: input.reason
}); });
return created; return created;
}); }
export function tempBanJobId(guildId: string, userId: string): string {
return `tempBan:${guildId}:${userId}`;
} }
export async function scheduleTempBanExpire( export async function scheduleTempBanExpire(
@@ -63,10 +94,17 @@ export async function scheduleTempBanExpire(
durationMs: number, durationMs: number,
reason?: string reason?: string
) { ) {
const jobId = tempBanJobId(guildId, userId);
const existing = await moderationQueue.getJob(jobId);
if (existing) {
await existing.remove();
}
await moderationQueue.add( await moderationQueue.add(
'tempBanExpire', 'tempBanExpire',
{ guildId, userId, reason: reason ?? null }, { guildId, userId, reason: reason ?? null },
{ {
jobId,
delay: durationMs, delay: durationMs,
removeOnComplete: 1000, removeOnComplete: 1000,
removeOnFail: 500 removeOnFail: 500
@@ -78,7 +116,8 @@ export async function applyWarnEscalation(
interaction: ChatInputCommandInteraction, interaction: ChatInputCommandInteraction,
context: BotContext, context: BotContext,
userId: string, userId: string,
moderatorId: string moderatorId: string,
locale: Locale
): Promise<string | null> { ): Promise<string | null> {
const guildId = interaction.guildId; const guildId = interaction.guildId;
if (!guildId || !interaction.guild) { if (!guildId || !interaction.guild) {
@@ -98,16 +137,25 @@ export async function applyWarnEscalation(
} }
const member = await interaction.guild.members.fetch(userId); 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.action === 'TIMEOUT') {
if (!rule.durationMs) { 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)) { 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, { await createCase(context, {
guildId, guildId,
targetUserId: userId, targetUserId: userId,
@@ -116,14 +164,20 @@ export async function applyWarnEscalation(
reason, reason,
channelId: interaction.channelId ?? undefined, channelId: interaction.channelId ?? undefined,
source: 'escalation', 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 (rule.action === 'KICK') {
if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.KickMembers)) { 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 member.kick(reason);
await createCase(context, { await createCase(context, {
@@ -136,11 +190,14 @@ export async function applyWarnEscalation(
source: 'escalation', source: 'escalation',
metadata: { escalation: true, warningCount } metadata: { escalation: true, warningCount }
}); });
return 'Auto escalation applied: kick.'; return t(locale, 'moderation.escalation.appliedKick');
} }
if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.BanMembers)) { 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 interaction.guild.members.ban(userId, { reason });
await createCase(context, { await createCase(context, {
@@ -153,5 +210,5 @@ export async function applyWarnEscalation(
source: 'escalation', source: 'escalation',
metadata: { escalation: true, warningCount } 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-separator": "^1.1.12",
"@radix-ui/react-slot": "^1.3.0", "@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-switch": "^1.3.4", "@radix-ui/react-switch": "^1.3.4",
"@sentry/node": "^10.67.0",
"bullmq": "^5.34.0", "bullmq": "^5.34.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.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) - Modal-only Components, File-Component, Premium-Buttons (nicht im MVP)
- Pixelgenaue Discord-Vorschau im Dashboard - 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 ## Nächster geplanter Schritt
- Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy``main` mergen (nur auf Freigabe). - 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.commandChannelBlocked': 'Dieser Befehl ist in diesem Kanal nicht erlaubt.',
'generic.commandRoleBlocked': 'Du darfst diesen Befehl mit deinen Rollen nicht nutzen.', '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.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.title': 'Nexumi Hilfe',
'core.help.subtitle': 'Commands nach Modul. Optional: `/help module:<name>`.', 'core.help.subtitle': 'Commands nach Modul. Optional: `/help module:<name>`.',
'core.help.moduleLine': '**{module}**: {commands}', 'core.help.moduleLine': '**{module}**: {commands}',
@@ -111,10 +114,23 @@ const de: Dictionary = {
'gdpr.delete.doneBody': 'gdpr.delete.doneBody':
'Gelöscht/anonymisiert: {warnings} Verwarnungen, {levels} Level-Einträge, {economy} Economy-Einträge, {cases} Cases anonymisiert.', 'Gelöscht/anonymisiert: {warnings} Verwarnungen, {levels} Level-Einträge, {economy} Economy-Einträge, {cases} Cases anonymisiert.',
'moderation.success': 'Aktion erfolgreich ausgeführt.', '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.reason.none': 'Kein Grund angegeben',
'moderation.warning.created': 'Verwarnung erstellt: {warningId}', 'moderation.warning.created': 'Verwarnung erstellt: {warningId}',
'moderation.warning.none': 'Keine Verwarnungen vorhanden.', 'moderation.warning.none': 'Keine Verwarnungen vorhanden.',
'moderation.warning.defaultReason': 'Kein Grund', '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.note.none': 'Keine Notizen vorhanden.',
'moderation.case.notFound': 'Fall nicht gefunden.', 'moderation.case.notFound': 'Fall nicht gefunden.',
'moderation.escalation.durationRequired': 'moderation.escalation.durationRequired':
@@ -124,6 +140,14 @@ const de: Dictionary = {
'Eskalationsregel gespeichert: {warnCount} Verwarnungen => {action}{duration}.', 'Eskalationsregel gespeichert: {warnCount} Verwarnungen => {action}{duration}.',
'moderation.escalation.removed': 'moderation.escalation.removed':
'Eskalationsregel für {warnCount} Verwarnungen entfernt.', '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.confirm': 'Bestätigen',
'moderation.confirm.button.cancel': 'Abbrechen', 'moderation.confirm.button.cancel': 'Abbrechen',
'moderation.confirm.permanent': 'dauerhaft', 'moderation.confirm.permanent': 'dauerhaft',
@@ -612,6 +636,9 @@ const en: Dictionary = {
'generic.commandChannelBlocked': 'This command is not allowed in this channel.', 'generic.commandChannelBlocked': 'This command is not allowed in this channel.',
'generic.commandRoleBlocked': 'You are not allowed to use this command with your roles.', '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.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.title': 'Nexumi Help',
'core.help.subtitle': 'Commands by module. Optional: `/help module:<name>`.', 'core.help.subtitle': 'Commands by module. Optional: `/help module:<name>`.',
'core.help.moduleLine': '**{module}**: {commands}', 'core.help.moduleLine': '**{module}**: {commands}',
@@ -664,10 +691,23 @@ const en: Dictionary = {
'gdpr.delete.doneBody': 'gdpr.delete.doneBody':
'Deleted/anonymized: {warnings} warnings, {levels} level rows, {economy} economy rows, {cases} cases anonymized.', 'Deleted/anonymized: {warnings} warnings, {levels} level rows, {economy} economy rows, {cases} cases anonymized.',
'moderation.success': 'Action executed successfully.', '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.reason.none': 'No reason provided',
'moderation.warning.created': 'Warning created: {warningId}', 'moderation.warning.created': 'Warning created: {warningId}',
'moderation.warning.none': 'No warnings.', 'moderation.warning.none': 'No warnings.',
'moderation.warning.defaultReason': 'No reason', '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.note.none': 'No notes.',
'moderation.case.notFound': 'Case not found.', 'moderation.case.notFound': 'Case not found.',
'moderation.escalation.durationRequired': 'Duration is required for TIMEOUT escalation.', 'moderation.escalation.durationRequired': 'Duration is required for TIMEOUT escalation.',
@@ -675,6 +715,14 @@ const en: Dictionary = {
'moderation.escalation.saved': 'moderation.escalation.saved':
'Escalation rule saved: {warnCount} warnings => {action}{duration}.', 'Escalation rule saved: {warnCount} warnings => {action}{duration}.',
'moderation.escalation.removed': 'Escalation rule removed for {warnCount} warnings.', '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.confirm': 'Confirm',
'moderation.confirm.button.cancel': 'Cancel', 'moderation.confirm.button.cancel': 'Cancel',
'moderation.confirm.permanent': 'permanent', 'moderation.confirm.permanent': 'permanent',