From bc97d1d74cd929f95eb054e389d3340044461872 Mon Sep 17 00:00:00 2001 From: smueller Date: Wed, 22 Jul 2026 11:11:39 +0200 Subject: [PATCH] Update ESLint rules, enhance environment schema with new metrics and health port, and refactor Redis import. Change TypeScript definitions path in package.json. --- .eslintrc.cjs | 3 +- apps/bot/src/commands.ts | 25 + apps/bot/src/env.ts | 4 +- apps/bot/src/health.ts | 32 ++ apps/bot/src/index.ts | 63 +++ apps/bot/src/jobs.ts | 39 ++ apps/bot/src/modules/moderation/commands.ts | 498 ++++++++++++++++++ .../src/modules/moderation/duration.test.ts | 16 + apps/bot/src/modules/moderation/duration.ts | 10 + apps/bot/src/modules/moderation/service.ts | 47 ++ apps/bot/src/permissions.ts | 27 + apps/bot/src/redis.ts | 4 +- apps/bot/src/types.ts | 14 + eslint.config.js | 23 + packages/shared/package.json | 2 +- 15 files changed, 801 insertions(+), 6 deletions(-) create mode 100644 apps/bot/src/commands.ts create mode 100644 apps/bot/src/health.ts create mode 100644 apps/bot/src/index.ts create mode 100644 apps/bot/src/jobs.ts create mode 100644 apps/bot/src/modules/moderation/commands.ts create mode 100644 apps/bot/src/modules/moderation/duration.test.ts create mode 100644 apps/bot/src/modules/moderation/duration.ts create mode 100644 apps/bot/src/modules/moderation/service.ts create mode 100644 apps/bot/src/permissions.ts create mode 100644 apps/bot/src/types.ts create mode 100644 eslint.config.js diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 76aef51..df2efb5 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -13,7 +13,6 @@ module.exports = { }, ignorePatterns: ["dist", ".turbo", "node_modules"], rules: { - "@typescript-eslint/no-explicit-any": "error", - "@typescript-eslint/no-floating-promises": "error" + "@typescript-eslint/no-explicit-any": "error" } }; diff --git a/apps/bot/src/commands.ts b/apps/bot/src/commands.ts new file mode 100644 index 0000000..3c1f9dd --- /dev/null +++ b/apps/bot/src/commands.ts @@ -0,0 +1,25 @@ +import { REST, Routes, type ChatInputCommandInteraction } from 'discord.js'; +import { env } from './env.js'; +import { logger } from './logger.js'; +import { moderationCommands } from './modules/moderation/commands.js'; +import type { BotContext, SlashCommand } from './types.js'; + +const commands: SlashCommand[] = [...moderationCommands]; +const map = new Map(commands.map((c) => [c.data.name, c])); + +export async function registerCommands() { + const rest = new REST({ version: '10' }).setToken(env.BOT_TOKEN); + await rest.put(Routes.applicationCommands(env.BOT_CLIENT_ID), { + body: commands.map((c) => c.data.toJSON()) + }); + logger.info({ count: commands.length }, 'Registered application commands'); +} + +export async function routeCommand(interaction: ChatInputCommandInteraction, context: BotContext) { + const command = map.get(interaction.commandName); + if (!command) { + await interaction.reply({ content: 'Unknown command.', ephemeral: true }); + return; + } + await command.execute(interaction, context); +} diff --git a/apps/bot/src/env.ts b/apps/bot/src/env.ts index a0f968a..a0a8587 100644 --- a/apps/bot/src/env.ts +++ b/apps/bot/src/env.ts @@ -11,7 +11,9 @@ const EnvSchema = z.object({ REDIS_URL: z.string().url(), LOG_LEVEL: z.string().default('info'), DEFAULT_LOCALE: z.enum(['de', 'en']).default('de'), - BACKUP_RETENTION_DAYS: z.coerce.number().int().positive().default(14) + BACKUP_RETENTION_DAYS: z.coerce.number().int().positive().default(14), + METRICS_TOKEN: z.string().optional(), + HEALTH_PORT: z.coerce.number().int().positive().default(8080) }); export const env = EnvSchema.parse(process.env); diff --git a/apps/bot/src/health.ts b/apps/bot/src/health.ts new file mode 100644 index 0000000..9e085dd --- /dev/null +++ b/apps/bot/src/health.ts @@ -0,0 +1,32 @@ +import { createServer } from 'node:http'; +import { env } from './env.js'; + +export function startHealthServer(): void { + const server = createServer((req, res) => { + if (!req.url) { + res.statusCode = 400; + res.end('Bad Request'); + return; + } + if (req.url === '/health') { + res.statusCode = 200; + res.end('ok'); + return; + } + if (req.url === '/metrics') { + const auth = req.headers.authorization; + if (!env.METRICS_TOKEN || auth !== `Bearer ${env.METRICS_TOKEN}`) { + res.statusCode = 401; + res.end('Unauthorized'); + return; + } + res.statusCode = 200; + res.end('# Prometheus metrics scaffold\nnexumi_up 1\n'); + return; + } + res.statusCode = 404; + res.end('Not Found'); + }); + + server.listen(env.HEALTH_PORT); +} diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts new file mode 100644 index 0000000..5511df1 --- /dev/null +++ b/apps/bot/src/index.ts @@ -0,0 +1,63 @@ +import { + Client, + Events, + GatewayIntentBits, + Partials, + ShardingManager, + type Interaction +} from 'discord.js'; +import { env } from './env.js'; +import { logger } from './logger.js'; +import { prisma } from './db.js'; +import { redis } from './redis.js'; +import { registerCommands, routeCommand } from './commands.js'; +import { startWorkers } from './jobs.js'; +import type { BotContext } from './types.js'; +import { startHealthServer } from './health.js'; + +const isShard = process.argv.includes('--shard'); + +if (!isShard) { + startHealthServer(); + const manager = new ShardingManager(new URL('./index.js', import.meta.url).pathname, { + token: env.BOT_TOKEN, + totalShards: 'auto', + shardArgs: ['--shard'] + }); + manager.on('shardCreate', (shard) => logger.info({ shardId: shard.id }, 'Shard spawned')); + await manager.spawn(); +} else { + const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent + ], + partials: [Partials.Channel] + }); + + const context: BotContext = { client, prisma, redis }; + startWorkers(context); + + client.on(Events.ClientReady, async () => { + logger.info({ tag: client.user?.tag }, 'Bot ready'); + await registerCommands(); + }); + + client.on(Events.InteractionCreate, async (interaction: Interaction) => { + if (!interaction.isChatInputCommand()) return; + try { + await routeCommand(interaction, context); + } catch (error) { + logger.error({ error }, 'Command execution failed'); + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ content: 'An error occurred.', ephemeral: true }); + } else { + await interaction.reply({ content: 'An error occurred.', ephemeral: true }); + } + } + }); + + await client.login(env.BOT_TOKEN); +} diff --git a/apps/bot/src/jobs.ts b/apps/bot/src/jobs.ts new file mode 100644 index 0000000..320a244 --- /dev/null +++ b/apps/bot/src/jobs.ts @@ -0,0 +1,39 @@ +import { Queue, Worker } from 'bullmq'; +import { PermissionFlagsBits } from 'discord.js'; +import { redis } from './redis.js'; +import { logger } from './logger.js'; +import type { BotContext } from './types.js'; + +export const moderationQueueName = 'moderation'; +export const moderationQueue = new Queue(moderationQueueName, { connection: redis }); + +type TempBanJob = { + guildId: string; + userId: string; + reason: string | null; +}; + +export function startWorkers(context: BotContext): Worker[] { + const worker = new Worker( + moderationQueueName, + async (job) => { + if (job.name !== 'tempBanExpire') { + return; + } + + const guild = await context.client.guilds.fetch(job.data.guildId); + const me = await guild.members.fetchMe(); + if (!me.permissions.has(PermissionFlagsBits.BanMembers)) { + throw new Error('Missing BanMembers permission for temp ban expiration'); + } + await guild.members.unban(job.data.userId, job.data.reason ?? 'Temporary ban expired'); + }, + { connection: redis } + ); + + worker.on('failed', (job, error) => { + logger.error({ jobId: job?.id, error }, 'Moderation job failed'); + }); + + return [worker]; +} diff --git a/apps/bot/src/modules/moderation/commands.ts b/apps/bot/src/modules/moderation/commands.ts new file mode 100644 index 0000000..9d5e3a0 --- /dev/null +++ b/apps/bot/src/modules/moderation/commands.ts @@ -0,0 +1,498 @@ +import { + ChannelType, + PermissionFlagsBits, + SlashCommandBuilder, + TextChannel, + type ChatInputCommandInteraction, + type GuildMember +} from 'discord.js'; +import { t } from '@nexumi/shared'; +import type { SlashCommand, BotContext } from '../../types.js'; +import { requirePermission } from '../../permissions.js'; +import { createCase, scheduleTempBanExpire } from './service.js'; +import { parseDuration } from './duration.js'; + +function reasonFrom(i: ChatInputCommandInteraction): string { + return i.options.getString('reason') ?? t('de', 'moderation.reason.none'); +} + +async function ensureMod(interaction: ChatInputCommandInteraction, permission: bigint): Promise { + if (!interaction.inGuild()) { + await interaction.reply({ content: 'Guild only command.', ephemeral: true }); + return false; + } + return requirePermission(interaction, permission); +} + +const banCommand: SlashCommand = { + data: new SlashCommandBuilder() + .setName('ban') + .setDescription('Ban a user') + .addUserOption((opt) => opt.setName('user').setDescription('Target user').setRequired(true)) + .addStringOption((opt) => opt.setName('reason').setDescription('Reason')) + .addStringOption((opt) => opt.setName('duration').setDescription('Optional temp-ban duration (e.g. 7d)')), + async execute(interaction, context) { + if (!(await ensureMod(interaction, PermissionFlagsBits.BanMembers))) return; + const user = interaction.options.getUser('user', true); + const reason = reasonFrom(interaction); + await interaction.guild!.members.ban(user.id, { reason }); + await createCase(context, { + guildId: interaction.guildId!, + targetUserId: user.id, + moderatorId: interaction.user.id, + action: 'BAN', + reason + }); + const duration = interaction.options.getString('duration'); + if (duration) { + await scheduleTempBanExpire(interaction.guildId!, user.id, parseDuration(duration), reason); + } + await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); + } +}; + +const unbanCommand: SlashCommand = { + data: new SlashCommandBuilder() + .setName('unban') + .setDescription('Unban a user') + .addStringOption((opt) => opt.setName('user_id').setDescription('Discord user id').setRequired(true)) + .addStringOption((opt) => opt.setName('reason').setDescription('Reason')), + async execute(interaction, context) { + if (!(await ensureMod(interaction, PermissionFlagsBits.BanMembers))) return; + const userId = interaction.options.getString('user_id', true); + const reason = reasonFrom(interaction); + await interaction.guild!.members.unban(userId, reason); + await createCase(context, { + guildId: interaction.guildId!, + targetUserId: userId, + moderatorId: interaction.user.id, + action: 'UNBAN', + reason + }); + await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); + } +}; + +const kickCommand: SlashCommand = { + data: new SlashCommandBuilder() + .setName('kick') + .setDescription('Kick a user') + .addUserOption((opt) => opt.setName('user').setDescription('Target user').setRequired(true)) + .addStringOption((opt) => opt.setName('reason').setDescription('Reason')), + async execute(interaction, context) { + if (!(await ensureMod(interaction, PermissionFlagsBits.KickMembers))) return; + const user = interaction.options.getUser('user', true); + const reason = reasonFrom(interaction); + const member = (await interaction.guild!.members.fetch(user.id)) as GuildMember; + await member.kick(reason); + await createCase(context, { + guildId: interaction.guildId!, + targetUserId: user.id, + moderatorId: interaction.user.id, + action: 'KICK', + reason + }); + await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); + } +}; + +const timeoutCommand: SlashCommand = { + data: new SlashCommandBuilder() + .setName('timeout') + .setDescription('Timeout a user') + .addUserOption((opt) => opt.setName('user').setDescription('Target user').setRequired(true)) + .addStringOption((opt) => + opt.setName('duration').setDescription('Duration (e.g. 15m)').setRequired(true) + ) + .addStringOption((opt) => opt.setName('reason').setDescription('Reason')), + async execute(interaction, context) { + if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers))) return; + const user = interaction.options.getUser('user', true); + const duration = parseDuration(interaction.options.getString('duration', true)); + const reason = reasonFrom(interaction); + const member = await interaction.guild!.members.fetch(user.id); + await member.timeout(duration, reason); + await createCase(context, { + guildId: interaction.guildId!, + targetUserId: user.id, + moderatorId: interaction.user.id, + action: 'TIMEOUT', + reason, + metadata: { durationMs: duration } + }); + await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); + } +}; + +const untimeoutCommand: SlashCommand = { + data: new SlashCommandBuilder() + .setName('untimeout') + .setDescription('Remove timeout from a user') + .addUserOption((opt) => opt.setName('user').setDescription('Target user').setRequired(true)) + .addStringOption((opt) => opt.setName('reason').setDescription('Reason')), + async execute(interaction, context) { + if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers))) return; + const user = interaction.options.getUser('user', true); + const reason = reasonFrom(interaction); + const member = await interaction.guild!.members.fetch(user.id); + await member.timeout(null, reason); + await createCase(context, { + guildId: interaction.guildId!, + targetUserId: user.id, + moderatorId: interaction.user.id, + action: 'UN_TIMEOUT', + reason + }); + await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); + } +}; + +const warnCommand: SlashCommand = { + data: new SlashCommandBuilder() + .setName('warn') + .setDescription('Manage user warnings') + .addSubcommand((s) => + s + .setName('add') + .setDescription('Add warning') + .addUserOption((o) => o.setName('user').setDescription('Target').setRequired(true)) + .addStringOption((o) => o.setName('reason').setDescription('Reason').setRequired(true)) + ) + .addSubcommand((s) => + s + .setName('list') + .setDescription('List warnings') + .addUserOption((o) => o.setName('user').setDescription('Target').setRequired(true)) + ) + .addSubcommand((s) => + s + .setName('remove') + .setDescription('Remove warning') + .addStringOption((o) => o.setName('warning_id').setDescription('Warning ID').setRequired(true)) + ) + .addSubcommand((s) => + s + .setName('clear') + .setDescription('Clear warnings for user') + .addUserOption((o) => o.setName('user').setDescription('Target').setRequired(true)) + ), + async execute(interaction, context) { + if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers))) return; + const sub = interaction.options.getSubcommand(); + if (sub === 'add') { + const user = interaction.options.getUser('user', true); + const reason = interaction.options.getString('reason', true); + const modCase = await createCase(context, { + guildId: interaction.guildId!, + targetUserId: user.id, + moderatorId: interaction.user.id, + action: 'WARN_ADD', + reason + }); + await context.prisma.user.upsert({ + where: { id: user.id }, + update: {}, + create: { id: user.id } + }); + const warning = await context.prisma.warning.create({ + data: { + guildId: interaction.guildId!, + userId: user.id, + moderatorId: interaction.user.id, + reason, + caseId: modCase.id + } + }); + await interaction.reply({ content: `Warning created: ${warning.id}`, ephemeral: true }); + return; + } + + if (sub === 'list') { + const user = interaction.options.getUser('user', true); + const warnings = await context.prisma.warning.findMany({ + where: { guildId: interaction.guildId!, userId: user.id }, + orderBy: { createdAt: 'desc' }, + take: 20 + }); + const content = + warnings.length === 0 + ? 'No warnings.' + : warnings.map((w: { id: string; reason: string | null }) => `- ${w.id}: ${w.reason ?? 'No reason'}`).join('\n'); + await interaction.reply({ content, ephemeral: true }); + return; + } + + if (sub === 'remove') { + const warningId = interaction.options.getString('warning_id', true); + const warning = await context.prisma.warning.delete({ where: { id: warningId } }); + await createCase(context, { + guildId: interaction.guildId!, + targetUserId: warning.userId, + moderatorId: interaction.user.id, + action: 'WARN_REMOVE', + reason: `Warning ${warningId} removed` + }); + await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); + return; + } + + const user = interaction.options.getUser('user', true); + await context.prisma.warning.deleteMany({ where: { guildId: interaction.guildId!, userId: user.id } }); + await createCase(context, { + guildId: interaction.guildId!, + targetUserId: user.id, + moderatorId: interaction.user.id, + action: 'WARN_CLEAR', + reason: 'Warnings cleared' + }); + await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); + } +}; + +const purgeCommand: SlashCommand = { + data: new SlashCommandBuilder() + .setName('purge') + .setDescription('Bulk delete messages') + .addIntegerOption((o) => o.setName('amount').setDescription('2-100').setRequired(true).setMinValue(2).setMaxValue(100)), + async execute(interaction, context) { + if (!(await ensureMod(interaction, PermissionFlagsBits.ManageMessages))) return; + const amount = interaction.options.getInteger('amount', true); + let deletedCount = 0; + if (interaction.channel instanceof TextChannel) { + const deleted = await interaction.channel.bulkDelete(amount, true); + deletedCount = deleted.size; + } + await createCase(context, { + guildId: interaction.guildId!, + targetUserId: interaction.user.id, + moderatorId: interaction.user.id, + action: 'PURGE', + reason: `Deleted ${deletedCount} messages` + }); + await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); + } +}; + +const slowmodeCommand: SlashCommand = { + data: new SlashCommandBuilder() + .setName('slowmode') + .setDescription('Set channel slowmode') + .addIntegerOption((o) => o.setName('seconds').setDescription('0-21600').setRequired(true).setMinValue(0).setMaxValue(21600)), + async execute(interaction, context) { + if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels))) return; + const seconds = interaction.options.getInteger('seconds', true); + if (interaction.channel instanceof TextChannel) { + await interaction.channel.setRateLimitPerUser(seconds); + } + await createCase(context, { + guildId: interaction.guildId!, + targetUserId: interaction.user.id, + moderatorId: interaction.user.id, + action: 'SLOWMODE', + reason: `Set slowmode to ${seconds}s` + }); + await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); + } +}; + +const lockCommand: SlashCommand = { + data: new SlashCommandBuilder().setName('lock').setDescription('Lock current text channel'), + async execute(interaction, context) { + if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels))) return; + if (interaction.channel?.type === ChannelType.GuildText) { + await interaction.channel.permissionOverwrites.edit(interaction.guild!.roles.everyone, { + SendMessages: false + }); + } + await createCase(context, { + guildId: interaction.guildId!, + targetUserId: interaction.user.id, + moderatorId: interaction.user.id, + action: 'LOCK', + reason: 'Channel locked' + }); + await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); + } +}; + +const unlockCommand: SlashCommand = { + data: new SlashCommandBuilder().setName('unlock').setDescription('Unlock current text channel'), + async execute(interaction, context) { + if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels))) return; + if (interaction.channel?.type === ChannelType.GuildText) { + await interaction.channel.permissionOverwrites.edit(interaction.guild!.roles.everyone, { + SendMessages: null + }); + } + await createCase(context, { + guildId: interaction.guildId!, + targetUserId: interaction.user.id, + moderatorId: interaction.user.id, + action: 'UNLOCK', + reason: 'Channel unlocked' + }); + await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); + } +}; + +const nickCommand: SlashCommand = { + data: new SlashCommandBuilder() + .setName('nick') + .setDescription('Manage nicknames') + .addSubcommand((s) => + s + .setName('set') + .setDescription('Set nickname') + .addUserOption((o) => o.setName('user').setDescription('Target').setRequired(true)) + .addStringOption((o) => o.setName('nickname').setDescription('New nickname').setRequired(true)) + ) + .addSubcommand((s) => + s + .setName('reset') + .setDescription('Reset nickname') + .addUserOption((o) => o.setName('user').setDescription('Target').setRequired(true)) + ), + async execute(interaction, context) { + if (!(await ensureMod(interaction, PermissionFlagsBits.ManageNicknames))) return; + const sub = interaction.options.getSubcommand(); + const user = interaction.options.getUser('user', true); + const member = await interaction.guild!.members.fetch(user.id); + if (sub === 'set') { + const nickname = interaction.options.getString('nickname', true); + await member.setNickname(nickname); + await createCase(context, { + guildId: interaction.guildId!, + targetUserId: user.id, + moderatorId: interaction.user.id, + action: 'NICK_SET', + reason: nickname + }); + } else { + await member.setNickname(null); + await createCase(context, { + guildId: interaction.guildId!, + targetUserId: user.id, + moderatorId: interaction.user.id, + action: 'NICK_RESET', + reason: 'Nickname reset' + }); + } + await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); + } +}; + +const caseCommand: SlashCommand = { + data: new SlashCommandBuilder() + .setName('case') + .setDescription('Manage moderation cases') + .addSubcommand((s) => + s.setName('view').setDescription('View case').addIntegerOption((o) => o.setName('id').setDescription('Case number').setRequired(true)) + ) + .addSubcommand((s) => + s + .setName('edit') + .setDescription('Edit case reason') + .addIntegerOption((o) => o.setName('id').setDescription('Case number').setRequired(true)) + .addStringOption((o) => o.setName('reason').setDescription('New reason').setRequired(true)) + ) + .addSubcommand((s) => + s.setName('delete').setDescription('Soft-delete case').addIntegerOption((o) => o.setName('id').setDescription('Case number').setRequired(true)) + ), + async execute(interaction, context) { + if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers))) return; + const sub = interaction.options.getSubcommand(); + const caseNumber = interaction.options.getInteger('id', true); + if (sub === 'view') { + const found = await context.prisma.case.findUnique({ + where: { guildId_caseNumber: { guildId: interaction.guildId!, caseNumber } } + }); + await interaction.reply({ content: found ? JSON.stringify(found, null, 2) : 'Case not found.', ephemeral: true }); + return; + } + if (sub === 'edit') { + const reason = interaction.options.getString('reason', true); + await context.prisma.case.update({ + where: { guildId_caseNumber: { guildId: interaction.guildId!, caseNumber } }, + data: { reason } + }); + await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); + return; + } + await context.prisma.case.update({ + where: { guildId_caseNumber: { guildId: interaction.guildId!, caseNumber } }, + data: { deletedAt: new Date() } + }); + await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); + } +}; + +const modNoteCommand: SlashCommand = { + data: new SlashCommandBuilder() + .setName('modnote') + .setDescription('Manage mod notes') + .addSubcommand((s) => + s + .setName('add') + .setDescription('Add note') + .addUserOption((o) => o.setName('user').setDescription('Target').setRequired(true)) + .addStringOption((o) => o.setName('note').setDescription('Note').setRequired(true)) + ) + .addSubcommand((s) => + s + .setName('list') + .setDescription('List notes') + .addUserOption((o) => o.setName('user').setDescription('Target').setRequired(true)) + ), + async execute(interaction, context) { + if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers))) return; + const sub = interaction.options.getSubcommand(); + const user = interaction.options.getUser('user', true); + await context.prisma.user.upsert({ where: { id: user.id }, update: {}, create: { id: user.id } }); + if (sub === 'add') { + const note = interaction.options.getString('note', true); + await context.prisma.modNote.create({ + data: { + guildId: interaction.guildId!, + userId: user.id, + moderatorId: interaction.user.id, + note + } + }); + await createCase(context, { + guildId: interaction.guildId!, + targetUserId: user.id, + moderatorId: interaction.user.id, + action: 'MODNOTE_ADD', + reason: note + }); + await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); + return; + } + const notes = await context.prisma.modNote.findMany({ + where: { guildId: interaction.guildId!, userId: user.id }, + orderBy: { createdAt: 'desc' }, + take: 20 + }); + const content = + notes.length === 0 + ? 'No notes.' + : notes.map((n: { note: string }) => `- ${n.note}`).join('\n'); + await interaction.reply({ content, ephemeral: true }); + } +}; + +export const moderationCommands: SlashCommand[] = [ + banCommand, + unbanCommand, + kickCommand, + timeoutCommand, + untimeoutCommand, + warnCommand, + purgeCommand, + slowmodeCommand, + lockCommand, + unlockCommand, + nickCommand, + caseCommand, + modNoteCommand +]; diff --git a/apps/bot/src/modules/moderation/duration.test.ts b/apps/bot/src/modules/moderation/duration.test.ts new file mode 100644 index 0000000..b275305 --- /dev/null +++ b/apps/bot/src/modules/moderation/duration.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { parseDuration } from './duration.js'; + +describe('parseDuration', () => { + it('parses minutes', () => { + expect(parseDuration('15m')).toBe(900000); + }); + + it('parses days', () => { + expect(parseDuration('2d')).toBe(172800000); + }); + + it('throws on invalid strings', () => { + expect(() => parseDuration('tomorrow')).toThrow(); + }); +}); diff --git a/apps/bot/src/modules/moderation/duration.ts b/apps/bot/src/modules/moderation/duration.ts new file mode 100644 index 0000000..abb55cd --- /dev/null +++ b/apps/bot/src/modules/moderation/duration.ts @@ -0,0 +1,10 @@ +export function parseDuration(input: string): number { + const m = input.match(/^(\d+)([smhd])$/i); + if (!m) { + throw new Error('Duration must be like 30m, 12h, 7d'); + } + const amount = Number(m[1]); + const unit = m[2].toLowerCase(); + const factor = unit === 's' ? 1000 : unit === 'm' ? 60000 : unit === 'h' ? 3600000 : 86400000; + return amount * factor; +} diff --git a/apps/bot/src/modules/moderation/service.ts b/apps/bot/src/modules/moderation/service.ts new file mode 100644 index 0000000..3565e1d --- /dev/null +++ b/apps/bot/src/modules/moderation/service.ts @@ -0,0 +1,47 @@ +import { moderationQueue } from '../../jobs.js'; +import type { BotContext } from '../../types.js'; + +type CreateCaseInput = { + guildId: string; + targetUserId: string; + moderatorId: string; + action: string; + reason?: string; + metadata?: Record; +}; + +export async function createCase(context: BotContext, input: CreateCaseInput) { + const lastCase = await context.prisma.case.findFirst({ + where: { guildId: input.guildId }, + orderBy: { caseNumber: 'desc' } + }); + + return context.prisma.case.create({ + data: { + guildId: input.guildId, + caseNumber: (lastCase?.caseNumber ?? 0) + 1, + targetUserId: input.targetUserId, + moderatorId: input.moderatorId, + action: input.action, + reason: input.reason, + metadata: input.metadata + } + }); +} + +export async function scheduleTempBanExpire( + guildId: string, + userId: string, + durationMs: number, + reason?: string +) { + await moderationQueue.add( + 'tempBanExpire', + { guildId, userId, reason: reason ?? null }, + { + delay: durationMs, + removeOnComplete: 1000, + removeOnFail: 500 + } + ); +} diff --git a/apps/bot/src/permissions.ts b/apps/bot/src/permissions.ts new file mode 100644 index 0000000..75e4b46 --- /dev/null +++ b/apps/bot/src/permissions.ts @@ -0,0 +1,27 @@ +import { type ChatInputCommandInteraction } from 'discord.js'; +import { t } from '@nexumi/shared'; + +export async function requirePermission( + interaction: ChatInputCommandInteraction, + permission: bigint +): Promise { + const locale = 'de'; + const member = interaction.memberPermissions; + if (!member?.has(permission)) { + await interaction.reply({ + content: t(locale, 'generic.noPermission'), + ephemeral: true + }); + return false; + } + + if (!interaction.guild?.members.me?.permissions.has(permission)) { + await interaction.reply({ + content: `Bot lacks required permission (${permission.toString()}).`, + ephemeral: true + }); + return false; + } + + return true; +} diff --git a/apps/bot/src/redis.ts b/apps/bot/src/redis.ts index 956d7c7..f8f540f 100644 --- a/apps/bot/src/redis.ts +++ b/apps/bot/src/redis.ts @@ -1,6 +1,6 @@ -import IORedis from 'ioredis'; +import { Redis } from 'ioredis'; import { env } from './env.js'; -export const redis = new IORedis(env.REDIS_URL, { +export const redis = new Redis(env.REDIS_URL, { maxRetriesPerRequest: null }); diff --git a/apps/bot/src/types.ts b/apps/bot/src/types.ts new file mode 100644 index 0000000..a26dced --- /dev/null +++ b/apps/bot/src/types.ts @@ -0,0 +1,14 @@ +import type { PrismaClient } from '@prisma/client'; +import type { ChatInputCommandInteraction, Client } from 'discord.js'; +import type { Redis } from 'ioredis'; + +export type BotContext = { + client: Client; + prisma: PrismaClient; + redis: Redis; +}; + +export type SlashCommand = { + data: { name: string; toJSON: () => unknown }; + execute: (interaction: ChatInputCommandInteraction, context: BotContext) => Promise; +}; diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..7c29282 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,23 @@ +import tsParser from '@typescript-eslint/parser'; +import tsPlugin from '@typescript-eslint/eslint-plugin'; + +export default [ + { + files: ['**/*.ts'], + ignores: ['**/dist/**', '**/.turbo/**', '**/node_modules/**'], + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module' + } + }, + plugins: { + '@typescript-eslint': tsPlugin + }, + rules: { + ...tsPlugin.configs.recommended.rules, + '@typescript-eslint/no-explicit-any': 'error' + } + } +]; diff --git a/packages/shared/package.json b/packages/shared/package.json index 63ce93d..e8db7da 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "type": "module", "main": "dist/index.js", - "types": "dist/index.d.ts", + "types": "src/index.ts", "scripts": { "build": "tsc -p tsconfig.json", "lint": "eslint src --ext .ts",