Update ESLint rules, enhance environment schema with new metrics and health port, and refactor Redis import. Change TypeScript definitions path in package.json.
This commit is contained in:
@@ -13,7 +13,6 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
ignorePatterns: ["dist", ".turbo", "node_modules"],
|
ignorePatterns: ["dist", ".turbo", "node_modules"],
|
||||||
rules: {
|
rules: {
|
||||||
"@typescript-eslint/no-explicit-any": "error",
|
"@typescript-eslint/no-explicit-any": "error"
|
||||||
"@typescript-eslint/no-floating-promises": "error"
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
25
apps/bot/src/commands.ts
Normal file
25
apps/bot/src/commands.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -11,7 +11,9 @@ const EnvSchema = z.object({
|
|||||||
REDIS_URL: z.string().url(),
|
REDIS_URL: z.string().url(),
|
||||||
LOG_LEVEL: z.string().default('info'),
|
LOG_LEVEL: z.string().default('info'),
|
||||||
DEFAULT_LOCALE: z.enum(['de', 'en']).default('de'),
|
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);
|
export const env = EnvSchema.parse(process.env);
|
||||||
|
|||||||
32
apps/bot/src/health.ts
Normal file
32
apps/bot/src/health.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
63
apps/bot/src/index.ts
Normal file
63
apps/bot/src/index.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
39
apps/bot/src/jobs.ts
Normal file
39
apps/bot/src/jobs.ts
Normal file
@@ -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<TempBanJob>(
|
||||||
|
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];
|
||||||
|
}
|
||||||
498
apps/bot/src/modules/moderation/commands.ts
Normal file
498
apps/bot/src/modules/moderation/commands.ts
Normal file
@@ -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<boolean> {
|
||||||
|
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
|
||||||
|
];
|
||||||
16
apps/bot/src/modules/moderation/duration.test.ts
Normal file
16
apps/bot/src/modules/moderation/duration.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
10
apps/bot/src/modules/moderation/duration.ts
Normal file
10
apps/bot/src/modules/moderation/duration.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
47
apps/bot/src/modules/moderation/service.ts
Normal file
47
apps/bot/src/modules/moderation/service.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
27
apps/bot/src/permissions.ts
Normal file
27
apps/bot/src/permissions.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { type ChatInputCommandInteraction } from 'discord.js';
|
||||||
|
import { t } from '@nexumi/shared';
|
||||||
|
|
||||||
|
export async function requirePermission(
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
permission: bigint
|
||||||
|
): Promise<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import IORedis from 'ioredis';
|
import { Redis } from 'ioredis';
|
||||||
import { env } from './env.js';
|
import { env } from './env.js';
|
||||||
|
|
||||||
export const redis = new IORedis(env.REDIS_URL, {
|
export const redis = new Redis(env.REDIS_URL, {
|
||||||
maxRetriesPerRequest: null
|
maxRetriesPerRequest: null
|
||||||
});
|
});
|
||||||
|
|||||||
14
apps/bot/src/types.ts
Normal file
14
apps/bot/src/types.ts
Normal file
@@ -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<void>;
|
||||||
|
};
|
||||||
23
eslint.config.js
Normal file
23
eslint.config.js
Normal file
@@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"lint": "eslint src --ext .ts",
|
"lint": "eslint src --ext .ts",
|
||||||
|
|||||||
Reference in New Issue
Block a user