Compare commits
2 Commits
bc97d1d74c
...
b0e3409793
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0e3409793 | ||
|
|
dd3b132f73 |
@@ -9,3 +9,6 @@ DEFAULT_LOCALE=de
|
|||||||
METRICS_TOKEN=
|
METRICS_TOKEN=
|
||||||
SENTRY_DSN=
|
SENTRY_DSN=
|
||||||
BACKUP_RETENTION_DAYS=14
|
BACKUP_RETENTION_DAYS=14
|
||||||
|
BACKUP_CRON=0 3 * * *
|
||||||
|
BACKUP_DIR=/backups
|
||||||
|
HEALTH_PORT=8080
|
||||||
|
|||||||
43
README.md
Normal file
43
README.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Nexumi
|
||||||
|
|
||||||
|
Nexumi is a self-hosted public Discord bot with dashboard architecture, built as a TypeScript monorepo.
|
||||||
|
|
||||||
|
## Phase 1 scope
|
||||||
|
|
||||||
|
- Monorepo foundation with `apps/bot` and `packages/shared`
|
||||||
|
- PostgreSQL + Redis compose stack
|
||||||
|
- Prisma core schema (`Guild`, `GuildSettings`, `User`, `Case`) plus moderation tables (`Warning`, `ModNote`)
|
||||||
|
- Command and module framework for slash command routing
|
||||||
|
- Permission layer and i18n starter (`de` + `en`)
|
||||||
|
- BullMQ integration for persistent moderation jobs (temp-ban expiration)
|
||||||
|
- Complete moderation reference command set for Phase 1
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
1. Copy `.env.example` to `.env` and fill required values.
|
||||||
|
2. Install dependencies:
|
||||||
|
- `pnpm install`
|
||||||
|
3. Generate Prisma client:
|
||||||
|
- `pnpm prisma:generate`
|
||||||
|
4. Start stack:
|
||||||
|
- `docker compose up -d`
|
||||||
|
5. Start bot locally:
|
||||||
|
- `pnpm --filter @nexumi/bot dev`
|
||||||
|
|
||||||
|
## Verification commands
|
||||||
|
|
||||||
|
- `pnpm typecheck`
|
||||||
|
- `pnpm lint`
|
||||||
|
- `pnpm test`
|
||||||
|
|
||||||
|
## Backup and restore note
|
||||||
|
|
||||||
|
Phase 1 includes the BullMQ foundation for scheduled jobs. Database backup rotation is configured via `BACKUP_RETENTION_DAYS` and the `backups` volume is already reserved in compose.
|
||||||
|
|
||||||
|
Restore flow (manual, PostgreSQL container):
|
||||||
|
|
||||||
|
1. Stop bot/web services.
|
||||||
|
2. Copy dump into postgres container.
|
||||||
|
3. Run `psql` restore against `nexumi` database:
|
||||||
|
- `docker compose exec -T postgres psql -U nexumi -d nexumi < /path/to/backup.sql`
|
||||||
|
4. Start services again.
|
||||||
@@ -17,6 +17,7 @@ RUN pnpm --filter @nexumi/bot build
|
|||||||
FROM node:22-alpine AS runner
|
FROM node:22-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
|
RUN apk add --no-cache postgresql16-client
|
||||||
COPY --from=build /app/node_modules ./node_modules
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
COPY --from=build /app/apps/bot/dist ./apps/bot/dist
|
COPY --from=build /app/apps/bot/dist ./apps/bot/dist
|
||||||
COPY --from=build /app/apps/bot/prisma ./apps/bot/prisma
|
COPY --from=build /app/apps/bot/prisma ./apps/bot/prisma
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ model Guild {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
settings GuildSettings?
|
settings GuildSettings?
|
||||||
cases Case[]
|
cases Case[]
|
||||||
|
escalationRules EscalationRule[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model GuildSettings {
|
model GuildSettings {
|
||||||
@@ -79,3 +80,19 @@ model ModNote {
|
|||||||
|
|
||||||
@@index([guildId, userId])
|
@@index([guildId, userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model EscalationRule {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
warnCount Int
|
||||||
|
action String
|
||||||
|
durationMs Int?
|
||||||
|
reason String?
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([guildId, warnCount])
|
||||||
|
@@index([guildId, enabled])
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ const EnvSchema = z.object({
|
|||||||
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),
|
||||||
|
BACKUP_CRON: z.string().default('0 3 * * *'),
|
||||||
|
BACKUP_DIR: z.string().default('/backups'),
|
||||||
METRICS_TOKEN: z.string().optional(),
|
METRICS_TOKEN: z.string().optional(),
|
||||||
HEALTH_PORT: z.coerce.number().int().positive().default(8080)
|
HEALTH_PORT: z.coerce.number().int().positive().default(8080)
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { logger } from './logger.js';
|
|||||||
import { prisma } from './db.js';
|
import { prisma } from './db.js';
|
||||||
import { redis } from './redis.js';
|
import { redis } from './redis.js';
|
||||||
import { registerCommands, routeCommand } from './commands.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 type { BotContext } from './types.js';
|
||||||
import { startHealthServer } from './health.js';
|
import { startHealthServer } from './health.js';
|
||||||
|
|
||||||
@@ -39,6 +39,7 @@ if (!isShard) {
|
|||||||
|
|
||||||
const context: BotContext = { client, prisma, redis };
|
const context: BotContext = { client, prisma, redis };
|
||||||
startWorkers(context);
|
startWorkers(context);
|
||||||
|
await ensureRecurringJobs();
|
||||||
|
|
||||||
client.on(Events.ClientReady, async () => {
|
client.on(Events.ClientReady, async () => {
|
||||||
logger.info({ tag: client.user?.tag }, 'Bot ready');
|
logger.info({ tag: client.user?.tag }, 'Bot ready');
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { Queue, Worker } from 'bullmq';
|
import { Queue, Worker } from 'bullmq';
|
||||||
import { PermissionFlagsBits } from 'discord.js';
|
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 { redis } from './redis.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import type { BotContext } from './types.js';
|
import type { BotContext } from './types.js';
|
||||||
|
import { env } from './env.js';
|
||||||
|
|
||||||
export const moderationQueueName = 'moderation';
|
export const moderationQueueName = 'moderation';
|
||||||
export const moderationQueue = new Queue(moderationQueueName, { connection: redis });
|
export const moderationQueue = new Queue(moderationQueueName, { connection: redis });
|
||||||
|
export const backupQueueName = 'backups';
|
||||||
|
export const backupQueue = new Queue(backupQueueName, { connection: redis });
|
||||||
|
|
||||||
type TempBanJob = {
|
type TempBanJob = {
|
||||||
guildId: string;
|
guildId: string;
|
||||||
@@ -14,7 +20,7 @@ type TempBanJob = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function startWorkers(context: BotContext): Worker[] {
|
export function startWorkers(context: BotContext): Worker[] {
|
||||||
const worker = new Worker<TempBanJob>(
|
const moderationWorker = new Worker<TempBanJob>(
|
||||||
moderationQueueName,
|
moderationQueueName,
|
||||||
async (job) => {
|
async (job) => {
|
||||||
if (job.name !== 'tempBanExpire') {
|
if (job.name !== 'tempBanExpire') {
|
||||||
@@ -31,9 +37,81 @@ export function startWorkers(context: BotContext): Worker[] {
|
|||||||
{ connection: redis }
|
{ connection: redis }
|
||||||
);
|
);
|
||||||
|
|
||||||
worker.on('failed', (job, error) => {
|
moderationWorker.on('failed', (job, error) => {
|
||||||
logger.error({ jobId: job?.id, error }, 'Moderation job failed');
|
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
|
type GuildMember
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
import { t } from '@nexumi/shared';
|
import { t } from '@nexumi/shared';
|
||||||
import type { SlashCommand, BotContext } from '../../types.js';
|
import type { SlashCommand } from '../../types.js';
|
||||||
import { requirePermission } from '../../permissions.js';
|
import { requirePermission } from '../../permissions.js';
|
||||||
import { createCase, scheduleTempBanExpire } from './service.js';
|
import { applyWarnEscalation, createCase, scheduleTempBanExpire } from './service.js';
|
||||||
import { parseDuration } from './duration.js';
|
import { parseDuration } from './duration.js';
|
||||||
|
|
||||||
function reasonFrom(i: ChatInputCommandInteraction): string {
|
function reasonFrom(i: ChatInputCommandInteraction): string {
|
||||||
@@ -175,10 +175,102 @@ const warnCommand: SlashCommand = {
|
|||||||
.setName('clear')
|
.setName('clear')
|
||||||
.setDescription('Clear warnings for user')
|
.setDescription('Clear warnings for user')
|
||||||
.addUserOption((o) => o.setName('user').setDescription('Target').setRequired(true))
|
.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) {
|
async execute(interaction, context) {
|
||||||
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers))) return;
|
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers))) return;
|
||||||
const sub = interaction.options.getSubcommand();
|
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') {
|
if (sub === 'add') {
|
||||||
const user = interaction.options.getUser('user', true);
|
const user = interaction.options.getUser('user', true);
|
||||||
const reason = interaction.options.getString('reason', true);
|
const reason = interaction.options.getString('reason', true);
|
||||||
@@ -203,7 +295,18 @@ const warnCommand: SlashCommand = {
|
|||||||
caseId: modCase.id
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,13 +356,49 @@ const purgeCommand: SlashCommand = {
|
|||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
.setName('purge')
|
.setName('purge')
|
||||||
.setDescription('Bulk delete messages')
|
.setDescription('Bulk delete messages')
|
||||||
.addIntegerOption((o) => o.setName('amount').setDescription('2-100').setRequired(true).setMinValue(2).setMaxValue(100)),
|
.addIntegerOption((o) =>
|
||||||
|
o.setName('amount').setDescription('2-100').setRequired(true).setMinValue(2).setMaxValue(100)
|
||||||
|
)
|
||||||
|
.addUserOption((o) => o.setName('user').setDescription('Only purge this user messages'))
|
||||||
|
.addBooleanOption((o) => o.setName('bots').setDescription('Only purge bot messages'))
|
||||||
|
.addBooleanOption((o) => o.setName('links').setDescription('Only purge messages containing links'))
|
||||||
|
.addBooleanOption((o) =>
|
||||||
|
o.setName('attachments').setDescription('Only purge messages with attachments')
|
||||||
|
)
|
||||||
|
.addStringOption((o) =>
|
||||||
|
o.setName('regex').setDescription('Only purge messages matching regex')
|
||||||
|
),
|
||||||
async execute(interaction, context) {
|
async execute(interaction, context) {
|
||||||
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageMessages))) return;
|
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageMessages))) return;
|
||||||
const amount = interaction.options.getInteger('amount', true);
|
const amount = interaction.options.getInteger('amount', true);
|
||||||
|
const targetUser = interaction.options.getUser('user');
|
||||||
|
const botsOnly = interaction.options.getBoolean('bots') ?? false;
|
||||||
|
const linksOnly = interaction.options.getBoolean('links') ?? false;
|
||||||
|
const attachmentsOnly = interaction.options.getBoolean('attachments') ?? false;
|
||||||
|
const regexInput = interaction.options.getString('regex');
|
||||||
|
let regex: RegExp | null = null;
|
||||||
|
if (regexInput) {
|
||||||
|
try {
|
||||||
|
regex = new RegExp(regexInput, 'i');
|
||||||
|
} catch {
|
||||||
|
await interaction.reply({ content: 'Invalid regex pattern.', ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let deletedCount = 0;
|
let deletedCount = 0;
|
||||||
if (interaction.channel instanceof TextChannel) {
|
if (interaction.channel instanceof TextChannel) {
|
||||||
const deleted = await interaction.channel.bulkDelete(amount, true);
|
const fetched = await interaction.channel.messages.fetch({ limit: 100 });
|
||||||
|
const filtered = fetched.filter((message) => {
|
||||||
|
if (targetUser && message.author.id !== targetUser.id) return false;
|
||||||
|
if (botsOnly && !message.author.bot) return false;
|
||||||
|
if (linksOnly && !/(https?:\/\/|discord\.gg\/)/i.test(message.content)) return false;
|
||||||
|
if (attachmentsOnly && message.attachments.size === 0) return false;
|
||||||
|
if (regex && !regex.test(message.content)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const toDelete = filtered.first(amount);
|
||||||
|
const deleted = await interaction.channel.bulkDelete(toDelete, true);
|
||||||
deletedCount = deleted.size;
|
deletedCount = deleted.size;
|
||||||
}
|
}
|
||||||
await createCase(context, {
|
await createCase(context, {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { moderationQueue } from '../../jobs.js';
|
import { moderationQueue } from '../../jobs.js';
|
||||||
import type { BotContext } from '../../types.js';
|
import type { BotContext } from '../../types.js';
|
||||||
|
import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js';
|
||||||
|
import type { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
type CreateCaseInput = {
|
type CreateCaseInput = {
|
||||||
guildId: string;
|
guildId: string;
|
||||||
@@ -7,10 +9,16 @@ type CreateCaseInput = {
|
|||||||
moderatorId: string;
|
moderatorId: string;
|
||||||
action: string;
|
action: string;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Prisma.InputJsonValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function createCase(context: BotContext, input: 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({
|
const lastCase = await context.prisma.case.findFirst({
|
||||||
where: { guildId: input.guildId },
|
where: { guildId: input.guildId },
|
||||||
orderBy: { caseNumber: 'desc' }
|
orderBy: { caseNumber: 'desc' }
|
||||||
@@ -45,3 +53,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.';
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- backups:/backups
|
||||||
|
|
||||||
webui:
|
webui:
|
||||||
image: node:22-alpine
|
image: node:22-alpine
|
||||||
|
|||||||
BIN
docs/logo/nexumi-logo.png
Normal file
BIN
docs/logo/nexumi-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
37
docs/logo/nexumi-logo.svg
Normal file
37
docs/logo/nexumi-logo.svg
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Nexumi logo">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#181C2B"/>
|
||||||
|
<stop offset="1" stop-color="#0C0E15"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="mark" x1="0" y1="1" x2="1" y2="0">
|
||||||
|
<stop offset="0" stop-color="#6366F1"/>
|
||||||
|
<stop offset="1" stop-color="#8B5CF6"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- App-Icon-Grundflaeche -->
|
||||||
|
<rect width="512" height="512" rx="116" fill="url(#bg)"/>
|
||||||
|
<rect x="1.5" y="1.5" width="509" height="509" rx="114.5" fill="none" stroke="#FFFFFF" stroke-opacity="0.06" stroke-width="3"/>
|
||||||
|
|
||||||
|
<!-- Glow: gestaffelte Deckkraft statt Filter, rendersicher -->
|
||||||
|
<g fill="none" stroke="url(#mark)" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M170 362 V150 L342 362 V150" stroke-width="78" stroke-opacity="0.10"/>
|
||||||
|
<path d="M170 362 V150 L342 362 V150" stroke-width="52" stroke-opacity="0.16"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Kanten des N -->
|
||||||
|
<path d="M170 362 V150 L342 362 V150" fill="none" stroke="url(#mark)"
|
||||||
|
stroke-width="30" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
|
||||||
|
<!-- Knoten -->
|
||||||
|
<g fill="url(#mark)">
|
||||||
|
<circle cx="170" cy="150" r="34"/>
|
||||||
|
<circle cx="170" cy="362" r="34"/>
|
||||||
|
<circle cx="342" cy="362" r="34"/>
|
||||||
|
<circle cx="342" cy="150" r="34"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Aktiver Knoten oben rechts -->
|
||||||
|
<circle cx="342" cy="150" r="14" fill="#C7D2FE"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
8
packages/shared/src/i18n.test.ts
Normal file
8
packages/shared/src/i18n.test.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { t } from './index.js';
|
||||||
|
|
||||||
|
describe('i18n helper', () => {
|
||||||
|
it('returns german translations', () => {
|
||||||
|
expect(t('de', 'moderation.success')).toBe('Aktion erfolgreich ausgeführt.');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user