Add backup functionality with escalation rules for moderation commands
- Updated .env.example to include backup configuration options. - Modified docker-compose.yml to mount backup volume. - Enhanced Dockerfile to install PostgreSQL client. - Added EscalationRule model to Prisma schema for managing warning escalation. - Updated environment validation in env.ts to include backup settings. - Refactored job handling in jobs.ts to implement backup jobs and cleanup. - Introduced escalation commands in moderation service for managing user warnings. - Updated moderation commands to apply escalation rules based on warning count.
This commit is contained in:
@@ -12,6 +12,8 @@ const EnvSchema = z.object({
|
||||
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_CRON: z.string().default('0 3 * * *'),
|
||||
BACKUP_DIR: z.string().default('/backups'),
|
||||
METRICS_TOKEN: z.string().optional(),
|
||||
HEALTH_PORT: z.coerce.number().int().positive().default(8080)
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ 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 { ensureRecurringJobs, startWorkers } from './jobs.js';
|
||||
import type { BotContext } from './types.js';
|
||||
import { startHealthServer } from './health.js';
|
||||
|
||||
@@ -39,6 +39,7 @@ if (!isShard) {
|
||||
|
||||
const context: BotContext = { client, prisma, redis };
|
||||
startWorkers(context);
|
||||
await ensureRecurringJobs();
|
||||
|
||||
client.on(Events.ClientReady, async () => {
|
||||
logger.info({ tag: client.user?.tag }, 'Bot ready');
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { Queue, Worker } from 'bullmq';
|
||||
import { PermissionFlagsBits } from 'discord.js';
|
||||
import { mkdir, readdir, rm, stat } from 'node:fs/promises';
|
||||
import { basename, join } from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { redis } from './redis.js';
|
||||
import { logger } from './logger.js';
|
||||
import type { BotContext } from './types.js';
|
||||
import { env } from './env.js';
|
||||
|
||||
export const moderationQueueName = 'moderation';
|
||||
export const moderationQueue = new Queue(moderationQueueName, { connection: redis });
|
||||
export const backupQueueName = 'backups';
|
||||
export const backupQueue = new Queue(backupQueueName, { connection: redis });
|
||||
|
||||
type TempBanJob = {
|
||||
guildId: string;
|
||||
@@ -14,7 +20,7 @@ type TempBanJob = {
|
||||
};
|
||||
|
||||
export function startWorkers(context: BotContext): Worker[] {
|
||||
const worker = new Worker<TempBanJob>(
|
||||
const moderationWorker = new Worker<TempBanJob>(
|
||||
moderationQueueName,
|
||||
async (job) => {
|
||||
if (job.name !== 'tempBanExpire') {
|
||||
@@ -31,9 +37,81 @@ export function startWorkers(context: BotContext): Worker[] {
|
||||
{ connection: redis }
|
||||
);
|
||||
|
||||
worker.on('failed', (job, error) => {
|
||||
moderationWorker.on('failed', (job, error) => {
|
||||
logger.error({ jobId: job?.id, error }, 'Moderation job failed');
|
||||
});
|
||||
|
||||
return [worker];
|
||||
const backupWorker = new Worker(
|
||||
backupQueueName,
|
||||
async (job) => {
|
||||
if (job.name !== 'dailyPgDump') {
|
||||
return;
|
||||
}
|
||||
await runPgDumpBackup();
|
||||
},
|
||||
{ connection: redis }
|
||||
);
|
||||
|
||||
backupWorker.on('failed', (job, error) => {
|
||||
logger.error({ jobId: job?.id, error }, 'Backup job failed');
|
||||
});
|
||||
|
||||
return [moderationWorker, backupWorker];
|
||||
}
|
||||
|
||||
export async function ensureRecurringJobs(): Promise<void> {
|
||||
await backupQueue.add(
|
||||
'dailyPgDump',
|
||||
{},
|
||||
{
|
||||
repeat: { pattern: env.BACKUP_CRON },
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 100
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function runPgDumpBackup(): Promise<void> {
|
||||
await mkdir(env.BACKUP_DIR, { recursive: true });
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filename = `nexumi-${timestamp}.sql`;
|
||||
const outputPath = join(env.BACKUP_DIR, filename);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const dump = spawn('pg_dump', ['--dbname', env.DATABASE_URL, '--file', outputPath], {
|
||||
stdio: 'pipe'
|
||||
});
|
||||
let stderr = '';
|
||||
dump.stderr.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString('utf8');
|
||||
});
|
||||
dump.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(stderr || `pg_dump exited with code ${code ?? 'unknown'}`));
|
||||
});
|
||||
});
|
||||
|
||||
await cleanupOldBackups();
|
||||
logger.info({ backupFile: basename(outputPath) }, 'Database backup finished');
|
||||
}
|
||||
|
||||
async function cleanupOldBackups(): Promise<void> {
|
||||
const files = await readdir(env.BACKUP_DIR, { withFileTypes: true });
|
||||
const cutoff = Date.now() - env.BACKUP_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||
await Promise.all(
|
||||
files
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.sql'))
|
||||
.map(async (entry) => {
|
||||
const fullPath = join(env.BACKUP_DIR, entry.name);
|
||||
const fileStat = await stat(fullPath);
|
||||
const created = fileStat.mtime.getTime();
|
||||
if (Number.isNaN(created) || created >= cutoff) {
|
||||
return;
|
||||
}
|
||||
await rm(fullPath, { force: true });
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
type GuildMember
|
||||
} from 'discord.js';
|
||||
import { t } from '@nexumi/shared';
|
||||
import type { SlashCommand, BotContext } from '../../types.js';
|
||||
import type { SlashCommand } from '../../types.js';
|
||||
import { requirePermission } from '../../permissions.js';
|
||||
import { createCase, scheduleTempBanExpire } from './service.js';
|
||||
import { applyWarnEscalation, createCase, scheduleTempBanExpire } from './service.js';
|
||||
import { parseDuration } from './duration.js';
|
||||
|
||||
function reasonFrom(i: ChatInputCommandInteraction): string {
|
||||
@@ -175,10 +175,102 @@ const warnCommand: SlashCommand = {
|
||||
.setName('clear')
|
||||
.setDescription('Clear warnings for user')
|
||||
.addUserOption((o) => o.setName('user').setDescription('Target').setRequired(true))
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
s
|
||||
.setName('escalation-set')
|
||||
.setDescription('Create or update escalation rule')
|
||||
.addIntegerOption((o) => o.setName('count').setDescription('Warning count threshold').setRequired(true).setMinValue(1).setMaxValue(50))
|
||||
.addStringOption((o) =>
|
||||
o
|
||||
.setName('action')
|
||||
.setDescription('Escalation action')
|
||||
.setRequired(true)
|
||||
.addChoices(
|
||||
{ name: 'timeout', value: 'TIMEOUT' },
|
||||
{ name: 'kick', value: 'KICK' },
|
||||
{ name: 'ban', value: 'BAN' }
|
||||
)
|
||||
)
|
||||
.addStringOption((o) => o.setName('duration').setDescription('Timeout duration (required for TIMEOUT)'))
|
||||
.addStringOption((o) => o.setName('reason').setDescription('Optional escalation reason'))
|
||||
)
|
||||
.addSubcommand((s) => s.setName('escalation-list').setDescription('List escalation rules'))
|
||||
.addSubcommand((s) =>
|
||||
s
|
||||
.setName('escalation-remove')
|
||||
.setDescription('Remove escalation rule')
|
||||
.addIntegerOption((o) => o.setName('count').setDescription('Warning count threshold').setRequired(true).setMinValue(1).setMaxValue(50))
|
||||
),
|
||||
async execute(interaction, context) {
|
||||
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers))) return;
|
||||
const sub = interaction.options.getSubcommand();
|
||||
if (sub === 'escalation-set') {
|
||||
const warnCount = interaction.options.getInteger('count', true);
|
||||
const action = interaction.options.getString('action', true);
|
||||
const durationInput = interaction.options.getString('duration');
|
||||
const reason = interaction.options.getString('reason');
|
||||
const durationMs = durationInput ? parseDuration(durationInput) : null;
|
||||
if (action === 'TIMEOUT' && !durationMs) {
|
||||
await interaction.reply({ content: 'Duration is required for TIMEOUT escalation.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
await context.prisma.escalationRule.upsert({
|
||||
where: {
|
||||
guildId_warnCount: {
|
||||
guildId: interaction.guildId!,
|
||||
warnCount
|
||||
}
|
||||
},
|
||||
update: {
|
||||
action,
|
||||
durationMs,
|
||||
reason,
|
||||
enabled: true
|
||||
},
|
||||
create: {
|
||||
guildId: interaction.guildId!,
|
||||
warnCount,
|
||||
action,
|
||||
durationMs,
|
||||
reason,
|
||||
enabled: true
|
||||
}
|
||||
});
|
||||
await interaction.reply({
|
||||
content: `Escalation rule saved: ${warnCount} warnings => ${action}${durationMs ? ` (${durationMs} ms)` : ''}.`,
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'escalation-list') {
|
||||
const rules = await context.prisma.escalationRule.findMany({
|
||||
where: { guildId: interaction.guildId!, enabled: true },
|
||||
orderBy: { warnCount: 'asc' }
|
||||
});
|
||||
const content =
|
||||
rules.length === 0
|
||||
? 'No escalation rules configured.'
|
||||
: rules
|
||||
.map(
|
||||
(rule) =>
|
||||
`- ${rule.warnCount} warnings => ${rule.action}${rule.durationMs ? ` (${rule.durationMs} ms)` : ''}${rule.reason ? ` | ${rule.reason}` : ''}`
|
||||
)
|
||||
.join('\n');
|
||||
await interaction.reply({ content, ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'escalation-remove') {
|
||||
const warnCount = interaction.options.getInteger('count', true);
|
||||
await context.prisma.escalationRule.delete({
|
||||
where: { guildId_warnCount: { guildId: interaction.guildId!, warnCount } }
|
||||
});
|
||||
await interaction.reply({ content: `Escalation rule removed for ${warnCount} warnings.`, ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'add') {
|
||||
const user = interaction.options.getUser('user', true);
|
||||
const reason = interaction.options.getString('reason', true);
|
||||
@@ -203,7 +295,18 @@ const warnCommand: SlashCommand = {
|
||||
caseId: modCase.id
|
||||
}
|
||||
});
|
||||
await interaction.reply({ content: `Warning created: ${warning.id}`, ephemeral: true });
|
||||
const escalationResult = await applyWarnEscalation(
|
||||
interaction,
|
||||
context,
|
||||
user.id,
|
||||
interaction.user.id
|
||||
);
|
||||
await interaction.reply({
|
||||
content: escalationResult
|
||||
? `Warning created: ${warning.id}\n${escalationResult}`
|
||||
: `Warning created: ${warning.id}`,
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { moderationQueue } from '../../jobs.js';
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js';
|
||||
|
||||
type CreateCaseInput = {
|
||||
guildId: string;
|
||||
@@ -11,6 +12,12 @@ type CreateCaseInput = {
|
||||
};
|
||||
|
||||
export async function createCase(context: BotContext, input: CreateCaseInput) {
|
||||
await context.prisma.guild.upsert({
|
||||
where: { id: input.guildId },
|
||||
update: {},
|
||||
create: { id: input.guildId }
|
||||
});
|
||||
|
||||
const lastCase = await context.prisma.case.findFirst({
|
||||
where: { guildId: input.guildId },
|
||||
orderBy: { caseNumber: 'desc' }
|
||||
@@ -45,3 +52,79 @@ export async function scheduleTempBanExpire(
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function applyWarnEscalation(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
context: BotContext,
|
||||
userId: string,
|
||||
moderatorId: string
|
||||
): Promise<string | null> {
|
||||
const guildId = interaction.guildId;
|
||||
if (!guildId || !interaction.guild) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const warningCount = await context.prisma.warning.count({
|
||||
where: { guildId, userId }
|
||||
});
|
||||
|
||||
const rule = await context.prisma.escalationRule.findUnique({
|
||||
where: { guildId_warnCount: { guildId, warnCount: warningCount } }
|
||||
});
|
||||
|
||||
if (!rule || !rule.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const member = await interaction.guild.members.fetch(userId);
|
||||
const reason = rule.reason ?? `Auto escalation at ${warningCount} warnings`;
|
||||
|
||||
if (rule.action === 'TIMEOUT') {
|
||||
if (!rule.durationMs) {
|
||||
return `Escalation rule for ${warningCount} warns skipped (missing duration).`;
|
||||
}
|
||||
if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.ModerateMembers)) {
|
||||
return `Escalation rule for ${warningCount} warns skipped (missing bot permissions).`;
|
||||
}
|
||||
await member.timeout(rule.durationMs, reason);
|
||||
await createCase(context, {
|
||||
guildId,
|
||||
targetUserId: userId,
|
||||
moderatorId,
|
||||
action: 'TIMEOUT',
|
||||
reason,
|
||||
metadata: { escalation: true, durationMs: rule.durationMs, warningCount }
|
||||
});
|
||||
return `Auto escalation applied: timeout (${rule.durationMs} ms).`;
|
||||
}
|
||||
|
||||
if (rule.action === 'KICK') {
|
||||
if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.KickMembers)) {
|
||||
return `Escalation rule for ${warningCount} warns skipped (missing bot permissions).`;
|
||||
}
|
||||
await member.kick(reason);
|
||||
await createCase(context, {
|
||||
guildId,
|
||||
targetUserId: userId,
|
||||
moderatorId,
|
||||
action: 'KICK',
|
||||
reason,
|
||||
metadata: { escalation: true, warningCount }
|
||||
});
|
||||
return 'Auto escalation applied: kick.';
|
||||
}
|
||||
|
||||
if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.BanMembers)) {
|
||||
return `Escalation rule for ${warningCount} warns skipped (missing bot permissions).`;
|
||||
}
|
||||
await interaction.guild.members.ban(userId, { reason });
|
||||
await createCase(context, {
|
||||
guildId,
|
||||
targetUserId: userId,
|
||||
moderatorId,
|
||||
action: 'BAN',
|
||||
reason,
|
||||
metadata: { escalation: true, warningCount }
|
||||
});
|
||||
return 'Auto escalation applied: ban.';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user