deploy #1
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"css.lint.unknownAtRules": "ignore"
|
||||||
|
}
|
||||||
@@ -14,9 +14,10 @@
|
|||||||
"profile:banner": "tsx scripts/set-bot-banner.ts"
|
"profile:banner": "tsx scripts/set-bot-banner.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nexumi/shared": "workspace:*",
|
|
||||||
"@napi-rs/canvas": "^0.1.65",
|
"@napi-rs/canvas": "^0.1.65",
|
||||||
|
"@nexumi/shared": "workspace:*",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
|
"@sentry/node": "^10.67.0",
|
||||||
"bullmq": "^5.34.0",
|
"bullmq": "^5.34.0",
|
||||||
"discord.js": "^14.17.3",
|
"discord.js": "^14.17.3",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
|
|||||||
@@ -9,7 +9,15 @@ import { env } from './env.js';
|
|||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { getGuildLocale } from './i18n.js';
|
import { getGuildLocale } from './i18n.js';
|
||||||
import { evaluateCommandGate, type CommandGateReason } from './command-gates.js';
|
import { evaluateCommandGate, type CommandGateReason } from './command-gates.js';
|
||||||
|
import {
|
||||||
|
isGuildBlacklisted,
|
||||||
|
isModuleEnabled,
|
||||||
|
isUserBlacklisted,
|
||||||
|
moduleForCommand
|
||||||
|
} from './module-gates.js';
|
||||||
import { isMaintenanceMode } from './presence.js';
|
import { isMaintenanceMode } from './presence.js';
|
||||||
|
import { recordCommandMetric } from './metrics.js';
|
||||||
|
import { captureException } from './sentry.js';
|
||||||
import { moderationCommands } from './modules/moderation/commands.js';
|
import { moderationCommands } from './modules/moderation/commands.js';
|
||||||
import { automodCommands } from './modules/automod/commands.js';
|
import { automodCommands } from './modules/automod/commands.js';
|
||||||
import { welcomeCommands } from './modules/welcome/commands.js';
|
import { welcomeCommands } from './modules/welcome/commands.js';
|
||||||
@@ -116,7 +124,27 @@ function gateMessage(locale: Locale, reason: CommandGateReason, retryAfterSecond
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function routeCommand(interaction: ChatInputCommandInteraction, context: BotContext) {
|
export async function routeCommand(interaction: ChatInputCommandInteraction, context: BotContext) {
|
||||||
|
const started = Date.now();
|
||||||
|
let ok = true;
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (await isUserBlacklisted(context.prisma, interaction.user.id)) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'generic.userBlacklisted'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.guildId && (await isGuildBlacklisted(context.prisma, interaction.guildId))) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'generic.guildBlacklisted'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const maintenance = await isMaintenanceMode(context);
|
const maintenance = await isMaintenanceMode(context);
|
||||||
const ownerIds = env.OWNER_USER_IDS ?? [];
|
const ownerIds = env.OWNER_USER_IDS ?? [];
|
||||||
if (maintenance.enabled && !ownerIds.includes(interaction.user.id)) {
|
if (maintenance.enabled && !ownerIds.includes(interaction.user.id)) {
|
||||||
@@ -133,6 +161,23 @@ export async function routeCommand(interaction: ChatInputCommandInteraction, con
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const moduleId = moduleForCommand(interaction.commandName);
|
||||||
|
if (moduleId && interaction.guildId) {
|
||||||
|
const enabled = await isModuleEnabled(
|
||||||
|
context.prisma,
|
||||||
|
context.redis,
|
||||||
|
interaction.guildId,
|
||||||
|
moduleId
|
||||||
|
);
|
||||||
|
if (!enabled) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'generic.moduleDisabled'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const gate = await evaluateCommandGate(interaction, context);
|
const gate = await evaluateCommandGate(interaction, context);
|
||||||
if (!gate.ok) {
|
if (!gate.ok) {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
@@ -143,14 +188,45 @@ export async function routeCommand(interaction: ChatInputCommandInteraction, con
|
|||||||
}
|
}
|
||||||
|
|
||||||
await command.execute(interaction, context);
|
await command.execute(interaction, context);
|
||||||
|
} catch (error) {
|
||||||
|
ok = false;
|
||||||
|
captureException(error, {
|
||||||
|
command: interaction.commandName,
|
||||||
|
guildId: interaction.guildId,
|
||||||
|
userId: interaction.user.id
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await recordCommandMetric(context.redis, {
|
||||||
|
ok,
|
||||||
|
durationMs: Date.now() - started
|
||||||
|
}).catch(() => undefined);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function routeContextMenu(
|
export async function routeContextMenu(
|
||||||
interaction: MessageContextMenuCommandInteraction,
|
interaction: MessageContextMenuCommandInteraction,
|
||||||
context: BotContext
|
context: BotContext
|
||||||
) {
|
) {
|
||||||
const command = contextMenuMap.get(interaction.commandName);
|
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
|
||||||
|
if (await isUserBlacklisted(context.prisma, interaction.user.id)) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'generic.userBlacklisted'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.guildId && (await isGuildBlacklisted(context.prisma, interaction.guildId))) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'generic.guildBlacklisted'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = contextMenuMap.get(interaction.commandName);
|
||||||
if (!command) {
|
if (!command) {
|
||||||
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });
|
||||||
return;
|
return;
|
||||||
|
|||||||
52
apps/bot/src/confirmation-store.test.ts
Normal file
52
apps/bot/src/confirmation-store.test.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { Redis } from 'ioredis';
|
||||||
|
import {
|
||||||
|
deleteConfirmation,
|
||||||
|
getConfirmation,
|
||||||
|
setConfirmation,
|
||||||
|
takeConfirmation
|
||||||
|
} from './confirmation-store.js';
|
||||||
|
|
||||||
|
function createMemoryRedis(): Redis {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
return {
|
||||||
|
async set(key: string, value: string) {
|
||||||
|
store.set(key, value);
|
||||||
|
return 'OK';
|
||||||
|
},
|
||||||
|
async get(key: string) {
|
||||||
|
return store.get(key) ?? null;
|
||||||
|
},
|
||||||
|
async del(key: string) {
|
||||||
|
store.delete(key);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
} as unknown as Redis;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('confirmation-store', () => {
|
||||||
|
it('stores and retrieves pending confirmations', async () => {
|
||||||
|
const redis = createMemoryRedis();
|
||||||
|
await setConfirmation(redis, 'moderation', 'tok-1', { action: 'ban', userId: '1' }, 60);
|
||||||
|
await expect(getConfirmation(redis, 'moderation', 'tok-1')).resolves.toEqual({
|
||||||
|
action: 'ban',
|
||||||
|
userId: '1'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takeConfirmation deletes the entry', async () => {
|
||||||
|
const redis = createMemoryRedis();
|
||||||
|
await setConfirmation(redis, 'moderation', 'tok-2', { action: 'purge' }, 60);
|
||||||
|
await expect(takeConfirmation(redis, 'moderation', 'tok-2')).resolves.toEqual({
|
||||||
|
action: 'purge'
|
||||||
|
});
|
||||||
|
await expect(getConfirmation(redis, 'moderation', 'tok-2')).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteConfirmation removes pending state', async () => {
|
||||||
|
const redis = createMemoryRedis();
|
||||||
|
await setConfirmation(redis, 'guildbackup', 'tok-3', { action: 'delete' }, 60);
|
||||||
|
await deleteConfirmation(redis, 'guildbackup', 'tok-3');
|
||||||
|
await expect(getConfirmation(redis, 'guildbackup', 'tok-3')).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
57
apps/bot/src/confirmation-store.ts
Normal file
57
apps/bot/src/confirmation-store.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import type { Redis } from 'ioredis';
|
||||||
|
|
||||||
|
function key(namespace: string, token: string): string {
|
||||||
|
return `confirm:${namespace}:${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setConfirmation<T extends object>(
|
||||||
|
redis: Redis,
|
||||||
|
namespace: string,
|
||||||
|
token: string,
|
||||||
|
entry: T,
|
||||||
|
ttlSeconds: number
|
||||||
|
): Promise<void> {
|
||||||
|
await redis.set(key(namespace, token), JSON.stringify(entry), 'EX', ttlSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConfirmation<T>(
|
||||||
|
redis: Redis,
|
||||||
|
namespace: string,
|
||||||
|
token: string
|
||||||
|
): Promise<T | null> {
|
||||||
|
const raw = await redis.get(key(namespace, token));
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteConfirmation(
|
||||||
|
redis: Redis,
|
||||||
|
namespace: string,
|
||||||
|
token: string
|
||||||
|
): Promise<void> {
|
||||||
|
await redis.del(key(namespace, token));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function takeConfirmation<T>(
|
||||||
|
redis: Redis,
|
||||||
|
namespace: string,
|
||||||
|
token: string
|
||||||
|
): Promise<T | null> {
|
||||||
|
const k = key(namespace, token);
|
||||||
|
const raw = await redis.get(k);
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await redis.del(k);
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ const EnvSchema = z.object({
|
|||||||
BACKUP_CRON: z.string().default('0 3 * * *'),
|
BACKUP_CRON: z.string().default('0 3 * * *'),
|
||||||
BACKUP_DIR: z.string().default('/backups'),
|
BACKUP_DIR: z.string().default('/backups'),
|
||||||
METRICS_TOKEN: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
|
METRICS_TOKEN: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
|
||||||
|
SENTRY_DSN: z.preprocess(emptyToUndefined, z.string().url().optional()),
|
||||||
HEALTH_PORT: z.coerce.number().int().positive().default(8080),
|
HEALTH_PORT: z.coerce.number().int().positive().default(8080),
|
||||||
PUBLIC_BASE_URL: z.string().url().default('http://localhost:8080'),
|
PUBLIC_BASE_URL: z.string().url().default('http://localhost:8080'),
|
||||||
TWITCH_CLIENT_ID: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
|
TWITCH_CLIENT_ID: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
|
||||||
|
|||||||
44
apps/bot/src/harden.test.ts
Normal file
44
apps/bot/src/harden.test.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { formatPrometheus, type MetricsSnapshot } from './metrics.js';
|
||||||
|
|
||||||
|
describe('confirmation custom_id matchers', () => {
|
||||||
|
it('detects moderation confirm/cancel prefixes', () => {
|
||||||
|
const isMod = (id: string) => id.startsWith('mod:confirm:') || id.startsWith('mod:cancel:');
|
||||||
|
expect(isMod('mod:confirm:abc')).toBe(true);
|
||||||
|
expect(isMod('mod:cancel:abc')).toBe(true);
|
||||||
|
expect(isMod('other')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects guildbackup confirm/cancel prefixes', () => {
|
||||||
|
const isBackup = (id: string) =>
|
||||||
|
id.startsWith('gbackup:confirm:delete:') ||
|
||||||
|
id.startsWith('gbackup:confirm:restore:') ||
|
||||||
|
id.startsWith('gbackup:cancel:');
|
||||||
|
expect(isBackup('gbackup:confirm:delete:abc')).toBe(true);
|
||||||
|
expect(isBackup('gbackup:confirm:restore:abc')).toBe(true);
|
||||||
|
expect(isBackup('gbackup:cancel:abc')).toBe(true);
|
||||||
|
expect(isBackup('mod:confirm:abc')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatPrometheus', () => {
|
||||||
|
it('emits core counters and shard gauges', () => {
|
||||||
|
const snapshot: MetricsSnapshot = {
|
||||||
|
up: 1,
|
||||||
|
commandsTotal: 10,
|
||||||
|
commandsErrors: 2,
|
||||||
|
commandDurationSumMs: 500,
|
||||||
|
commandDurationCount: 10,
|
||||||
|
eventsTotal: 100,
|
||||||
|
shards: [{ id: 0, ping: 42, guilds: 5, updatedAt: Date.now() }],
|
||||||
|
queueWaiting: 3
|
||||||
|
};
|
||||||
|
const body = formatPrometheus(snapshot);
|
||||||
|
expect(body).toContain('nexumi_up 1');
|
||||||
|
expect(body).toContain('nexumi_commands_total 10');
|
||||||
|
expect(body).toContain('nexumi_commands_errors_total 2');
|
||||||
|
expect(body).toContain('nexumi_guilds{shard="0"} 5');
|
||||||
|
expect(body).toContain('nexumi_shard_ping_ms{shard="0"} 42');
|
||||||
|
expect(body).toContain('nexumi_queue_waiting 3');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,17 @@ import {
|
|||||||
getCaptchaChallenge,
|
getCaptchaChallenge,
|
||||||
hashCaptchaAnswer
|
hashCaptchaAnswer
|
||||||
} from './modules/verification/service.js';
|
} from './modules/verification/service.js';
|
||||||
import { verificationQueue } from './queues.js';
|
import {
|
||||||
|
automodQueue,
|
||||||
|
backupQueue,
|
||||||
|
birthdayQueue,
|
||||||
|
feedsQueue,
|
||||||
|
presenceQueue,
|
||||||
|
retentionQueue,
|
||||||
|
ticketQueue,
|
||||||
|
verificationQueue
|
||||||
|
} from './queues.js';
|
||||||
|
import { collectMetricsSnapshot, formatPrometheus } from './metrics.js';
|
||||||
|
|
||||||
function readBody(req: IncomingMessage): Promise<string> {
|
function readBody(req: IncomingMessage): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -99,6 +109,19 @@ async function handleCaptchaPost(req: IncomingMessage, res: ServerResponse): Pro
|
|||||||
res.end('<html><body style="font-family:sans-serif;background:#111827;color:#f9fafb;display:flex;justify-content:center;align-items:center;min-height:100vh"><div><h1>Verified</h1><p>You can return to Discord.</p></div></body></html>');
|
res.end('<html><body style="font-family:sans-serif;background:#111827;color:#f9fafb;display:flex;justify-content:center;align-items:center;min-height:100vh"><div><h1>Verified</h1><p>You can return to Discord.</p></div></body></html>');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function approximateQueueWaiting(): Promise<number> {
|
||||||
|
const counts = await Promise.all([
|
||||||
|
automodQueue.getWaitingCount(),
|
||||||
|
backupQueue.getWaitingCount(),
|
||||||
|
birthdayQueue.getWaitingCount(),
|
||||||
|
feedsQueue.getWaitingCount(),
|
||||||
|
presenceQueue.getWaitingCount(),
|
||||||
|
retentionQueue.getWaitingCount(),
|
||||||
|
ticketQueue.getWaitingCount()
|
||||||
|
]);
|
||||||
|
return counts.reduce((sum, n) => sum + n, 0);
|
||||||
|
}
|
||||||
|
|
||||||
export function startHealthServer(): void {
|
export function startHealthServer(): void {
|
||||||
const server = createServer(async (req, res) => {
|
const server = createServer(async (req, res) => {
|
||||||
if (!req.url) {
|
if (!req.url) {
|
||||||
@@ -121,8 +144,16 @@ export function startHealthServer(): void {
|
|||||||
res.end('Unauthorized');
|
res.end('Unauthorized');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const snapshot = await collectMetricsSnapshot(redis);
|
||||||
|
snapshot.queueWaiting = await approximateQueueWaiting();
|
||||||
res.statusCode = 200;
|
res.statusCode = 200;
|
||||||
res.end('# Prometheus metrics scaffold\nnexumi_up 1\n');
|
res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
|
||||||
|
res.end(formatPrometheus(snapshot));
|
||||||
|
} catch {
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.end('metrics error');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
141
apps/bot/src/hierarchy.ts
Normal file
141
apps/bot/src/hierarchy.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import {
|
||||||
|
type ChatInputCommandInteraction,
|
||||||
|
type GuildMember,
|
||||||
|
type Interaction
|
||||||
|
} from 'discord.js';
|
||||||
|
import type { Locale } from '@nexumi/shared';
|
||||||
|
import { t } from '@nexumi/shared';
|
||||||
|
|
||||||
|
export type HierarchyAction = 'ban' | 'kick' | 'timeout' | 'nick';
|
||||||
|
|
||||||
|
export type HierarchyOk = { ok: true; member: GuildMember | null };
|
||||||
|
export type HierarchyDenied = { ok: false };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that the moderator and bot can act on the target member.
|
||||||
|
* Replies ephemerally on denial. For bans, `member` may be null if the user
|
||||||
|
* already left the guild (ban-by-id is still allowed).
|
||||||
|
*/
|
||||||
|
export async function assertModerableMember(
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
targetUserId: string,
|
||||||
|
locale: Locale,
|
||||||
|
action: HierarchyAction
|
||||||
|
): Promise<HierarchyOk | HierarchyDenied> {
|
||||||
|
const guild = interaction.guild;
|
||||||
|
if (!guild) {
|
||||||
|
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetUserId === interaction.user.id) {
|
||||||
|
await interaction.reply({ content: t(locale, 'moderation.hierarchy.self'), ephemeral: true });
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetUserId === guild.ownerId) {
|
||||||
|
await interaction.reply({ content: t(locale, 'moderation.hierarchy.owner'), ephemeral: true });
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
let member: GuildMember | null = null;
|
||||||
|
try {
|
||||||
|
member = await guild.members.fetch(targetUserId);
|
||||||
|
} catch {
|
||||||
|
if (action === 'ban') {
|
||||||
|
return { ok: true, member: null };
|
||||||
|
}
|
||||||
|
await interaction.reply({ content: t(locale, 'moderation.hierarchy.notMember'), ephemeral: true });
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const moderator =
|
||||||
|
interaction.member && 'roles' in interaction.member
|
||||||
|
? (interaction.member as GuildMember)
|
||||||
|
: await guild.members.fetch(interaction.user.id);
|
||||||
|
|
||||||
|
if (guild.ownerId !== moderator.id) {
|
||||||
|
if (member.roles.highest.position >= moderator.roles.highest.position) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'moderation.hierarchy.moderator'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const botOk =
|
||||||
|
action === 'ban'
|
||||||
|
? member.bannable
|
||||||
|
: action === 'kick'
|
||||||
|
? member.kickable
|
||||||
|
: action === 'timeout'
|
||||||
|
? member.moderatable
|
||||||
|
: member.manageable;
|
||||||
|
|
||||||
|
if (!botOk) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'moderation.hierarchy.bot'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, member };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replyHierarchyForButton(interaction: Interaction, locale: Locale, key: string): Promise<void> {
|
||||||
|
if (!interaction.isRepliable()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const content = t(locale, key);
|
||||||
|
if (interaction.isButton()) {
|
||||||
|
await interaction.update({ content, components: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (interaction.replied || interaction.deferred) {
|
||||||
|
await interaction.followUp({ content, ephemeral: true });
|
||||||
|
} else {
|
||||||
|
await interaction.reply({ content, ephemeral: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertBannableForConfirm(
|
||||||
|
interaction: Interaction,
|
||||||
|
locale: Locale,
|
||||||
|
targetUserId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const guild = interaction.guild;
|
||||||
|
if (!guild) {
|
||||||
|
await replyHierarchyForButton(interaction, locale, 'generic.guildOnly');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (targetUserId === interaction.user.id) {
|
||||||
|
await replyHierarchyForButton(interaction, locale, 'moderation.hierarchy.self');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (targetUserId === guild.ownerId) {
|
||||||
|
await replyHierarchyForButton(interaction, locale, 'moderation.hierarchy.owner');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const member = await guild.members.fetch(targetUserId);
|
||||||
|
if (!member.bannable) {
|
||||||
|
await replyHierarchyForButton(interaction, locale, 'moderation.hierarchy.bot');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const moderator = await guild.members.fetch(interaction.user.id);
|
||||||
|
if (
|
||||||
|
guild.ownerId !== moderator.id &&
|
||||||
|
member.roles.highest.position >= moderator.roles.highest.position
|
||||||
|
) {
|
||||||
|
await replyHierarchyForButton(interaction, locale, 'moderation.hierarchy.moderator');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Target left the guild — ban by id is still fine.
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -14,64 +14,30 @@ import { registerCommands, routeCommand, routeContextMenu } from './commands.js'
|
|||||||
import { ensureRecurringJobs, startWorkers } from './jobs.js';
|
import { ensureRecurringJobs, startWorkers } from './jobs.js';
|
||||||
import type { BotContext } from './types.js';
|
import type { BotContext } from './types.js';
|
||||||
import { startHealthServer } from './health.js';
|
import { startHealthServer } from './health.js';
|
||||||
import {
|
|
||||||
handleModerationConfirmation,
|
|
||||||
isModerationConfirmation
|
|
||||||
} from './modules/moderation/confirmations.js';
|
|
||||||
import { getGuildLocale } from './i18n.js';
|
import { getGuildLocale } from './i18n.js';
|
||||||
import { t } from '@nexumi/shared';
|
import { t } from '@nexumi/shared';
|
||||||
import { handleAutoModMessage } from './modules/automod/actions.js';
|
import { handleAutoModMessage } from './modules/automod/actions.js';
|
||||||
import { registerLoggingEvents } from './modules/logging/events.js';
|
import { registerLoggingEvents } from './modules/logging/events.js';
|
||||||
import { registerWelcomeEvents } from './modules/welcome/events.js';
|
import { registerWelcomeEvents } from './modules/welcome/events.js';
|
||||||
import { registerVerificationEvents } from './modules/verification/events.js';
|
import { registerVerificationEvents } from './modules/verification/events.js';
|
||||||
import { handleVerificationButton } from './modules/verification/handlers.js';
|
|
||||||
import { isVerificationButton } from './modules/verification/service.js';
|
|
||||||
import { registerLevelingEvents } from './modules/leveling/events.js';
|
import { registerLevelingEvents } from './modules/leveling/events.js';
|
||||||
import {
|
import { registerUtilityEvents } from './modules/utility/index.js';
|
||||||
handleBlackjackButton,
|
import { registerTicketEvents } from './modules/tickets/index.js';
|
||||||
isBlackjackButton
|
import { registerSelfRoleEvents } from './modules/selfroles/index.js';
|
||||||
} from './modules/economy/blackjack-buttons.js';
|
|
||||||
import {
|
|
||||||
handleEmbedModal,
|
|
||||||
handlePollButton,
|
|
||||||
isEmbedModal,
|
|
||||||
isPollButton,
|
|
||||||
registerUtilityEvents
|
|
||||||
} from './modules/utility/index.js';
|
|
||||||
import { handleFunButton, isFunButton } from './modules/fun/index.js';
|
|
||||||
import { handleGiveawayButton, isGiveawayButton } from './modules/giveaways/index.js';
|
|
||||||
import {
|
|
||||||
handleTicketInteraction,
|
|
||||||
isTicketInteraction,
|
|
||||||
registerTicketEvents
|
|
||||||
} from './modules/tickets/index.js';
|
|
||||||
import {
|
|
||||||
handleSelfRoleInteraction,
|
|
||||||
isSelfRoleInteraction,
|
|
||||||
registerSelfRoleEvents
|
|
||||||
} from './modules/selfroles/index.js';
|
|
||||||
import { registerTagEvents } from './modules/tags/index.js';
|
import { registerTagEvents } from './modules/tags/index.js';
|
||||||
import { registerStarboardEvents } from './modules/starboard/index.js';
|
import { registerStarboardEvents } from './modules/starboard/index.js';
|
||||||
import {
|
import { registerTempVoiceEvents } from './modules/tempvoice/index.js';
|
||||||
handleSuggestionButton,
|
|
||||||
isSuggestionButton
|
|
||||||
} from './modules/suggestions/index.js';
|
|
||||||
import {
|
|
||||||
handleTempVoiceInteraction,
|
|
||||||
isTempVoiceInteraction,
|
|
||||||
isTempVoiceModal,
|
|
||||||
registerTempVoiceEvents
|
|
||||||
} from './modules/tempvoice/index.js';
|
|
||||||
import { registerStatsEvents } from './modules/stats/index.js';
|
import { registerStatsEvents } from './modules/stats/index.js';
|
||||||
import { handleGuildBackupButton, isGuildBackupButton } from './modules/guildbackup/index.js';
|
import { routeComponentInteraction } from './interaction-registry.js';
|
||||||
import {
|
import { isPrimaryShard, runOncePerCluster } from './shard-lifecycle.js';
|
||||||
handleComponentsV2Interaction,
|
import { isGuildBlacklisted, isUserBlacklisted } from './module-gates.js';
|
||||||
isComponentsV2Interaction
|
import { publishShardMetrics, recordEventMetric } from './metrics.js';
|
||||||
} from './modules/components-v2/index.js';
|
import { captureException, initSentry } from './sentry.js';
|
||||||
|
|
||||||
const isShard = process.argv.includes('--shard');
|
const isShard = process.argv.includes('--shard');
|
||||||
|
|
||||||
if (!isShard) {
|
if (!isShard) {
|
||||||
|
initSentry('bot-manager');
|
||||||
startHealthServer();
|
startHealthServer();
|
||||||
const manager = new ShardingManager(new URL('./index.js', import.meta.url).pathname, {
|
const manager = new ShardingManager(new URL('./index.js', import.meta.url).pathname, {
|
||||||
token: env.BOT_TOKEN,
|
token: env.BOT_TOKEN,
|
||||||
@@ -79,8 +45,25 @@ if (!isShard) {
|
|||||||
shardArgs: ['--shard']
|
shardArgs: ['--shard']
|
||||||
});
|
});
|
||||||
manager.on('shardCreate', (shard) => logger.info({ shardId: shard.id }, 'Shard spawned'));
|
manager.on('shardCreate', (shard) => logger.info({ shardId: shard.id }, 'Shard spawned'));
|
||||||
|
|
||||||
|
// Register slash commands once from the manager (not once per shard).
|
||||||
|
try {
|
||||||
|
await runOncePerCluster(
|
||||||
|
'nexumi:lock:register-commands',
|
||||||
|
300,
|
||||||
|
async () => {
|
||||||
|
await registerCommands();
|
||||||
|
},
|
||||||
|
'Command registration'
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Failed to register application commands from manager');
|
||||||
|
captureException(error, { phase: 'registerCommands' });
|
||||||
|
}
|
||||||
|
|
||||||
await manager.spawn();
|
await manager.spawn();
|
||||||
} else {
|
} else {
|
||||||
|
initSentry('bot-shard');
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
intents: [
|
intents: [
|
||||||
GatewayIntentBits.Guilds,
|
GatewayIntentBits.Guilds,
|
||||||
@@ -103,7 +86,7 @@ if (!isShard) {
|
|||||||
|
|
||||||
const context: BotContext = { client, prisma, redis };
|
const context: BotContext = { client, prisma, redis };
|
||||||
startWorkers(context);
|
startWorkers(context);
|
||||||
await ensureRecurringJobs();
|
|
||||||
registerLoggingEvents(context);
|
registerLoggingEvents(context);
|
||||||
registerWelcomeEvents(context);
|
registerWelcomeEvents(context);
|
||||||
registerVerificationEvents(context);
|
registerVerificationEvents(context);
|
||||||
@@ -117,12 +100,24 @@ if (!isShard) {
|
|||||||
registerStatsEvents(context);
|
registerStatsEvents(context);
|
||||||
|
|
||||||
client.on(Events.ClientReady, async () => {
|
client.on(Events.ClientReady, async () => {
|
||||||
logger.info({ tag: client.user?.tag }, 'Bot ready');
|
logger.info({ tag: client.user?.tag, shards: client.shard?.ids }, 'Bot ready');
|
||||||
|
|
||||||
|
if (isPrimaryShard(client)) {
|
||||||
try {
|
try {
|
||||||
await registerCommands();
|
await runOncePerCluster(
|
||||||
|
'nexumi:lock:ensure-recurring-jobs',
|
||||||
|
120,
|
||||||
|
async () => {
|
||||||
|
await ensureRecurringJobs();
|
||||||
|
},
|
||||||
|
'Recurring job registration'
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, 'Failed to register application commands');
|
logger.error({ error }, 'Failed to ensure recurring jobs');
|
||||||
|
captureException(error, { phase: 'ensureRecurringJobs' });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { applyBotPresence, publishBotStatus } = await import('./presence.js');
|
const { applyBotPresence, publishBotStatus } = await import('./presence.js');
|
||||||
await applyBotPresence(context);
|
await applyBotPresence(context);
|
||||||
@@ -130,18 +125,61 @@ if (!isShard) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn({ error }, 'Failed to apply initial bot presence/status');
|
logger.warn({ error }, 'Failed to apply initial bot presence/status');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await publishShardMetrics(context.redis, client);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ error }, 'Failed to publish initial shard metrics');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
client.on(Events.GuildCreate, async (guild) => {
|
||||||
|
try {
|
||||||
|
if (await isGuildBlacklisted(context.prisma, guild.id)) {
|
||||||
|
logger.info({ guildId: guild.id }, 'Leaving blacklisted guild');
|
||||||
|
await guild.leave();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error, guildId: guild.id }, 'GuildCreate blacklist check failed');
|
||||||
|
captureException(error, { guildId: guild.id });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
publishShardMetrics(context.redis, client).catch(() => undefined);
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
client.on(Events.MessageCreate, async (message) => {
|
client.on(Events.MessageCreate, async (message) => {
|
||||||
try {
|
try {
|
||||||
|
await recordEventMetric(context.redis);
|
||||||
|
if (message.author.bot) return;
|
||||||
|
if (await isUserBlacklisted(context.prisma, message.author.id)) return;
|
||||||
|
if (message.guildId && (await isGuildBlacklisted(context.prisma, message.guildId))) return;
|
||||||
await handleAutoModMessage(context, message);
|
await handleAutoModMessage(context, message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error, messageId: message.id }, 'AutoMod message handler failed');
|
logger.error({ error, messageId: message.id }, 'AutoMod message handler failed');
|
||||||
|
captureException(error, { messageId: message.id });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on(Events.InteractionCreate, async (interaction: Interaction) => {
|
client.on(Events.InteractionCreate, async (interaction: Interaction) => {
|
||||||
try {
|
try {
|
||||||
|
if (
|
||||||
|
interaction.user &&
|
||||||
|
(await isUserBlacklisted(context.prisma, interaction.user.id)) &&
|
||||||
|
!interaction.isChatInputCommand() &&
|
||||||
|
!interaction.isMessageContextMenuCommand()
|
||||||
|
) {
|
||||||
|
if (interaction.isRepliable() && !interaction.replied && !interaction.deferred) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'generic.userBlacklisted'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (interaction.isChatInputCommand()) {
|
if (interaction.isChatInputCommand()) {
|
||||||
await routeCommand(interaction, context);
|
await routeCommand(interaction, context);
|
||||||
return;
|
return;
|
||||||
@@ -152,93 +190,23 @@ if (!isShard) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.isButton() && isModerationConfirmation(interaction.customId)) {
|
|
||||||
await handleModerationConfirmation(interaction, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interaction.isButton() && isVerificationButton(interaction.customId)) {
|
|
||||||
await handleVerificationButton(interaction, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interaction.isButton() && isBlackjackButton(interaction.customId)) {
|
|
||||||
await handleBlackjackButton(interaction, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interaction.isButton() && isPollButton(interaction.customId)) {
|
|
||||||
await handlePollButton(interaction, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interaction.isButton() && isFunButton(interaction.customId)) {
|
|
||||||
await handleFunButton(interaction, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interaction.isButton() && isGiveawayButton(interaction.customId)) {
|
|
||||||
await handleGiveawayButton(interaction, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(interaction.isButton() ||
|
interaction.isButton() ||
|
||||||
interaction.isStringSelectMenu() ||
|
|
||||||
interaction.isModalSubmit()) &&
|
|
||||||
isTicketInteraction(interaction.customId)
|
|
||||||
) {
|
|
||||||
await handleTicketInteraction(interaction, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
(interaction.isButton() || interaction.isStringSelectMenu()) &&
|
|
||||||
isSelfRoleInteraction(interaction.customId)
|
|
||||||
) {
|
|
||||||
await handleSelfRoleInteraction(interaction, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interaction.isButton() && isSuggestionButton(interaction.customId)) {
|
|
||||||
await handleSuggestionButton(interaction, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interaction.isButton() && isTempVoiceInteraction(interaction.customId)) {
|
|
||||||
await handleTempVoiceInteraction(interaction, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interaction.isModalSubmit() && isTempVoiceModal(interaction.customId)) {
|
|
||||||
await handleTempVoiceInteraction(interaction, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interaction.isButton() && isGuildBackupButton(interaction.customId)) {
|
|
||||||
await handleGuildBackupButton(interaction, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interaction.isModalSubmit() && isEmbedModal(interaction.customId)) {
|
|
||||||
await handleEmbedModal(interaction, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
(interaction.isButton() ||
|
|
||||||
interaction.isStringSelectMenu() ||
|
interaction.isStringSelectMenu() ||
|
||||||
interaction.isUserSelectMenu() ||
|
interaction.isUserSelectMenu() ||
|
||||||
interaction.isRoleSelectMenu() ||
|
interaction.isRoleSelectMenu() ||
|
||||||
interaction.isChannelSelectMenu() ||
|
interaction.isChannelSelectMenu() ||
|
||||||
interaction.isMentionableSelectMenu()) &&
|
interaction.isMentionableSelectMenu() ||
|
||||||
isComponentsV2Interaction(interaction.customId)
|
interaction.isModalSubmit()
|
||||||
) {
|
) {
|
||||||
await handleComponentsV2Interaction(interaction, context);
|
await routeComponentInteraction(interaction, context);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, 'Interaction handling failed');
|
logger.error({ error }, 'Interaction handling failed');
|
||||||
|
captureException(error, {
|
||||||
|
interactionType: interaction.type,
|
||||||
|
guildId: interaction.guildId
|
||||||
|
});
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
const message = t(locale, 'generic.error');
|
const message = t(locale, 'generic.error');
|
||||||
if (interaction.isRepliable()) {
|
if (interaction.isRepliable()) {
|
||||||
|
|||||||
154
apps/bot/src/interaction-registry.ts
Normal file
154
apps/bot/src/interaction-registry.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import type {
|
||||||
|
ButtonInteraction,
|
||||||
|
ChannelSelectMenuInteraction,
|
||||||
|
MentionableSelectMenuInteraction,
|
||||||
|
ModalSubmitInteraction,
|
||||||
|
RoleSelectMenuInteraction,
|
||||||
|
StringSelectMenuInteraction,
|
||||||
|
UserSelectMenuInteraction
|
||||||
|
} from 'discord.js';
|
||||||
|
import type { BotContext } from './types.js';
|
||||||
|
import {
|
||||||
|
handleModerationConfirmation,
|
||||||
|
isModerationConfirmation
|
||||||
|
} from './modules/moderation/confirmations.js';
|
||||||
|
import { handleVerificationButton } from './modules/verification/handlers.js';
|
||||||
|
import { isVerificationButton } from './modules/verification/service.js';
|
||||||
|
import {
|
||||||
|
handleBlackjackButton,
|
||||||
|
isBlackjackButton
|
||||||
|
} from './modules/economy/blackjack-buttons.js';
|
||||||
|
import {
|
||||||
|
handleEmbedModal,
|
||||||
|
handlePollButton,
|
||||||
|
isEmbedModal,
|
||||||
|
isPollButton
|
||||||
|
} from './modules/utility/index.js';
|
||||||
|
import { handleFunButton, isFunButton } from './modules/fun/index.js';
|
||||||
|
import { handleGiveawayButton, isGiveawayButton } from './modules/giveaways/index.js';
|
||||||
|
import { handleTicketInteraction, isTicketInteraction } from './modules/tickets/index.js';
|
||||||
|
import {
|
||||||
|
handleSelfRoleInteraction,
|
||||||
|
isSelfRoleInteraction
|
||||||
|
} from './modules/selfroles/index.js';
|
||||||
|
import {
|
||||||
|
handleSuggestionButton,
|
||||||
|
isSuggestionButton
|
||||||
|
} from './modules/suggestions/index.js';
|
||||||
|
import {
|
||||||
|
handleTempVoiceInteraction,
|
||||||
|
isTempVoiceInteraction,
|
||||||
|
isTempVoiceModal
|
||||||
|
} from './modules/tempvoice/index.js';
|
||||||
|
import { handleGuildBackupButton, isGuildBackupButton } from './modules/guildbackup/index.js';
|
||||||
|
import {
|
||||||
|
handleComponentsV2Interaction,
|
||||||
|
isComponentsV2Interaction
|
||||||
|
} from './modules/components-v2/index.js';
|
||||||
|
|
||||||
|
type AnyComponentInteraction =
|
||||||
|
| ButtonInteraction
|
||||||
|
| StringSelectMenuInteraction
|
||||||
|
| UserSelectMenuInteraction
|
||||||
|
| RoleSelectMenuInteraction
|
||||||
|
| ChannelSelectMenuInteraction
|
||||||
|
| MentionableSelectMenuInteraction
|
||||||
|
| ModalSubmitInteraction;
|
||||||
|
|
||||||
|
type InteractionHandler = {
|
||||||
|
match: (customId: string) => boolean;
|
||||||
|
handle: (interaction: AnyComponentInteraction, context: BotContext) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function asButton(interaction: AnyComponentInteraction): ButtonInteraction {
|
||||||
|
return interaction as ButtonInteraction;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asModal(interaction: AnyComponentInteraction): ModalSubmitInteraction {
|
||||||
|
return interaction as ModalSubmitInteraction;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlers: InteractionHandler[] = [
|
||||||
|
{
|
||||||
|
match: isModerationConfirmation,
|
||||||
|
handle: (interaction, context) => handleModerationConfirmation(asButton(interaction), context)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: isVerificationButton,
|
||||||
|
handle: (interaction, context) => handleVerificationButton(asButton(interaction), context)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: isBlackjackButton,
|
||||||
|
handle: (interaction, context) => handleBlackjackButton(asButton(interaction), context)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: isPollButton,
|
||||||
|
handle: (interaction, context) => handlePollButton(asButton(interaction), context)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: isFunButton,
|
||||||
|
handle: (interaction, context) => handleFunButton(asButton(interaction), context)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: isGiveawayButton,
|
||||||
|
handle: (interaction, context) => handleGiveawayButton(asButton(interaction), context)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: isTicketInteraction,
|
||||||
|
handle: (interaction, context) =>
|
||||||
|
handleTicketInteraction(
|
||||||
|
interaction as Parameters<typeof handleTicketInteraction>[0],
|
||||||
|
context
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: isSelfRoleInteraction,
|
||||||
|
handle: (interaction, context) =>
|
||||||
|
handleSelfRoleInteraction(
|
||||||
|
interaction as Parameters<typeof handleSelfRoleInteraction>[0],
|
||||||
|
context
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: isSuggestionButton,
|
||||||
|
handle: (interaction, context) => handleSuggestionButton(asButton(interaction), context)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: (customId) => isTempVoiceInteraction(customId) || isTempVoiceModal(customId),
|
||||||
|
handle: (interaction, context) =>
|
||||||
|
handleTempVoiceInteraction(
|
||||||
|
interaction as Parameters<typeof handleTempVoiceInteraction>[0],
|
||||||
|
context
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: isGuildBackupButton,
|
||||||
|
handle: (interaction, context) => handleGuildBackupButton(asButton(interaction), context)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: isEmbedModal,
|
||||||
|
handle: (interaction, context) => handleEmbedModal(asModal(interaction), context)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: isComponentsV2Interaction,
|
||||||
|
handle: (interaction, context) =>
|
||||||
|
handleComponentsV2Interaction(
|
||||||
|
interaction as Parameters<typeof handleComponentsV2Interaction>[0],
|
||||||
|
context
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function routeComponentInteraction(
|
||||||
|
interaction: AnyComponentInteraction,
|
||||||
|
context: BotContext
|
||||||
|
): Promise<boolean> {
|
||||||
|
const customId = interaction.customId;
|
||||||
|
for (const handler of handlers) {
|
||||||
|
if (handler.match(customId)) {
|
||||||
|
await handler.handle(interaction, context);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -119,12 +119,70 @@ export function startWorkers(context: BotContext): Worker[] {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const guild = await context.client.guilds.fetch(job.data.guildId);
|
const { guildId, userId, reason } = job.data;
|
||||||
|
const guild = context.client.guilds.cache.get(guildId);
|
||||||
|
if (!guild) {
|
||||||
|
if (!context.client.shard) {
|
||||||
|
throw new Error(`Guild ${guildId} not available for temp ban expiration`);
|
||||||
|
}
|
||||||
|
const results = await context.client.shard.broadcastEval(
|
||||||
|
async (c, payload) => {
|
||||||
|
const g = c.guilds.cache.get(payload.guildId);
|
||||||
|
if (!g) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const me = await g.members.fetchMe();
|
||||||
|
if (!me.permissions.has(PermissionFlagsBits.BanMembers)) {
|
||||||
|
throw new Error('Missing BanMembers permission for temp ban expiration');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await g.members.unban(payload.userId, payload.reason ?? 'Temporary ban expired');
|
||||||
|
} catch (error) {
|
||||||
|
const code =
|
||||||
|
typeof error === 'object' && error && 'code' in error
|
||||||
|
? Number((error as { code: unknown }).code)
|
||||||
|
: 0;
|
||||||
|
// Unknown ban (10026) — already unbanned
|
||||||
|
if (code === 10026) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{ context: { guildId, userId, reason } }
|
||||||
|
);
|
||||||
|
if (!results.some(Boolean)) {
|
||||||
|
throw new Error(`Guild ${guildId} not found on any shard for temp ban expiration`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
const me = await guild.members.fetchMe();
|
const me = await guild.members.fetchMe();
|
||||||
if (!me.permissions.has(PermissionFlagsBits.BanMembers)) {
|
if (!me.permissions.has(PermissionFlagsBits.BanMembers)) {
|
||||||
throw new Error('Missing BanMembers permission for temp ban expiration');
|
throw new Error('Missing BanMembers permission for temp ban expiration');
|
||||||
}
|
}
|
||||||
await guild.members.unban(job.data.userId, job.data.reason ?? 'Temporary ban expired');
|
try {
|
||||||
|
await guild.members.unban(userId, reason ?? 'Temporary ban expired');
|
||||||
|
} catch (error) {
|
||||||
|
const code =
|
||||||
|
typeof error === 'object' && error && 'code' in error
|
||||||
|
? Number((error as { code: unknown }).code)
|
||||||
|
: 0;
|
||||||
|
if (code !== 10026) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { createCase } = await import('./modules/moderation/service.js');
|
||||||
|
await createCase(context, {
|
||||||
|
guildId,
|
||||||
|
targetUserId: userId,
|
||||||
|
moderatorId: context.client.user?.id ?? 'system',
|
||||||
|
action: 'UNBAN',
|
||||||
|
reason: reason ?? 'Temporary ban expired',
|
||||||
|
source: 'job',
|
||||||
|
metadata: { tempBanExpire: true }
|
||||||
|
});
|
||||||
},
|
},
|
||||||
{ connection: redis }
|
{ connection: redis }
|
||||||
);
|
);
|
||||||
|
|||||||
129
apps/bot/src/metrics.ts
Normal file
129
apps/bot/src/metrics.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import type { Redis } from 'ioredis';
|
||||||
|
import type { Client } from 'discord.js';
|
||||||
|
|
||||||
|
const KEY_COMMANDS_TOTAL = 'nexumi:metrics:commands_total';
|
||||||
|
const KEY_COMMANDS_ERRORS = 'nexumi:metrics:commands_errors';
|
||||||
|
const KEY_COMMAND_DURATION_SUM = 'nexumi:metrics:command_duration_ms_sum';
|
||||||
|
const KEY_COMMAND_DURATION_COUNT = 'nexumi:metrics:command_duration_ms_count';
|
||||||
|
const KEY_EVENTS_TOTAL = 'nexumi:metrics:events_total';
|
||||||
|
const shardKey = (id: number) => `nexumi:metrics:shard:${id}`;
|
||||||
|
|
||||||
|
export async function recordCommandMetric(
|
||||||
|
redis: Redis,
|
||||||
|
opts: { ok: boolean; durationMs: number }
|
||||||
|
): Promise<void> {
|
||||||
|
const multi = redis.multi();
|
||||||
|
multi.incr(KEY_COMMANDS_TOTAL);
|
||||||
|
if (!opts.ok) {
|
||||||
|
multi.incr(KEY_COMMANDS_ERRORS);
|
||||||
|
}
|
||||||
|
multi.incrbyfloat(KEY_COMMAND_DURATION_SUM, opts.durationMs);
|
||||||
|
multi.incr(KEY_COMMAND_DURATION_COUNT);
|
||||||
|
await multi.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordEventMetric(redis: Redis): Promise<void> {
|
||||||
|
await redis.incr(KEY_EVENTS_TOTAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishShardMetrics(redis: Redis, client: Client): Promise<void> {
|
||||||
|
const ids = client.shard?.ids ?? [0];
|
||||||
|
const ping = client.ws.ping;
|
||||||
|
const guilds = client.guilds.cache.size;
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
ping,
|
||||||
|
guilds,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
});
|
||||||
|
await Promise.all(ids.map((id) => redis.set(shardKey(id), payload, 'EX', 120)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MetricsSnapshot = {
|
||||||
|
up: 1;
|
||||||
|
commandsTotal: number;
|
||||||
|
commandsErrors: number;
|
||||||
|
commandDurationSumMs: number;
|
||||||
|
commandDurationCount: number;
|
||||||
|
eventsTotal: number;
|
||||||
|
shards: Array<{ id: number; ping: number; guilds: number; updatedAt: number }>;
|
||||||
|
queueWaiting?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function collectMetricsSnapshot(redis: Redis): Promise<MetricsSnapshot> {
|
||||||
|
const [commandsTotal, commandsErrors, durationSum, durationCount, eventsTotal, shardKeys] =
|
||||||
|
await Promise.all([
|
||||||
|
redis.get(KEY_COMMANDS_TOTAL),
|
||||||
|
redis.get(KEY_COMMANDS_ERRORS),
|
||||||
|
redis.get(KEY_COMMAND_DURATION_SUM),
|
||||||
|
redis.get(KEY_COMMAND_DURATION_COUNT),
|
||||||
|
redis.get(KEY_EVENTS_TOTAL),
|
||||||
|
redis.keys('nexumi:metrics:shard:*')
|
||||||
|
]);
|
||||||
|
|
||||||
|
const shards: MetricsSnapshot['shards'] = [];
|
||||||
|
if (shardKeys.length > 0) {
|
||||||
|
const values = await redis.mget(...shardKeys);
|
||||||
|
for (let i = 0; i < shardKeys.length; i++) {
|
||||||
|
const id = Number(shardKeys[i]!.replace('nexumi:metrics:shard:', ''));
|
||||||
|
const raw = values[i];
|
||||||
|
if (!raw || Number.isNaN(id)) continue;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as { ping: number; guilds: number; updatedAt: number };
|
||||||
|
shards.push({ id, ping: parsed.ping, guilds: parsed.guilds, updatedAt: parsed.updatedAt });
|
||||||
|
} catch {
|
||||||
|
// ignore malformed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shards.sort((a, b) => a.id - b.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
up: 1,
|
||||||
|
commandsTotal: Number(commandsTotal ?? 0),
|
||||||
|
commandsErrors: Number(commandsErrors ?? 0),
|
||||||
|
commandDurationSumMs: Number(durationSum ?? 0),
|
||||||
|
commandDurationCount: Number(durationCount ?? 0),
|
||||||
|
eventsTotal: Number(eventsTotal ?? 0),
|
||||||
|
shards
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPrometheus(snapshot: MetricsSnapshot): string {
|
||||||
|
const lines: string[] = [
|
||||||
|
'# HELP nexumi_up 1 if the metrics endpoint is healthy',
|
||||||
|
'# TYPE nexumi_up gauge',
|
||||||
|
`nexumi_up ${snapshot.up}`,
|
||||||
|
'# HELP nexumi_commands_total Total slash commands handled',
|
||||||
|
'# TYPE nexumi_commands_total counter',
|
||||||
|
`nexumi_commands_total ${snapshot.commandsTotal}`,
|
||||||
|
'# HELP nexumi_commands_errors_total Total slash command failures',
|
||||||
|
'# TYPE nexumi_commands_errors_total counter',
|
||||||
|
`nexumi_commands_errors_total ${snapshot.commandsErrors}`,
|
||||||
|
'# HELP nexumi_command_duration_ms_sum Sum of command durations in milliseconds',
|
||||||
|
'# TYPE nexumi_command_duration_ms_sum counter',
|
||||||
|
`nexumi_command_duration_ms_sum ${snapshot.commandDurationSumMs}`,
|
||||||
|
'# HELP nexumi_command_duration_ms_count Count of timed commands',
|
||||||
|
'# TYPE nexumi_command_duration_ms_count counter',
|
||||||
|
`nexumi_command_duration_ms_count ${snapshot.commandDurationCount}`,
|
||||||
|
'# HELP nexumi_events_total Approximate event throughput counter',
|
||||||
|
'# TYPE nexumi_events_total counter',
|
||||||
|
`nexumi_events_total ${snapshot.eventsTotal}`,
|
||||||
|
'# HELP nexumi_guilds Guild count per shard',
|
||||||
|
'# TYPE nexumi_guilds gauge',
|
||||||
|
'# HELP nexumi_shard_ping_ms Websocket heartbeat latency per shard',
|
||||||
|
'# TYPE nexumi_shard_ping_ms gauge'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const shard of snapshot.shards) {
|
||||||
|
lines.push(`nexumi_guilds{shard="${shard.id}"} ${shard.guilds}`);
|
||||||
|
lines.push(`nexumi_shard_ping_ms{shard="${shard.id}"} ${shard.ping}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.queueWaiting !== undefined) {
|
||||||
|
lines.push('# HELP nexumi_queue_waiting Approximate waiting jobs across known queues');
|
||||||
|
lines.push('# TYPE nexumi_queue_waiting gauge');
|
||||||
|
lines.push(`nexumi_queue_waiting ${snapshot.queueWaiting}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${lines.join('\n')}\n`;
|
||||||
|
}
|
||||||
33
apps/bot/src/module-gates.test.ts
Normal file
33
apps/bot/src/module-gates.test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { moduleForCommand } from './module-gates.js';
|
||||||
|
|
||||||
|
describe('moduleForCommand', () => {
|
||||||
|
it('maps moderation commands', () => {
|
||||||
|
expect(moduleForCommand('ban')).toBe('moderation');
|
||||||
|
expect(moduleForCommand('warn')).toBe('moderation');
|
||||||
|
expect(moduleForCommand('case')).toBe('moderation');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps redis-only and prisma modules', () => {
|
||||||
|
expect(moduleForCommand('giveaway')).toBe('giveaways');
|
||||||
|
expect(moduleForCommand('tag')).toBe('tags');
|
||||||
|
expect(moduleForCommand('backup')).toBe('guildbackup');
|
||||||
|
expect(moduleForCommand('economy' as string)).toBeNull();
|
||||||
|
expect(moduleForCommand('balance')).toBe('economy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves core commands unmapped', () => {
|
||||||
|
expect(moduleForCommand('help')).toBeNull();
|
||||||
|
expect(moduleForCommand('info')).toBeNull();
|
||||||
|
expect(moduleForCommand('gdpr')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('feature flag rollout hashing', () => {
|
||||||
|
it('is stable for the same guild/key pair', () => {
|
||||||
|
const a = createHash('sha256').update('moderation:guild-1').digest().readUInt32BE(0) % 100;
|
||||||
|
const b = createHash('sha256').update('moderation:guild-1').digest().readUInt32BE(0) % 100;
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
275
apps/bot/src/module-gates.ts
Normal file
275
apps/bot/src/module-gates.ts
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
import type { DashboardModuleId } from '@nexumi/shared';
|
||||||
|
import type { PrismaClient } from '@prisma/client';
|
||||||
|
import type { Redis } from 'ioredis';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
|
const REDIS_ONLY_MODULES = new Set<DashboardModuleId>([
|
||||||
|
'giveaways',
|
||||||
|
'tags',
|
||||||
|
'selfroles',
|
||||||
|
'feeds',
|
||||||
|
'scheduler',
|
||||||
|
'guildbackup',
|
||||||
|
'messages'
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Slash command name → dashboard module. Core/GDPR/utility (except gated) omit. */
|
||||||
|
const COMMAND_MODULE_MAP: Record<string, DashboardModuleId> = {
|
||||||
|
ban: 'moderation',
|
||||||
|
unban: 'moderation',
|
||||||
|
kick: 'moderation',
|
||||||
|
timeout: 'moderation',
|
||||||
|
untimeout: 'moderation',
|
||||||
|
warn: 'moderation',
|
||||||
|
purge: 'moderation',
|
||||||
|
slowmode: 'moderation',
|
||||||
|
lock: 'moderation',
|
||||||
|
unlock: 'moderation',
|
||||||
|
nick: 'moderation',
|
||||||
|
case: 'moderation',
|
||||||
|
modnote: 'moderation',
|
||||||
|
automod: 'automod',
|
||||||
|
welcome: 'welcome',
|
||||||
|
verify: 'verification',
|
||||||
|
rank: 'leveling',
|
||||||
|
leaderboard: 'leveling',
|
||||||
|
xp: 'leveling',
|
||||||
|
balance: 'economy',
|
||||||
|
daily: 'economy',
|
||||||
|
weekly: 'economy',
|
||||||
|
work: 'economy',
|
||||||
|
pay: 'economy',
|
||||||
|
gamble: 'economy',
|
||||||
|
slots: 'economy',
|
||||||
|
blackjack: 'economy',
|
||||||
|
coinflip: 'economy',
|
||||||
|
shop: 'economy',
|
||||||
|
inventory: 'economy',
|
||||||
|
eco: 'economy',
|
||||||
|
'8ball': 'fun',
|
||||||
|
dice: 'fun',
|
||||||
|
rps: 'fun',
|
||||||
|
choose: 'fun',
|
||||||
|
trivia: 'fun',
|
||||||
|
tictactoe: 'fun',
|
||||||
|
connect4: 'fun',
|
||||||
|
hangman: 'fun',
|
||||||
|
meme: 'fun',
|
||||||
|
cat: 'fun',
|
||||||
|
dog: 'fun',
|
||||||
|
giveaway: 'giveaways',
|
||||||
|
ticket: 'tickets',
|
||||||
|
selfroles: 'selfroles',
|
||||||
|
tag: 'tags',
|
||||||
|
starboard: 'starboard',
|
||||||
|
suggest: 'suggestions',
|
||||||
|
suggestion: 'suggestions',
|
||||||
|
birthday: 'birthdays',
|
||||||
|
voice: 'tempvoice',
|
||||||
|
invites: 'stats',
|
||||||
|
stats: 'stats',
|
||||||
|
feeds: 'feeds',
|
||||||
|
schedule: 'scheduler',
|
||||||
|
announce: 'scheduler',
|
||||||
|
backup: 'guildbackup',
|
||||||
|
embed: 'messages'
|
||||||
|
};
|
||||||
|
|
||||||
|
function redisModulesKey(guildId: string): string {
|
||||||
|
return `dashboard:modules:${guildId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function funConfigKey(guildId: string): string {
|
||||||
|
return `fun:config:${guildId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashPercent(input: string): number {
|
||||||
|
const digest = createHash('sha256').update(input).digest();
|
||||||
|
return digest.readUInt32BE(0) % 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moduleForCommand(commandName: string): DashboardModuleId | null {
|
||||||
|
return COMMAND_MODULE_MAP[commandName] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isFeatureFlagEnabled(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
key: string,
|
||||||
|
guildId: string | null
|
||||||
|
): Promise<boolean> {
|
||||||
|
const flag = await prisma.featureFlag.findUnique({ where: { key } });
|
||||||
|
if (!flag) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (guildId && flag.whitelistGuildIds.includes(guildId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!flag.enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (flag.rolloutPercent >= 100) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (flag.rolloutPercent <= 0 || !guildId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return hashPercent(`${flag.key}:${guildId}`) < flag.rolloutPercent;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isRedisModuleEnabled(
|
||||||
|
redis: Redis,
|
||||||
|
guildId: string,
|
||||||
|
moduleId: DashboardModuleId
|
||||||
|
): Promise<boolean> {
|
||||||
|
const value = await redis.hget(redisModulesKey(guildId), moduleId);
|
||||||
|
if (value === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return value === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isFunEnabled(redis: Redis, guildId: string): Promise<boolean> {
|
||||||
|
const raw = await redis.get(funConfigKey(guildId));
|
||||||
|
if (!raw) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as { enabled?: boolean };
|
||||||
|
return parsed.enabled ?? true;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isModuleEnabled(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
redis: Redis,
|
||||||
|
guildId: string,
|
||||||
|
moduleId: DashboardModuleId
|
||||||
|
): Promise<boolean> {
|
||||||
|
const flagOk = await isFeatureFlagEnabled(prisma, moduleId, guildId);
|
||||||
|
if (!flagOk) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (REDIS_ONLY_MODULES.has(moduleId)) {
|
||||||
|
return isRedisModuleEnabled(redis, guildId, moduleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (moduleId) {
|
||||||
|
case 'moderation': {
|
||||||
|
const settings = await prisma.guildSettings.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { moderationEnabled: true }
|
||||||
|
});
|
||||||
|
return settings?.moderationEnabled ?? true;
|
||||||
|
}
|
||||||
|
case 'automod': {
|
||||||
|
const config = await prisma.autoModConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { enabled: true }
|
||||||
|
});
|
||||||
|
return config?.enabled ?? true;
|
||||||
|
}
|
||||||
|
case 'logging': {
|
||||||
|
const config = await prisma.loggingConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { enabled: true }
|
||||||
|
});
|
||||||
|
return config?.enabled ?? true;
|
||||||
|
}
|
||||||
|
case 'welcome': {
|
||||||
|
const config = await prisma.welcomeConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { welcomeEnabled: true, leaveEnabled: true }
|
||||||
|
});
|
||||||
|
if (!config) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return Boolean(config.welcomeEnabled || config.leaveEnabled);
|
||||||
|
}
|
||||||
|
case 'verification': {
|
||||||
|
const config = await prisma.verificationConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { enabled: true }
|
||||||
|
});
|
||||||
|
return config?.enabled ?? false;
|
||||||
|
}
|
||||||
|
case 'leveling': {
|
||||||
|
const config = await prisma.levelingConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { enabled: true }
|
||||||
|
});
|
||||||
|
return config?.enabled ?? true;
|
||||||
|
}
|
||||||
|
case 'economy': {
|
||||||
|
const config = await prisma.economyConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { enabled: true }
|
||||||
|
});
|
||||||
|
return config?.enabled ?? true;
|
||||||
|
}
|
||||||
|
case 'fun':
|
||||||
|
return isFunEnabled(redis, guildId);
|
||||||
|
case 'tickets': {
|
||||||
|
const config = await prisma.ticketConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { enabled: true }
|
||||||
|
});
|
||||||
|
return config?.enabled ?? true;
|
||||||
|
}
|
||||||
|
case 'starboard': {
|
||||||
|
const config = await prisma.starboardConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { enabled: true }
|
||||||
|
});
|
||||||
|
return config?.enabled ?? false;
|
||||||
|
}
|
||||||
|
case 'suggestions': {
|
||||||
|
const config = await prisma.suggestionConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { enabled: true }
|
||||||
|
});
|
||||||
|
return config?.enabled ?? true;
|
||||||
|
}
|
||||||
|
case 'birthdays': {
|
||||||
|
const config = await prisma.birthdayConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { enabled: true }
|
||||||
|
});
|
||||||
|
return config?.enabled ?? true;
|
||||||
|
}
|
||||||
|
case 'tempvoice': {
|
||||||
|
const config = await prisma.tempVoiceConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { enabled: true }
|
||||||
|
});
|
||||||
|
return config?.enabled ?? false;
|
||||||
|
}
|
||||||
|
case 'stats': {
|
||||||
|
const config = await prisma.statsConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { enabled: true }
|
||||||
|
});
|
||||||
|
return config?.enabled ?? false;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isUserBlacklisted(prisma: PrismaClient, userId: string): Promise<boolean> {
|
||||||
|
const row = await prisma.globalUserBlacklist.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
return Boolean(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isGuildBlacklisted(prisma: PrismaClient, guildId: string): Promise<boolean> {
|
||||||
|
const row = await prisma.guildBlacklist.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
return Boolean(row);
|
||||||
|
}
|
||||||
@@ -154,7 +154,7 @@ const backupCommand: SlashCommand = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await promptDeleteConfirmation(interaction, locale, backupId);
|
await promptDeleteConfirmation(interaction, context, locale, backupId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ const backupCommand: SlashCommand = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await promptRestoreConfirmation(interaction, locale, backupId);
|
await promptRestoreConfirmation(interaction, context, locale, backupId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import { type Locale, t, tf } from '@nexumi/shared';
|
import { type Locale, t, tf } from '@nexumi/shared';
|
||||||
import type { BotContext } from '../../types.js';
|
import type { BotContext } from '../../types.js';
|
||||||
import { getGuildLocale } from '../../i18n.js';
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
|
import {
|
||||||
|
deleteConfirmation,
|
||||||
|
getConfirmation,
|
||||||
|
setConfirmation,
|
||||||
|
takeConfirmation
|
||||||
|
} from '../../confirmation-store.js';
|
||||||
import {
|
import {
|
||||||
deleteGuildBackup,
|
deleteGuildBackup,
|
||||||
enqueueGuildBackupRestore,
|
enqueueGuildBackupRestore,
|
||||||
@@ -22,7 +28,8 @@ import {
|
|||||||
const DELETE_CONFIRM_PREFIX = 'gbackup:confirm:delete:';
|
const DELETE_CONFIRM_PREFIX = 'gbackup:confirm:delete:';
|
||||||
const RESTORE_CONFIRM_PREFIX = 'gbackup:confirm:restore:';
|
const RESTORE_CONFIRM_PREFIX = 'gbackup:confirm:restore:';
|
||||||
const CANCEL_PREFIX = 'gbackup:cancel:';
|
const CANCEL_PREFIX = 'gbackup:cancel:';
|
||||||
const TTL_MS = 120_000;
|
const NAMESPACE = 'guildbackup';
|
||||||
|
const TTL_SECONDS = 120;
|
||||||
|
|
||||||
type PendingDelete = {
|
type PendingDelete = {
|
||||||
action: 'delete';
|
action: 'delete';
|
||||||
@@ -30,7 +37,6 @@ type PendingDelete = {
|
|||||||
userId: string;
|
userId: string;
|
||||||
guildId: string;
|
guildId: string;
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
expiresAt: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type PendingRestore = {
|
type PendingRestore = {
|
||||||
@@ -40,22 +46,10 @@ type PendingRestore = {
|
|||||||
userId: string;
|
userId: string;
|
||||||
guildId: string;
|
guildId: string;
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
expiresAt: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type PendingEntry = PendingDelete | PendingRestore;
|
type PendingEntry = PendingDelete | PendingRestore;
|
||||||
|
|
||||||
const pending = new Map<string, PendingEntry>();
|
|
||||||
|
|
||||||
function pruneExpired(): void {
|
|
||||||
const now = Date.now();
|
|
||||||
for (const [token, entry] of pending) {
|
|
||||||
if (entry.expiresAt <= now) {
|
|
||||||
pending.delete(token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRows(token: string, locale: Locale, confirmPrefix: string): ActionRowBuilder<ButtonBuilder>[] {
|
function buildRows(token: string, locale: Locale, confirmPrefix: string): ActionRowBuilder<ButtonBuilder>[] {
|
||||||
return [
|
return [
|
||||||
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
@@ -73,19 +67,24 @@ function buildRows(token: string, locale: Locale, confirmPrefix: string): Action
|
|||||||
|
|
||||||
export async function promptDeleteConfirmation(
|
export async function promptDeleteConfirmation(
|
||||||
interaction: ChatInputCommandInteraction,
|
interaction: ChatInputCommandInteraction,
|
||||||
|
context: BotContext,
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
backupId: string
|
backupId: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
pruneExpired();
|
|
||||||
const token = randomUUID();
|
const token = randomUUID();
|
||||||
pending.set(token, {
|
await setConfirmation(
|
||||||
|
context.redis,
|
||||||
|
NAMESPACE,
|
||||||
|
token,
|
||||||
|
{
|
||||||
action: 'delete',
|
action: 'delete',
|
||||||
backupId,
|
backupId,
|
||||||
userId: interaction.user.id,
|
userId: interaction.user.id,
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
locale,
|
locale
|
||||||
expiresAt: Date.now() + TTL_MS
|
} satisfies PendingDelete,
|
||||||
});
|
TTL_SECONDS
|
||||||
|
);
|
||||||
|
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: tf(locale, 'guildbackup.confirm.delete', { id: backupId }),
|
content: tf(locale, 'guildbackup.confirm.delete', { id: backupId }),
|
||||||
@@ -96,20 +95,25 @@ export async function promptDeleteConfirmation(
|
|||||||
|
|
||||||
export async function promptRestoreConfirmation(
|
export async function promptRestoreConfirmation(
|
||||||
interaction: ChatInputCommandInteraction,
|
interaction: ChatInputCommandInteraction,
|
||||||
|
context: BotContext,
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
backupId: string
|
backupId: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
pruneExpired();
|
|
||||||
const token = randomUUID();
|
const token = randomUUID();
|
||||||
pending.set(token, {
|
await setConfirmation(
|
||||||
|
context.redis,
|
||||||
|
NAMESPACE,
|
||||||
|
token,
|
||||||
|
{
|
||||||
action: 'restore',
|
action: 'restore',
|
||||||
step: 1,
|
step: 1,
|
||||||
backupId,
|
backupId,
|
||||||
userId: interaction.user.id,
|
userId: interaction.user.id,
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
locale,
|
locale
|
||||||
expiresAt: Date.now() + TTL_MS
|
} satisfies PendingRestore,
|
||||||
});
|
TTL_SECONDS
|
||||||
|
);
|
||||||
|
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: tf(locale, 'guildbackup.confirm.restore.step1', { id: backupId }),
|
content: tf(locale, 'guildbackup.confirm.restore.step1', { id: backupId }),
|
||||||
@@ -122,8 +126,6 @@ export async function handleGuildBackupButton(
|
|||||||
interaction: ButtonInteraction,
|
interaction: ButtonInteraction,
|
||||||
context: BotContext
|
context: BotContext
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
pruneExpired();
|
|
||||||
|
|
||||||
const customId = interaction.customId;
|
const customId = interaction.customId;
|
||||||
const isDeleteConfirm = customId.startsWith(DELETE_CONFIRM_PREFIX);
|
const isDeleteConfirm = customId.startsWith(DELETE_CONFIRM_PREFIX);
|
||||||
const isRestoreConfirm = customId.startsWith(RESTORE_CONFIRM_PREFIX);
|
const isRestoreConfirm = customId.startsWith(RESTORE_CONFIRM_PREFIX);
|
||||||
@@ -141,48 +143,57 @@ export async function handleGuildBackupButton(
|
|||||||
: CANCEL_PREFIX.length
|
: CANCEL_PREFIX.length
|
||||||
);
|
);
|
||||||
|
|
||||||
const entry = pending.get(token);
|
const peeked = await getConfirmation<PendingEntry>(context.redis, NAMESPACE, token);
|
||||||
if (!entry) {
|
if (!peeked) {
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
await interaction.reply({ content: t(locale, 'guildbackup.confirm.expired'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'guildbackup.confirm.expired'), ephemeral: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.user.id !== entry.userId) {
|
if (interaction.user.id !== peeked.userId) {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: t(entry.locale, 'guildbackup.confirm.unauthorized'),
|
content: t(peeked.locale, 'guildbackup.confirm.unauthorized'),
|
||||||
ephemeral: true
|
ephemeral: true
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCancel) {
|
if (isCancel) {
|
||||||
pending.delete(token);
|
await deleteConfirmation(context.redis, NAMESPACE, token);
|
||||||
await interaction.update({
|
await interaction.update({
|
||||||
content: t(entry.locale, 'guildbackup.confirm.cancelled'),
|
content: t(peeked.locale, 'guildbackup.confirm.cancelled'),
|
||||||
components: []
|
components: []
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.action === 'delete') {
|
if (peeked.action === 'delete') {
|
||||||
pending.delete(token);
|
const entry = await takeConfirmation<PendingDelete>(context.redis, NAMESPACE, token);
|
||||||
|
if (!entry) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
await interaction.reply({ content: t(locale, 'guildbackup.confirm.expired'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
await executeDelete(interaction, context, entry);
|
await executeDelete(interaction, context, entry);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.step === 1) {
|
if (peeked.step === 1) {
|
||||||
entry.step = 2;
|
const next: PendingRestore = { ...peeked, step: 2 };
|
||||||
entry.expiresAt = Date.now() + TTL_MS;
|
await setConfirmation(context.redis, NAMESPACE, token, next, TTL_SECONDS);
|
||||||
pending.set(token, entry);
|
|
||||||
await interaction.update({
|
await interaction.update({
|
||||||
content: tf(entry.locale, 'guildbackup.confirm.restore.step2', { id: entry.backupId }),
|
content: tf(peeked.locale, 'guildbackup.confirm.restore.step2', { id: peeked.backupId }),
|
||||||
components: buildRows(token, entry.locale, RESTORE_CONFIRM_PREFIX)
|
components: buildRows(token, peeked.locale, RESTORE_CONFIRM_PREFIX)
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
pending.delete(token);
|
const entry = await takeConfirmation<PendingRestore>(context.redis, NAMESPACE, token);
|
||||||
|
if (!entry) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
await interaction.reply({ content: t(locale, 'guildbackup.confirm.expired'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
await executeRestore(interaction, context, entry);
|
await executeRestore(interaction, context, entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { TextChannel, type ButtonInteraction } from 'discord.js';
|
import { TextChannel, type ButtonInteraction } from 'discord.js';
|
||||||
import { type Locale, t } from '@nexumi/shared';
|
import { type Locale, t, tf } from '@nexumi/shared';
|
||||||
import type { BotContext } from '../../types.js';
|
import type { BotContext } from '../../types.js';
|
||||||
|
import { assertBannableForConfirm } from '../../hierarchy.js';
|
||||||
import { createCase, scheduleTempBanExpire } from './service.js';
|
import { createCase, scheduleTempBanExpire } from './service.js';
|
||||||
import { parseDuration } from './duration.js';
|
import { tryParseDuration } from './duration.js';
|
||||||
|
|
||||||
type BanPayload = {
|
type BanPayload = {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -36,8 +37,24 @@ export async function executeBan(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!(await assertBannableForConfirm(interaction, locale, payload.userId))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let durationMs: number | null = null;
|
||||||
|
if (payload.duration) {
|
||||||
|
durationMs = tryParseDuration(payload.duration);
|
||||||
|
if (durationMs === null || durationMs <= 0) {
|
||||||
|
await interaction.update({
|
||||||
|
content: t(locale, 'moderation.duration.invalid'),
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await guild.members.ban(payload.userId, { reason: payload.reason });
|
await guild.members.ban(payload.userId, { reason: payload.reason });
|
||||||
await createCase(context, {
|
const modCase = await createCase(context, {
|
||||||
guildId: guild.id,
|
guildId: guild.id,
|
||||||
targetUserId: payload.userId,
|
targetUserId: payload.userId,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -47,21 +64,17 @@ export async function executeBan(
|
|||||||
source: 'button_confirmation',
|
source: 'button_confirmation',
|
||||||
metadata: {
|
metadata: {
|
||||||
confirmed: true,
|
confirmed: true,
|
||||||
duration: payload.duration
|
duration: payload.duration,
|
||||||
|
durationMs
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (payload.duration) {
|
if (durationMs !== null) {
|
||||||
await scheduleTempBanExpire(
|
await scheduleTempBanExpire(guild.id, payload.userId, durationMs, payload.reason);
|
||||||
guild.id,
|
|
||||||
payload.userId,
|
|
||||||
parseDuration(payload.duration),
|
|
||||||
payload.reason
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await interaction.update({
|
await interaction.update({
|
||||||
content: t(locale, 'moderation.success'),
|
content: tf(locale, 'moderation.success.case', { caseNumber: modCase.caseNumber }),
|
||||||
components: []
|
components: []
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -97,7 +110,7 @@ export async function executePurge(
|
|||||||
deletedCount = deleted.size;
|
deletedCount = deleted.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
await createCase(context, {
|
const modCase = await createCase(context, {
|
||||||
guildId: guild.id,
|
guildId: guild.id,
|
||||||
targetUserId: interaction.user.id,
|
targetUserId: interaction.user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -122,7 +135,10 @@ export async function executePurge(
|
|||||||
});
|
});
|
||||||
|
|
||||||
await interaction.update({
|
await interaction.update({
|
||||||
content: t(locale, 'moderation.success'),
|
content: tf(locale, 'moderation.purge.done', {
|
||||||
|
deletedCount,
|
||||||
|
caseNumber: modCase.caseNumber
|
||||||
|
}),
|
||||||
components: []
|
components: []
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -143,7 +159,7 @@ export async function executeCaseDelete(
|
|||||||
where: { guildId_caseNumber: { guildId, caseNumber: payload.caseNumber } }
|
where: { guildId_caseNumber: { guildId, caseNumber: payload.caseNumber } }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing || existing.deletedAt) {
|
||||||
await interaction.update({
|
await interaction.update({
|
||||||
content: t(locale, 'moderation.case.notFound'),
|
content: t(locale, 'moderation.case.notFound'),
|
||||||
components: []
|
components: []
|
||||||
@@ -156,7 +172,7 @@ export async function executeCaseDelete(
|
|||||||
data: { deletedAt: new Date() }
|
data: { deletedAt: new Date() }
|
||||||
});
|
});
|
||||||
|
|
||||||
await createCase(context, {
|
const modCase = await createCase(context, {
|
||||||
guildId,
|
guildId,
|
||||||
targetUserId: existing.targetUserId,
|
targetUserId: existing.targetUserId,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -172,7 +188,7 @@ export async function executeCaseDelete(
|
|||||||
});
|
});
|
||||||
|
|
||||||
await interaction.update({
|
await interaction.update({
|
||||||
content: t(locale, 'moderation.success'),
|
content: tf(locale, 'moderation.success.case', { caseNumber: modCase.caseNumber }),
|
||||||
components: []
|
components: []
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
PermissionFlagsBits,
|
||||||
SlashCommandBuilder,
|
SlashCommandBuilder,
|
||||||
type SlashCommandStringOption,
|
type SlashCommandStringOption,
|
||||||
type SlashCommandUserOption
|
type SlashCommandUserOption
|
||||||
@@ -26,7 +27,9 @@ function reasonOpt(o: SlashCommandStringOption, required = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const banCommandData = applyCommandDescription(
|
export const banCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('ban'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('ban')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers),
|
||||||
'moderation.ban.description'
|
'moderation.ban.description'
|
||||||
)
|
)
|
||||||
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
|
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
|
||||||
@@ -36,7 +39,9 @@ export const banCommandData = applyCommandDescription(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const unbanCommandData = applyCommandDescription(
|
export const unbanCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('unban'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('unban')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers),
|
||||||
'moderation.unban.description'
|
'moderation.unban.description'
|
||||||
)
|
)
|
||||||
.addStringOption((o) =>
|
.addStringOption((o) =>
|
||||||
@@ -45,14 +50,18 @@ export const unbanCommandData = applyCommandDescription(
|
|||||||
.addStringOption((o) => reasonOpt(o));
|
.addStringOption((o) => reasonOpt(o));
|
||||||
|
|
||||||
export const kickCommandData = applyCommandDescription(
|
export const kickCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('kick'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('kick')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.KickMembers),
|
||||||
'moderation.kick.description'
|
'moderation.kick.description'
|
||||||
)
|
)
|
||||||
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
|
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
|
||||||
.addStringOption((o) => reasonOpt(o));
|
.addStringOption((o) => reasonOpt(o));
|
||||||
|
|
||||||
export const timeoutCommandData = applyCommandDescription(
|
export const timeoutCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('timeout'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('timeout')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
|
||||||
'moderation.timeout.description'
|
'moderation.timeout.description'
|
||||||
)
|
)
|
||||||
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
|
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
|
||||||
@@ -62,14 +71,18 @@ export const timeoutCommandData = applyCommandDescription(
|
|||||||
.addStringOption((o) => reasonOpt(o));
|
.addStringOption((o) => reasonOpt(o));
|
||||||
|
|
||||||
export const untimeoutCommandData = applyCommandDescription(
|
export const untimeoutCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('untimeout'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('untimeout')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
|
||||||
'moderation.untimeout.description'
|
'moderation.untimeout.description'
|
||||||
)
|
)
|
||||||
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
|
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
|
||||||
.addStringOption((o) => reasonOpt(o));
|
.addStringOption((o) => reasonOpt(o));
|
||||||
|
|
||||||
export const warnCommandData = applyCommandDescription(
|
export const warnCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('warn'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('warn')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
|
||||||
'moderation.warn.description'
|
'moderation.warn.description'
|
||||||
)
|
)
|
||||||
.addSubcommand((s) =>
|
.addSubcommand((s) =>
|
||||||
@@ -123,7 +136,9 @@ export const warnCommandData = applyCommandDescription(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const purgeCommandData = applyCommandDescription(
|
export const purgeCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('purge'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('purge')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages),
|
||||||
'moderation.purge.description'
|
'moderation.purge.description'
|
||||||
)
|
)
|
||||||
.addIntegerOption((o) =>
|
.addIntegerOption((o) =>
|
||||||
@@ -138,24 +153,32 @@ export const purgeCommandData = applyCommandDescription(
|
|||||||
.addStringOption((o) => applyOptionDescription(o.setName('regex'), 'moderation.purge.options.regex'));
|
.addStringOption((o) => applyOptionDescription(o.setName('regex'), 'moderation.purge.options.regex'));
|
||||||
|
|
||||||
export const slowmodeCommandData = applyCommandDescription(
|
export const slowmodeCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('slowmode'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('slowmode')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels),
|
||||||
'moderation.slowmode.description'
|
'moderation.slowmode.description'
|
||||||
).addIntegerOption((o) =>
|
).addIntegerOption((o) =>
|
||||||
applyOptionDescription(o.setName('seconds').setRequired(true).setMinValue(0).setMaxValue(21600), 'moderation.slowmode.options.seconds')
|
applyOptionDescription(o.setName('seconds').setRequired(true).setMinValue(0).setMaxValue(21600), 'moderation.slowmode.options.seconds')
|
||||||
);
|
);
|
||||||
|
|
||||||
export const lockCommandData = applyCommandDescription(
|
export const lockCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('lock'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('lock')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels),
|
||||||
'moderation.lock.description'
|
'moderation.lock.description'
|
||||||
);
|
);
|
||||||
|
|
||||||
export const unlockCommandData = applyCommandDescription(
|
export const unlockCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('unlock'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('unlock')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels),
|
||||||
'moderation.unlock.description'
|
'moderation.unlock.description'
|
||||||
);
|
);
|
||||||
|
|
||||||
export const nickCommandData = applyCommandDescription(
|
export const nickCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('nick'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('nick')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageNicknames),
|
||||||
'moderation.nick.description'
|
'moderation.nick.description'
|
||||||
)
|
)
|
||||||
.addSubcommand((s) =>
|
.addSubcommand((s) =>
|
||||||
@@ -172,7 +195,9 @@ export const nickCommandData = applyCommandDescription(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const caseCommandData = applyCommandDescription(
|
export const caseCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('case'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('case')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
|
||||||
'moderation.case.description'
|
'moderation.case.description'
|
||||||
)
|
)
|
||||||
.addSubcommand((s) =>
|
.addSubcommand((s) =>
|
||||||
@@ -196,7 +221,9 @@ export const caseCommandData = applyCommandDescription(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const modNoteCommandData = applyCommandDescription(
|
export const modNoteCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('modnote'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('modnote')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
|
||||||
'moderation.modnote.description'
|
'moderation.modnote.description'
|
||||||
)
|
)
|
||||||
.addSubcommand((s) =>
|
.addSubcommand((s) =>
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import {
|
|||||||
ChannelType,
|
ChannelType,
|
||||||
PermissionFlagsBits,
|
PermissionFlagsBits,
|
||||||
TextChannel,
|
TextChannel,
|
||||||
type ChatInputCommandInteraction,
|
type ChatInputCommandInteraction
|
||||||
type GuildMember
|
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
import { t, tf } from '@nexumi/shared';
|
import { t, tf } from '@nexumi/shared';
|
||||||
import type { SlashCommand } from '../../types.js';
|
import type { SlashCommand } from '../../types.js';
|
||||||
import { requirePermission } from '../../permissions.js';
|
import { requirePermission } from '../../permissions.js';
|
||||||
|
import { assertModerableMember } from '../../hierarchy.js';
|
||||||
import { applyWarnEscalation, createCase } from './service.js';
|
import { applyWarnEscalation, createCase } from './service.js';
|
||||||
import { parseDuration } from './duration.js';
|
import { parseDuration, parseTimeoutDuration, tryParseDuration } from './duration.js';
|
||||||
import { getGuildLocale } from '../../i18n.js';
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
import { promptDestructiveConfirmation } from './confirmations.js';
|
import { promptDestructiveConfirmation } from './confirmations.js';
|
||||||
import {
|
import {
|
||||||
@@ -51,6 +51,17 @@ async function ensureMod(
|
|||||||
return requirePermission(interaction, permission, locale);
|
return requirePermission(interaction, permission, locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function replySuccessCase(
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
locale: 'de' | 'en',
|
||||||
|
caseNumber: number
|
||||||
|
): Promise<void> {
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'moderation.success.case', { caseNumber }),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const banCommand: SlashCommand = {
|
const banCommand: SlashCommand = {
|
||||||
data: banCommandData,
|
data: banCommandData,
|
||||||
async execute(interaction, context) {
|
async execute(interaction, context) {
|
||||||
@@ -59,7 +70,22 @@ const banCommand: SlashCommand = {
|
|||||||
const user = interaction.options.getUser('user', true);
|
const user = interaction.options.getUser('user', true);
|
||||||
const reason = reasonFrom(interaction, locale);
|
const reason = reasonFrom(interaction, locale);
|
||||||
const duration = interaction.options.getString('duration');
|
const duration = interaction.options.getString('duration');
|
||||||
await promptDestructiveConfirmation(interaction, locale, {
|
|
||||||
|
if (duration) {
|
||||||
|
const durationMs = tryParseDuration(duration);
|
||||||
|
if (durationMs === null || durationMs <= 0) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'moderation.duration.invalid'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hierarchy = await assertModerableMember(interaction, user.id, locale, 'ban');
|
||||||
|
if (!hierarchy.ok) return;
|
||||||
|
|
||||||
|
await promptDestructiveConfirmation(interaction, context, locale, {
|
||||||
action: 'ban',
|
action: 'ban',
|
||||||
payload: {
|
payload: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -78,7 +104,7 @@ const unbanCommand: SlashCommand = {
|
|||||||
const userId = interaction.options.getString('user_id', true);
|
const userId = interaction.options.getString('user_id', true);
|
||||||
const reason = reasonFrom(interaction, locale);
|
const reason = reasonFrom(interaction, locale);
|
||||||
await interaction.guild!.members.unban(userId, reason);
|
await interaction.guild!.members.unban(userId, reason);
|
||||||
await createCase(context, {
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: userId,
|
targetUserId: userId,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -86,7 +112,7 @@ const unbanCommand: SlashCommand = {
|
|||||||
action: 'UNBAN',
|
action: 'UNBAN',
|
||||||
reason
|
reason
|
||||||
});
|
});
|
||||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -97,9 +123,10 @@ const kickCommand: SlashCommand = {
|
|||||||
if (!(await ensureMod(interaction, PermissionFlagsBits.KickMembers, locale))) return;
|
if (!(await ensureMod(interaction, PermissionFlagsBits.KickMembers, locale))) return;
|
||||||
const user = interaction.options.getUser('user', true);
|
const user = interaction.options.getUser('user', true);
|
||||||
const reason = reasonFrom(interaction, locale);
|
const reason = reasonFrom(interaction, locale);
|
||||||
const member = (await interaction.guild!.members.fetch(user.id)) as GuildMember;
|
const hierarchy = await assertModerableMember(interaction, user.id, locale, 'kick');
|
||||||
await member.kick(reason);
|
if (!hierarchy.ok || !hierarchy.member) return;
|
||||||
await createCase(context, {
|
await hierarchy.member.kick(reason);
|
||||||
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: user.id,
|
targetUserId: user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -107,7 +134,7 @@ const kickCommand: SlashCommand = {
|
|||||||
action: 'KICK',
|
action: 'KICK',
|
||||||
reason
|
reason
|
||||||
});
|
});
|
||||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -117,11 +144,21 @@ const timeoutCommand: SlashCommand = {
|
|||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return;
|
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return;
|
||||||
const user = interaction.options.getUser('user', true);
|
const user = interaction.options.getUser('user', true);
|
||||||
const duration = parseDuration(interaction.options.getString('duration', true));
|
let duration: number;
|
||||||
|
try {
|
||||||
|
duration = parseTimeoutDuration(interaction.options.getString('duration', true));
|
||||||
|
} catch {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'moderation.duration.invalidTimeout'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const reason = reasonFrom(interaction, locale);
|
const reason = reasonFrom(interaction, locale);
|
||||||
const member = await interaction.guild!.members.fetch(user.id);
|
const hierarchy = await assertModerableMember(interaction, user.id, locale, 'timeout');
|
||||||
await member.timeout(duration, reason);
|
if (!hierarchy.ok || !hierarchy.member) return;
|
||||||
await createCase(context, {
|
await hierarchy.member.timeout(duration, reason);
|
||||||
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: user.id,
|
targetUserId: user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -130,7 +167,7 @@ const timeoutCommand: SlashCommand = {
|
|||||||
reason,
|
reason,
|
||||||
metadata: { durationMs: duration }
|
metadata: { durationMs: duration }
|
||||||
});
|
});
|
||||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -141,9 +178,10 @@ const untimeoutCommand: SlashCommand = {
|
|||||||
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return;
|
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return;
|
||||||
const user = interaction.options.getUser('user', true);
|
const user = interaction.options.getUser('user', true);
|
||||||
const reason = reasonFrom(interaction, locale);
|
const reason = reasonFrom(interaction, locale);
|
||||||
const member = await interaction.guild!.members.fetch(user.id);
|
const hierarchy = await assertModerableMember(interaction, user.id, locale, 'timeout');
|
||||||
await member.timeout(null, reason);
|
if (!hierarchy.ok || !hierarchy.member) return;
|
||||||
await createCase(context, {
|
await hierarchy.member.timeout(null, reason);
|
||||||
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: user.id,
|
targetUserId: user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -151,7 +189,7 @@ const untimeoutCommand: SlashCommand = {
|
|||||||
action: 'UN_TIMEOUT',
|
action: 'UN_TIMEOUT',
|
||||||
reason
|
reason
|
||||||
});
|
});
|
||||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -166,7 +204,19 @@ const warnCommand: SlashCommand = {
|
|||||||
const action = interaction.options.getString('action', true);
|
const action = interaction.options.getString('action', true);
|
||||||
const durationInput = interaction.options.getString('duration');
|
const durationInput = interaction.options.getString('duration');
|
||||||
const reason = interaction.options.getString('reason');
|
const reason = interaction.options.getString('reason');
|
||||||
const durationMs = durationInput ? parseDuration(durationInput) : null;
|
let durationMs: number | null = null;
|
||||||
|
if (durationInput) {
|
||||||
|
try {
|
||||||
|
durationMs =
|
||||||
|
action === 'TIMEOUT' ? parseTimeoutDuration(durationInput) : parseDuration(durationInput);
|
||||||
|
} catch {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'moderation.duration.invalid'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (action === 'TIMEOUT' && !durationMs) {
|
if (action === 'TIMEOUT' && !durationMs) {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: t(locale, 'moderation.escalation.durationRequired'),
|
content: t(locale, 'moderation.escalation.durationRequired'),
|
||||||
@@ -267,7 +317,8 @@ const warnCommand: SlashCommand = {
|
|||||||
interaction,
|
interaction,
|
||||||
context,
|
context,
|
||||||
user.id,
|
user.id,
|
||||||
interaction.user.id
|
interaction.user.id,
|
||||||
|
locale
|
||||||
);
|
);
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: escalationResult
|
content: escalationResult
|
||||||
@@ -300,8 +351,18 @@ const warnCommand: SlashCommand = {
|
|||||||
|
|
||||||
if (sub === 'remove') {
|
if (sub === 'remove') {
|
||||||
const warningId = interaction.options.getString('warning_id', true);
|
const warningId = interaction.options.getString('warning_id', true);
|
||||||
const warning = await context.prisma.warning.delete({ where: { id: warningId } });
|
const warning = await context.prisma.warning.findFirst({
|
||||||
await createCase(context, {
|
where: { id: warningId, guildId: interaction.guildId! }
|
||||||
|
});
|
||||||
|
if (!warning) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'moderation.warning.notFound'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await context.prisma.warning.delete({ where: { id: warning.id } });
|
||||||
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: warning.userId,
|
targetUserId: warning.userId,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -309,13 +370,15 @@ const warnCommand: SlashCommand = {
|
|||||||
action: 'WARN_REMOVE',
|
action: 'WARN_REMOVE',
|
||||||
reason: `Warning ${warningId} removed`
|
reason: `Warning ${warningId} removed`
|
||||||
});
|
});
|
||||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = interaction.options.getUser('user', true);
|
const user = interaction.options.getUser('user', true);
|
||||||
await context.prisma.warning.deleteMany({ where: { guildId: interaction.guildId!, userId: user.id } });
|
await context.prisma.warning.deleteMany({
|
||||||
await createCase(context, {
|
where: { guildId: interaction.guildId!, userId: user.id }
|
||||||
|
});
|
||||||
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: user.id,
|
targetUserId: user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -323,7 +386,7 @@ const warnCommand: SlashCommand = {
|
|||||||
action: 'WARN_CLEAR',
|
action: 'WARN_CLEAR',
|
||||||
reason: 'Warnings cleared'
|
reason: 'Warnings cleared'
|
||||||
});
|
});
|
||||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -352,7 +415,7 @@ const purgeCommand: SlashCommand = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await promptDestructiveConfirmation(interaction, locale, {
|
await promptDestructiveConfirmation(interaction, context, locale, {
|
||||||
action: 'purge',
|
action: 'purge',
|
||||||
payload: {
|
payload: {
|
||||||
channelId: interaction.channel.id,
|
channelId: interaction.channel.id,
|
||||||
@@ -376,7 +439,7 @@ const slowmodeCommand: SlashCommand = {
|
|||||||
if (interaction.channel instanceof TextChannel) {
|
if (interaction.channel instanceof TextChannel) {
|
||||||
await interaction.channel.setRateLimitPerUser(seconds);
|
await interaction.channel.setRateLimitPerUser(seconds);
|
||||||
}
|
}
|
||||||
await createCase(context, {
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: interaction.user.id,
|
targetUserId: interaction.user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -384,7 +447,7 @@ const slowmodeCommand: SlashCommand = {
|
|||||||
action: 'SLOWMODE',
|
action: 'SLOWMODE',
|
||||||
reason: `Set slowmode to ${seconds}s`
|
reason: `Set slowmode to ${seconds}s`
|
||||||
});
|
});
|
||||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -398,7 +461,7 @@ const lockCommand: SlashCommand = {
|
|||||||
SendMessages: false
|
SendMessages: false
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await createCase(context, {
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: interaction.user.id,
|
targetUserId: interaction.user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -406,7 +469,7 @@ const lockCommand: SlashCommand = {
|
|||||||
action: 'LOCK',
|
action: 'LOCK',
|
||||||
reason: 'Channel locked'
|
reason: 'Channel locked'
|
||||||
});
|
});
|
||||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -420,7 +483,7 @@ const unlockCommand: SlashCommand = {
|
|||||||
SendMessages: null
|
SendMessages: null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await createCase(context, {
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: interaction.user.id,
|
targetUserId: interaction.user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -428,7 +491,7 @@ const unlockCommand: SlashCommand = {
|
|||||||
action: 'UNLOCK',
|
action: 'UNLOCK',
|
||||||
reason: 'Channel unlocked'
|
reason: 'Channel unlocked'
|
||||||
});
|
});
|
||||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -439,11 +502,13 @@ const nickCommand: SlashCommand = {
|
|||||||
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageNicknames, locale))) return;
|
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageNicknames, locale))) return;
|
||||||
const sub = interaction.options.getSubcommand();
|
const sub = interaction.options.getSubcommand();
|
||||||
const user = interaction.options.getUser('user', true);
|
const user = interaction.options.getUser('user', true);
|
||||||
const member = await interaction.guild!.members.fetch(user.id);
|
const hierarchy = await assertModerableMember(interaction, user.id, locale, 'nick');
|
||||||
|
if (!hierarchy.ok || !hierarchy.member) return;
|
||||||
|
const member = hierarchy.member;
|
||||||
if (sub === 'set') {
|
if (sub === 'set') {
|
||||||
const nickname = interaction.options.getString('nickname', true);
|
const nickname = interaction.options.getString('nickname', true);
|
||||||
await member.setNickname(nickname);
|
await member.setNickname(nickname);
|
||||||
await createCase(context, {
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: user.id,
|
targetUserId: user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -451,9 +516,10 @@ const nickCommand: SlashCommand = {
|
|||||||
action: 'NICK_SET',
|
action: 'NICK_SET',
|
||||||
reason: nickname
|
reason: nickname
|
||||||
});
|
});
|
||||||
|
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||||
} else {
|
} else {
|
||||||
await member.setNickname(null);
|
await member.setNickname(null);
|
||||||
await createCase(context, {
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: user.id,
|
targetUserId: user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -461,8 +527,8 @@ const nickCommand: SlashCommand = {
|
|||||||
action: 'NICK_RESET',
|
action: 'NICK_RESET',
|
||||||
reason: 'Nickname reset'
|
reason: 'Nickname reset'
|
||||||
});
|
});
|
||||||
|
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||||
}
|
}
|
||||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -474,8 +540,12 @@ const caseCommand: SlashCommand = {
|
|||||||
const sub = interaction.options.getSubcommand();
|
const sub = interaction.options.getSubcommand();
|
||||||
const caseNumber = interaction.options.getInteger('id', true);
|
const caseNumber = interaction.options.getInteger('id', true);
|
||||||
if (sub === 'view') {
|
if (sub === 'view') {
|
||||||
const found = await context.prisma.case.findUnique({
|
const found = await context.prisma.case.findFirst({
|
||||||
where: { guildId_caseNumber: { guildId: interaction.guildId!, caseNumber } }
|
where: {
|
||||||
|
guildId: interaction.guildId!,
|
||||||
|
caseNumber,
|
||||||
|
deletedAt: null
|
||||||
|
}
|
||||||
});
|
});
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: found ? JSON.stringify(found, null, 2) : t(locale, 'moderation.case.notFound'),
|
content: found ? JSON.stringify(found, null, 2) : t(locale, 'moderation.case.notFound'),
|
||||||
@@ -485,14 +555,31 @@ const caseCommand: SlashCommand = {
|
|||||||
}
|
}
|
||||||
if (sub === 'edit') {
|
if (sub === 'edit') {
|
||||||
const reason = interaction.options.getString('reason', true);
|
const reason = interaction.options.getString('reason', true);
|
||||||
await context.prisma.case.update({
|
const existing = await context.prisma.case.findFirst({
|
||||||
where: { guildId_caseNumber: { guildId: interaction.guildId!, caseNumber } },
|
where: {
|
||||||
data: { reason }
|
guildId: interaction.guildId!,
|
||||||
|
caseNumber,
|
||||||
|
deletedAt: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'moderation.case.notFound'),
|
||||||
|
ephemeral: true
|
||||||
});
|
});
|
||||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await promptDestructiveConfirmation(interaction, locale, {
|
await context.prisma.case.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { reason }
|
||||||
|
});
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'moderation.success.case', { caseNumber }),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await promptDestructiveConfirmation(interaction, context, locale, {
|
||||||
action: 'case_delete',
|
action: 'case_delete',
|
||||||
payload: { caseNumber }
|
payload: { caseNumber }
|
||||||
});
|
});
|
||||||
@@ -517,7 +604,7 @@ const modNoteCommand: SlashCommand = {
|
|||||||
note
|
note
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
await createCase(context, {
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: user.id,
|
targetUserId: user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -525,7 +612,7 @@ const modNoteCommand: SlashCommand = {
|
|||||||
action: 'MODNOTE_ADD',
|
action: 'MODNOTE_ADD',
|
||||||
reason: note
|
reason: note
|
||||||
});
|
});
|
||||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const notes = await context.prisma.modNote.findMany({
|
const notes = await context.prisma.modNote.findMany({
|
||||||
|
|||||||
@@ -9,11 +9,18 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import { type Locale, t, tf } from '@nexumi/shared';
|
import { type Locale, t, tf } from '@nexumi/shared';
|
||||||
import type { BotContext } from '../../types.js';
|
import type { BotContext } from '../../types.js';
|
||||||
import { getGuildLocale } from '../../i18n.js';
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
|
import {
|
||||||
|
deleteConfirmation,
|
||||||
|
getConfirmation,
|
||||||
|
setConfirmation,
|
||||||
|
takeConfirmation
|
||||||
|
} from '../../confirmation-store.js';
|
||||||
import { executeBan, executeCaseDelete, executePurge } from './actions.js';
|
import { executeBan, executeCaseDelete, executePurge } from './actions.js';
|
||||||
|
|
||||||
const CONFIRM_PREFIX = 'mod:confirm:';
|
const CONFIRM_PREFIX = 'mod:confirm:';
|
||||||
const CANCEL_PREFIX = 'mod:cancel:';
|
const CANCEL_PREFIX = 'mod:cancel:';
|
||||||
const TTL_MS = 60_000;
|
const NAMESPACE = 'moderation';
|
||||||
|
const TTL_SECONDS = 60;
|
||||||
|
|
||||||
type BanPayload = {
|
type BanPayload = {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -44,20 +51,8 @@ type PendingEntry = PendingConfirmation & {
|
|||||||
moderatorId: string;
|
moderatorId: string;
|
||||||
guildId: string;
|
guildId: string;
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
expiresAt: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const pending = new Map<string, PendingEntry>();
|
|
||||||
|
|
||||||
function pruneExpired(): void {
|
|
||||||
const now = Date.now();
|
|
||||||
for (const [token, entry] of pending) {
|
|
||||||
if (entry.expiresAt <= now) {
|
|
||||||
pending.delete(token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmationMessage(locale: Locale, entry: PendingConfirmation): string {
|
function confirmationMessage(locale: Locale, entry: PendingConfirmation): string {
|
||||||
if (entry.action === 'ban') {
|
if (entry.action === 'ban') {
|
||||||
return tf(locale, 'moderation.confirm.ban', {
|
return tf(locale, 'moderation.confirm.ban', {
|
||||||
@@ -89,18 +84,23 @@ function buildRows(token: string, locale: Locale): ActionRowBuilder<ButtonBuilde
|
|||||||
|
|
||||||
export async function promptDestructiveConfirmation(
|
export async function promptDestructiveConfirmation(
|
||||||
interaction: ChatInputCommandInteraction,
|
interaction: ChatInputCommandInteraction,
|
||||||
|
context: BotContext,
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
entry: PendingConfirmation
|
entry: PendingConfirmation
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
pruneExpired();
|
|
||||||
const token = randomUUID();
|
const token = randomUUID();
|
||||||
pending.set(token, {
|
await setConfirmation(
|
||||||
|
context.redis,
|
||||||
|
NAMESPACE,
|
||||||
|
token,
|
||||||
|
{
|
||||||
...entry,
|
...entry,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
locale,
|
locale
|
||||||
expiresAt: Date.now() + TTL_MS
|
} satisfies PendingEntry,
|
||||||
});
|
TTL_SECONDS
|
||||||
|
);
|
||||||
|
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: confirmationMessage(locale, entry),
|
content: confirmationMessage(locale, entry),
|
||||||
@@ -113,8 +113,6 @@ export async function handleModerationConfirmation(
|
|||||||
interaction: ButtonInteraction,
|
interaction: ButtonInteraction,
|
||||||
context: BotContext
|
context: BotContext
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
pruneExpired();
|
|
||||||
|
|
||||||
const customId = interaction.customId;
|
const customId = interaction.customId;
|
||||||
const isConfirm = customId.startsWith(CONFIRM_PREFIX);
|
const isConfirm = customId.startsWith(CONFIRM_PREFIX);
|
||||||
const isCancel = customId.startsWith(CANCEL_PREFIX);
|
const isCancel = customId.startsWith(CANCEL_PREFIX);
|
||||||
@@ -123,31 +121,37 @@ export async function handleModerationConfirmation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const token = customId.slice(isConfirm ? CONFIRM_PREFIX.length : CANCEL_PREFIX.length);
|
const token = customId.slice(isConfirm ? CONFIRM_PREFIX.length : CANCEL_PREFIX.length);
|
||||||
const entry = pending.get(token);
|
const peeked = await getConfirmation<PendingEntry>(context.redis, NAMESPACE, token);
|
||||||
if (!entry) {
|
if (!peeked) {
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
await interaction.reply({ content: t(locale, 'moderation.confirm.expired'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'moderation.confirm.expired'), ephemeral: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.user.id !== entry.moderatorId) {
|
if (interaction.user.id !== peeked.moderatorId) {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: t(entry.locale, 'moderation.confirm.unauthorized'),
|
content: t(peeked.locale, 'moderation.confirm.unauthorized'),
|
||||||
ephemeral: true
|
ephemeral: true
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
pending.delete(token);
|
|
||||||
|
|
||||||
if (isCancel) {
|
if (isCancel) {
|
||||||
|
await deleteConfirmation(context.redis, NAMESPACE, token);
|
||||||
await interaction.update({
|
await interaction.update({
|
||||||
content: t(entry.locale, 'moderation.confirm.cancelled'),
|
content: t(peeked.locale, 'moderation.confirm.cancelled'),
|
||||||
components: []
|
components: []
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const entry = await takeConfirmation<PendingEntry>(context.redis, NAMESPACE, token);
|
||||||
|
if (!entry) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
await interaction.reply({ content: t(locale, 'moderation.confirm.expired'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (entry.action === 'ban') {
|
if (entry.action === 'ban') {
|
||||||
await executeBan(interaction, context, entry.locale, entry.payload);
|
await executeBan(interaction, context, entry.locale, entry.payload);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { parseDuration } from './duration.js';
|
import {
|
||||||
|
MAX_TIMEOUT_MS,
|
||||||
|
parseDuration,
|
||||||
|
parseTimeoutDuration,
|
||||||
|
tryParseDuration
|
||||||
|
} from './duration.js';
|
||||||
|
|
||||||
describe('parseDuration', () => {
|
describe('parseDuration', () => {
|
||||||
it('parses minutes', () => {
|
it('parses minutes', () => {
|
||||||
@@ -14,3 +19,27 @@ describe('parseDuration', () => {
|
|||||||
expect(() => parseDuration('tomorrow')).toThrow();
|
expect(() => parseDuration('tomorrow')).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('parseTimeoutDuration', () => {
|
||||||
|
it('accepts durations within Discord limit', () => {
|
||||||
|
expect(parseTimeoutDuration('1d')).toBe(86_400_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects durations over 28 days', () => {
|
||||||
|
expect(() => parseTimeoutDuration('30d')).toThrow(/28/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes MAX_TIMEOUT_MS as 28 days', () => {
|
||||||
|
expect(MAX_TIMEOUT_MS).toBe(28 * 24 * 60 * 60 * 1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tryParseDuration', () => {
|
||||||
|
it('returns null for invalid input', () => {
|
||||||
|
expect(tryParseDuration('nope')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns ms for valid input', () => {
|
||||||
|
expect(tryParseDuration('10s')).toBe(10_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,10 +1,39 @@
|
|||||||
|
export const MAX_TIMEOUT_MS = 28 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export class DurationParseError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'DurationParseError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function parseDuration(input: string): number {
|
export function parseDuration(input: string): number {
|
||||||
const m = input.match(/^(\d+)([smhd])$/i);
|
const m = input.match(/^(\d+)([smhd])$/i);
|
||||||
if (!m) {
|
if (!m) {
|
||||||
throw new Error('Duration must be like 30m, 12h, 7d');
|
throw new DurationParseError('Duration must be like 30m, 12h, 7d');
|
||||||
}
|
}
|
||||||
const amount = Number(m[1]);
|
const amount = Number(m[1]);
|
||||||
const unit = m[2].toLowerCase();
|
const unit = m[2]!.toLowerCase();
|
||||||
const factor = unit === 's' ? 1000 : unit === 'm' ? 60000 : unit === 'h' ? 3600000 : 86400000;
|
const factor = unit === 's' ? 1000 : unit === 'm' ? 60000 : unit === 'h' ? 3600000 : 86400000;
|
||||||
return amount * factor;
|
return amount * factor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse a duration and enforce Discord's 28-day timeout maximum. */
|
||||||
|
export function parseTimeoutDuration(input: string): number {
|
||||||
|
const ms = parseDuration(input);
|
||||||
|
if (ms <= 0) {
|
||||||
|
throw new DurationParseError('Duration must be positive');
|
||||||
|
}
|
||||||
|
if (ms > MAX_TIMEOUT_MS) {
|
||||||
|
throw new DurationParseError('Timeout duration cannot exceed 28 days');
|
||||||
|
}
|
||||||
|
return ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tryParseDuration(input: string): number | null {
|
||||||
|
try {
|
||||||
|
return parseDuration(input);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { moderationQueue } from '../../queues.js';
|
|||||||
import type { BotContext } from '../../types.js';
|
import type { BotContext } from '../../types.js';
|
||||||
import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js';
|
import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js';
|
||||||
import type { Prisma } from '@prisma/client';
|
import type { Prisma } from '@prisma/client';
|
||||||
import { buildCaseMetadata, type CaseAuditSource } from '@nexumi/shared';
|
import { buildCaseMetadata, type CaseAuditSource, t, type Locale } from '@nexumi/shared';
|
||||||
|
import { MAX_TIMEOUT_MS } from './duration.js';
|
||||||
|
|
||||||
type CreateCaseInput = {
|
type CreateCaseInput = {
|
||||||
guildId: string;
|
guildId: string;
|
||||||
@@ -22,18 +23,23 @@ export async function createCase(context: BotContext, input: CreateCaseInput) {
|
|||||||
create: { id: input.guildId }
|
create: { id: input.guildId }
|
||||||
});
|
});
|
||||||
|
|
||||||
const lastCase = await context.prisma.case.findFirst({
|
|
||||||
where: { guildId: input.guildId },
|
|
||||||
orderBy: { caseNumber: 'desc' }
|
|
||||||
});
|
|
||||||
|
|
||||||
const metadata = buildCaseMetadata({
|
const metadata = buildCaseMetadata({
|
||||||
source: input.source ?? 'slash_command',
|
source: input.source ?? 'slash_command',
|
||||||
channelId: input.channelId,
|
channelId: input.channelId,
|
||||||
extras: input.metadata
|
extras: input.metadata
|
||||||
});
|
});
|
||||||
|
|
||||||
return context.prisma.case.create({
|
let created = null;
|
||||||
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
|
try {
|
||||||
|
created = await context.prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
const lastCase = await tx.case.findFirst({
|
||||||
|
where: { guildId: input.guildId },
|
||||||
|
orderBy: { caseNumber: 'desc' },
|
||||||
|
select: { caseNumber: true }
|
||||||
|
});
|
||||||
|
return tx.case.create({
|
||||||
data: {
|
data: {
|
||||||
guildId: input.guildId,
|
guildId: input.guildId,
|
||||||
caseNumber: (lastCase?.caseNumber ?? 0) + 1,
|
caseNumber: (lastCase?.caseNumber ?? 0) + 1,
|
||||||
@@ -43,7 +49,29 @@ export async function createCase(context: BotContext, input: CreateCaseInput) {
|
|||||||
reason: input.reason,
|
reason: input.reason,
|
||||||
metadata: metadata as Prisma.InputJsonValue
|
metadata: metadata as Prisma.InputJsonValue
|
||||||
}
|
}
|
||||||
}).then(async (created) => {
|
});
|
||||||
|
},
|
||||||
|
{ isolationLevel: 'Serializable' }
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
const code =
|
||||||
|
typeof error === 'object' && error && 'code' in error
|
||||||
|
? String((error as { code: unknown }).code)
|
||||||
|
: '';
|
||||||
|
if (code !== 'P2002' && attempt === 4) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (attempt === 4) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!created) {
|
||||||
|
throw new Error('Failed to create case');
|
||||||
|
}
|
||||||
|
|
||||||
const { logModAction } = await import('../logging/events.js');
|
const { logModAction } = await import('../logging/events.js');
|
||||||
await logModAction(context, {
|
await logModAction(context, {
|
||||||
guildId: input.guildId,
|
guildId: input.guildId,
|
||||||
@@ -54,7 +82,10 @@ export async function createCase(context: BotContext, input: CreateCaseInput) {
|
|||||||
reason: input.reason
|
reason: input.reason
|
||||||
});
|
});
|
||||||
return created;
|
return created;
|
||||||
});
|
}
|
||||||
|
|
||||||
|
export function tempBanJobId(guildId: string, userId: string): string {
|
||||||
|
return `tempBan:${guildId}:${userId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function scheduleTempBanExpire(
|
export async function scheduleTempBanExpire(
|
||||||
@@ -63,10 +94,17 @@ export async function scheduleTempBanExpire(
|
|||||||
durationMs: number,
|
durationMs: number,
|
||||||
reason?: string
|
reason?: string
|
||||||
) {
|
) {
|
||||||
|
const jobId = tempBanJobId(guildId, userId);
|
||||||
|
const existing = await moderationQueue.getJob(jobId);
|
||||||
|
if (existing) {
|
||||||
|
await existing.remove();
|
||||||
|
}
|
||||||
|
|
||||||
await moderationQueue.add(
|
await moderationQueue.add(
|
||||||
'tempBanExpire',
|
'tempBanExpire',
|
||||||
{ guildId, userId, reason: reason ?? null },
|
{ guildId, userId, reason: reason ?? null },
|
||||||
{
|
{
|
||||||
|
jobId,
|
||||||
delay: durationMs,
|
delay: durationMs,
|
||||||
removeOnComplete: 1000,
|
removeOnComplete: 1000,
|
||||||
removeOnFail: 500
|
removeOnFail: 500
|
||||||
@@ -78,7 +116,8 @@ export async function applyWarnEscalation(
|
|||||||
interaction: ChatInputCommandInteraction,
|
interaction: ChatInputCommandInteraction,
|
||||||
context: BotContext,
|
context: BotContext,
|
||||||
userId: string,
|
userId: string,
|
||||||
moderatorId: string
|
moderatorId: string,
|
||||||
|
locale: Locale
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const guildId = interaction.guildId;
|
const guildId = interaction.guildId;
|
||||||
if (!guildId || !interaction.guild) {
|
if (!guildId || !interaction.guild) {
|
||||||
@@ -98,16 +137,25 @@ export async function applyWarnEscalation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const member = await interaction.guild.members.fetch(userId);
|
const member = await interaction.guild.members.fetch(userId);
|
||||||
const reason = rule.reason ?? `Auto escalation at ${warningCount} warnings`;
|
const reason =
|
||||||
|
rule.reason ??
|
||||||
|
t(locale, 'moderation.escalation.defaultReason').replace('{warnCount}', String(warningCount));
|
||||||
|
|
||||||
if (rule.action === 'TIMEOUT') {
|
if (rule.action === 'TIMEOUT') {
|
||||||
if (!rule.durationMs) {
|
if (!rule.durationMs) {
|
||||||
return `Escalation rule for ${warningCount} warns skipped (missing duration).`;
|
return t(locale, 'moderation.escalation.skippedDuration').replace(
|
||||||
|
'{warnCount}',
|
||||||
|
String(warningCount)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.ModerateMembers)) {
|
if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.ModerateMembers)) {
|
||||||
return `Escalation rule for ${warningCount} warns skipped (missing bot permissions).`;
|
return t(locale, 'moderation.escalation.skippedPermission').replace(
|
||||||
|
'{warnCount}',
|
||||||
|
String(warningCount)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await member.timeout(rule.durationMs, reason);
|
const durationMs = Math.min(rule.durationMs, MAX_TIMEOUT_MS);
|
||||||
|
await member.timeout(durationMs, reason);
|
||||||
await createCase(context, {
|
await createCase(context, {
|
||||||
guildId,
|
guildId,
|
||||||
targetUserId: userId,
|
targetUserId: userId,
|
||||||
@@ -116,14 +164,20 @@ export async function applyWarnEscalation(
|
|||||||
reason,
|
reason,
|
||||||
channelId: interaction.channelId ?? undefined,
|
channelId: interaction.channelId ?? undefined,
|
||||||
source: 'escalation',
|
source: 'escalation',
|
||||||
metadata: { escalation: true, durationMs: rule.durationMs, warningCount }
|
metadata: { escalation: true, durationMs, warningCount }
|
||||||
});
|
});
|
||||||
return `Auto escalation applied: timeout (${rule.durationMs} ms).`;
|
return t(locale, 'moderation.escalation.appliedTimeout').replace(
|
||||||
|
'{durationMs}',
|
||||||
|
String(durationMs)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rule.action === 'KICK') {
|
if (rule.action === 'KICK') {
|
||||||
if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.KickMembers)) {
|
if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.KickMembers)) {
|
||||||
return `Escalation rule for ${warningCount} warns skipped (missing bot permissions).`;
|
return t(locale, 'moderation.escalation.skippedPermission').replace(
|
||||||
|
'{warnCount}',
|
||||||
|
String(warningCount)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await member.kick(reason);
|
await member.kick(reason);
|
||||||
await createCase(context, {
|
await createCase(context, {
|
||||||
@@ -136,11 +190,14 @@ export async function applyWarnEscalation(
|
|||||||
source: 'escalation',
|
source: 'escalation',
|
||||||
metadata: { escalation: true, warningCount }
|
metadata: { escalation: true, warningCount }
|
||||||
});
|
});
|
||||||
return 'Auto escalation applied: kick.';
|
return t(locale, 'moderation.escalation.appliedKick');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.BanMembers)) {
|
if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.BanMembers)) {
|
||||||
return `Escalation rule for ${warningCount} warns skipped (missing bot permissions).`;
|
return t(locale, 'moderation.escalation.skippedPermission').replace(
|
||||||
|
'{warnCount}',
|
||||||
|
String(warningCount)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await interaction.guild.members.ban(userId, { reason });
|
await interaction.guild.members.ban(userId, { reason });
|
||||||
await createCase(context, {
|
await createCase(context, {
|
||||||
@@ -153,5 +210,5 @@ export async function applyWarnEscalation(
|
|||||||
source: 'escalation',
|
source: 'escalation',
|
||||||
metadata: { escalation: true, warningCount }
|
metadata: { escalation: true, warningCount }
|
||||||
});
|
});
|
||||||
return 'Auto escalation applied: ban.';
|
return t(locale, 'moderation.escalation.appliedBan');
|
||||||
}
|
}
|
||||||
|
|||||||
36
apps/bot/src/sentry.ts
Normal file
36
apps/bot/src/sentry.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import * as Sentry from '@sentry/node';
|
||||||
|
import { env } from './env.js';
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
export function initSentry(service: 'bot-manager' | 'bot-shard' | 'webui'): void {
|
||||||
|
if (initialized || !env.SENTRY_DSN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Sentry.init({
|
||||||
|
dsn: env.SENTRY_DSN,
|
||||||
|
environment: env.NODE_ENV,
|
||||||
|
release: process.env.npm_package_version
|
||||||
|
? `nexumi@${process.env.npm_package_version}`
|
||||||
|
: 'nexumi@0.1.0',
|
||||||
|
tracesSampleRate: env.NODE_ENV === 'production' ? 0.1 : 1.0,
|
||||||
|
initialScope: {
|
||||||
|
tags: { service }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
initialized = true;
|
||||||
|
logger.info({ service }, 'Sentry initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function captureException(error: unknown, context?: Record<string, unknown>): void {
|
||||||
|
if (!env.SENTRY_DSN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Sentry.withScope((scope) => {
|
||||||
|
if (context) {
|
||||||
|
scope.setExtras(context);
|
||||||
|
}
|
||||||
|
Sentry.captureException(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
37
apps/bot/src/shard-lifecycle.ts
Normal file
37
apps/bot/src/shard-lifecycle.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Client } from 'discord.js';
|
||||||
|
import { redis } from './redis.js';
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when this process owns shard 0 (or has no shard manager = single process).
|
||||||
|
*/
|
||||||
|
export function isPrimaryShard(client: Client): boolean {
|
||||||
|
const ids = client.shard?.ids;
|
||||||
|
if (!ids || ids.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return ids.includes(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acquires a short-lived Redis lock. Returns true if this caller owns the lock.
|
||||||
|
*/
|
||||||
|
export async function acquireOnceLock(lockKey: string, ttlSeconds: number): Promise<boolean> {
|
||||||
|
const result = await redis.set(lockKey, String(Date.now()), 'EX', ttlSeconds, 'NX');
|
||||||
|
return result === 'OK';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runOncePerCluster(
|
||||||
|
lockKey: string,
|
||||||
|
ttlSeconds: number,
|
||||||
|
fn: () => Promise<void>,
|
||||||
|
label: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const acquired = await acquireOnceLock(lockKey, ttlSeconds);
|
||||||
|
if (!acquired) {
|
||||||
|
logger.info({ lockKey }, `${label} skipped (another process holds the lock)`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await fn();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
"@radix-ui/react-separator": "^1.1.12",
|
"@radix-ui/react-separator": "^1.1.12",
|
||||||
"@radix-ui/react-slot": "^1.3.0",
|
"@radix-ui/react-slot": "^1.3.0",
|
||||||
"@radix-ui/react-switch": "^1.3.4",
|
"@radix-ui/react-switch": "^1.3.4",
|
||||||
|
"@sentry/node": "^10.67.0",
|
||||||
"bullmq": "^5.34.0",
|
"bullmq": "^5.34.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
6
apps/webui/src/instrumentation.ts
Normal file
6
apps/webui/src/instrumentation.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export async function register(): Promise<void> {
|
||||||
|
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||||
|
const { initWebuiSentry } = await import('./lib/sentry');
|
||||||
|
initWebuiSentry();
|
||||||
|
}
|
||||||
|
}
|
||||||
32
apps/webui/src/lib/sentry.ts
Normal file
32
apps/webui/src/lib/sentry.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import * as Sentry from '@sentry/node';
|
||||||
|
import { env } from './env';
|
||||||
|
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
export function initWebuiSentry(): void {
|
||||||
|
if (initialized || !env.SENTRY_DSN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Sentry.init({
|
||||||
|
dsn: env.SENTRY_DSN,
|
||||||
|
environment: env.NODE_ENV,
|
||||||
|
release: 'nexumi-webui@0.1.0',
|
||||||
|
tracesSampleRate: env.NODE_ENV === 'production' ? 0.1 : 1.0,
|
||||||
|
initialScope: {
|
||||||
|
tags: { service: 'webui' }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function captureWebuiException(error: unknown, context?: Record<string, unknown>): void {
|
||||||
|
if (!env.SENTRY_DSN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Sentry.withScope((scope) => {
|
||||||
|
if (context) {
|
||||||
|
scope.setExtras(context);
|
||||||
|
}
|
||||||
|
Sentry.captureException(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -195,6 +195,31 @@
|
|||||||
- Modal-only Components, File-Component, Premium-Buttons (nicht im MVP)
|
- Modal-only Components, File-Component, Premium-Buttons (nicht im MVP)
|
||||||
- Pixelgenaue Discord-Vorschau im Dashboard
|
- Pixelgenaue Discord-Vorschau im Dashboard
|
||||||
|
|
||||||
|
## Post-Phase – Harden (Sharding / Gates / Observability) (Status: implementiert)
|
||||||
|
|
||||||
|
### Abgeschlossen (Code)
|
||||||
|
|
||||||
|
- Confirmations (Moderation + Guildbackup) in Redis mit TTL statt Prozess-`Map`
|
||||||
|
- Command-Registrierung einmal im Manager (Redis-Lock); Recurring Jobs einmal (Primary-Shard + Lock)
|
||||||
|
- Modul-Toggles + FeatureFlags + User-/Guild-Blacklists im Bot erzwungen (`routeCommand`, Events, GuildCreate leave)
|
||||||
|
- Moderation: Hierarchy-Checks, Duration vor Ban-Confirm, Timeout-Cap 28d, warn remove guild-scoped, atomare Case-Nummern, Temp-Ban Job-Dedup + UNBAN-Case, `setDefaultMemberPermissions`
|
||||||
|
- Sentry (`@sentry/node`) in Bot + WebUI (`instrumentation.ts`); echte Prometheus-Metrics (`/metrics`)
|
||||||
|
- Interaction-Registry statt if-Kette; Unit-Tests für Duration, Confirm-Store, Module-Gates, Metrics
|
||||||
|
|
||||||
|
### Manuell testen
|
||||||
|
|
||||||
|
- [ ] Ban mit Dauer: Confirm auf anderem Shard / nach Restart innerhalb TTL
|
||||||
|
- [ ] Dashboard: Modul Moderation aus → `/ban` antwortet „Modul deaktiviert“
|
||||||
|
- [ ] Owner: User-Blacklist → Interactions blockiert; Guild-Blacklist → Bot verlässt Server
|
||||||
|
- [ ] `/metrics` mit Bearer-Token: Counters + Shard-Ping
|
||||||
|
- [ ] Temp-Ban ablaufen → Unban + Case `UNBAN`
|
||||||
|
|
||||||
|
### Bewusst offen
|
||||||
|
|
||||||
|
- Musik- und KI-Modul (nur auf Zuruf)
|
||||||
|
- Premium-Zahlungsanbindung
|
||||||
|
- P4-UX (Server-Lock, Ban deleteMessageSeconds) bewusst nachgelagert
|
||||||
|
|
||||||
## Nächster geplanter Schritt
|
## Nächster geplanter Schritt
|
||||||
|
|
||||||
- Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy` → `main` mergen (nur auf Freigabe).
|
- Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy` → `main` mergen (nur auf Freigabe).
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ const de: Dictionary = {
|
|||||||
'generic.commandChannelBlocked': 'Dieser Befehl ist in diesem Kanal nicht erlaubt.',
|
'generic.commandChannelBlocked': 'Dieser Befehl ist in diesem Kanal nicht erlaubt.',
|
||||||
'generic.commandRoleBlocked': 'Du darfst diesen Befehl mit deinen Rollen nicht nutzen.',
|
'generic.commandRoleBlocked': 'Du darfst diesen Befehl mit deinen Rollen nicht nutzen.',
|
||||||
'generic.commandCooldown': 'Bitte warte noch {seconds} Sekunden, bevor du diesen Befehl erneut nutzt.',
|
'generic.commandCooldown': 'Bitte warte noch {seconds} Sekunden, bevor du diesen Befehl erneut nutzt.',
|
||||||
|
'generic.moduleDisabled': 'Dieses Modul ist auf diesem Server deaktiviert.',
|
||||||
|
'generic.userBlacklisted': 'Du darfst Nexumi derzeit nicht nutzen.',
|
||||||
|
'generic.guildBlacklisted': 'Dieser Server darf Nexumi derzeit nicht nutzen.',
|
||||||
'core.help.title': 'Nexumi Hilfe',
|
'core.help.title': 'Nexumi Hilfe',
|
||||||
'core.help.subtitle': 'Commands nach Modul. Optional: `/help module:<name>`.',
|
'core.help.subtitle': 'Commands nach Modul. Optional: `/help module:<name>`.',
|
||||||
'core.help.moduleLine': '**{module}**: {commands}',
|
'core.help.moduleLine': '**{module}**: {commands}',
|
||||||
@@ -111,10 +114,23 @@ const de: Dictionary = {
|
|||||||
'gdpr.delete.doneBody':
|
'gdpr.delete.doneBody':
|
||||||
'Gelöscht/anonymisiert: {warnings} Verwarnungen, {levels} Level-Einträge, {economy} Economy-Einträge, {cases} Cases anonymisiert.',
|
'Gelöscht/anonymisiert: {warnings} Verwarnungen, {levels} Level-Einträge, {economy} Economy-Einträge, {cases} Cases anonymisiert.',
|
||||||
'moderation.success': 'Aktion erfolgreich ausgeführt.',
|
'moderation.success': 'Aktion erfolgreich ausgeführt.',
|
||||||
|
'moderation.success.case': 'Aktion erfolgreich ausgeführt (Fall #{caseNumber}).',
|
||||||
|
'moderation.duration.invalid': 'Ungültige Dauer. Nutze z. B. 30m, 12h oder 7d.',
|
||||||
|
'moderation.duration.invalidTimeout':
|
||||||
|
'Ungültige Timeout-Dauer. Nutze z. B. 30m oder 1d (max. 28 Tage).',
|
||||||
|
'moderation.hierarchy.self': 'Du kannst diese Aktion nicht auf dich selbst anwenden.',
|
||||||
|
'moderation.hierarchy.owner': 'Der Server-Owner kann nicht moderiert werden.',
|
||||||
|
'moderation.hierarchy.moderator':
|
||||||
|
'Du kannst keine Mitglieder moderieren, die gleich oder höher in der Rollenhierarchie stehen.',
|
||||||
|
'moderation.hierarchy.bot':
|
||||||
|
'Der Bot kann dieses Mitglied wegen der Rollenhierarchie oder fehlender Rechte nicht moderieren.',
|
||||||
|
'moderation.hierarchy.notMember': 'Dieses Mitglied ist nicht auf dem Server.',
|
||||||
'moderation.reason.none': 'Kein Grund angegeben',
|
'moderation.reason.none': 'Kein Grund angegeben',
|
||||||
'moderation.warning.created': 'Verwarnung erstellt: {warningId}',
|
'moderation.warning.created': 'Verwarnung erstellt: {warningId}',
|
||||||
'moderation.warning.none': 'Keine Verwarnungen vorhanden.',
|
'moderation.warning.none': 'Keine Verwarnungen vorhanden.',
|
||||||
'moderation.warning.defaultReason': 'Kein Grund',
|
'moderation.warning.defaultReason': 'Kein Grund',
|
||||||
|
'moderation.warning.notFound': 'Verwarnung nicht gefunden.',
|
||||||
|
'moderation.purge.done': '{deletedCount} Nachrichten gelöscht (Fall #{caseNumber}).',
|
||||||
'moderation.note.none': 'Keine Notizen vorhanden.',
|
'moderation.note.none': 'Keine Notizen vorhanden.',
|
||||||
'moderation.case.notFound': 'Fall nicht gefunden.',
|
'moderation.case.notFound': 'Fall nicht gefunden.',
|
||||||
'moderation.escalation.durationRequired':
|
'moderation.escalation.durationRequired':
|
||||||
@@ -124,6 +140,14 @@ const de: Dictionary = {
|
|||||||
'Eskalationsregel gespeichert: {warnCount} Verwarnungen => {action}{duration}.',
|
'Eskalationsregel gespeichert: {warnCount} Verwarnungen => {action}{duration}.',
|
||||||
'moderation.escalation.removed':
|
'moderation.escalation.removed':
|
||||||
'Eskalationsregel für {warnCount} Verwarnungen entfernt.',
|
'Eskalationsregel für {warnCount} Verwarnungen entfernt.',
|
||||||
|
'moderation.escalation.defaultReason': 'Auto-Eskalation bei {warnCount} Verwarnungen',
|
||||||
|
'moderation.escalation.skippedDuration':
|
||||||
|
'Eskalationsregel für {warnCount} Verwarnungen übersprungen (fehlende Dauer).',
|
||||||
|
'moderation.escalation.skippedPermission':
|
||||||
|
'Eskalationsregel für {warnCount} Verwarnungen übersprungen (fehlende Bot-Rechte).',
|
||||||
|
'moderation.escalation.appliedTimeout': 'Auto-Eskalation angewendet: Timeout ({durationMs} ms).',
|
||||||
|
'moderation.escalation.appliedKick': 'Auto-Eskalation angewendet: Kick.',
|
||||||
|
'moderation.escalation.appliedBan': 'Auto-Eskalation angewendet: Ban.',
|
||||||
'moderation.confirm.button.confirm': 'Bestätigen',
|
'moderation.confirm.button.confirm': 'Bestätigen',
|
||||||
'moderation.confirm.button.cancel': 'Abbrechen',
|
'moderation.confirm.button.cancel': 'Abbrechen',
|
||||||
'moderation.confirm.permanent': 'dauerhaft',
|
'moderation.confirm.permanent': 'dauerhaft',
|
||||||
@@ -612,6 +636,9 @@ const en: Dictionary = {
|
|||||||
'generic.commandChannelBlocked': 'This command is not allowed in this channel.',
|
'generic.commandChannelBlocked': 'This command is not allowed in this channel.',
|
||||||
'generic.commandRoleBlocked': 'You are not allowed to use this command with your roles.',
|
'generic.commandRoleBlocked': 'You are not allowed to use this command with your roles.',
|
||||||
'generic.commandCooldown': 'Please wait {seconds} more seconds before using this command again.',
|
'generic.commandCooldown': 'Please wait {seconds} more seconds before using this command again.',
|
||||||
|
'generic.moduleDisabled': 'This module is disabled on this server.',
|
||||||
|
'generic.userBlacklisted': 'You are not allowed to use Nexumi right now.',
|
||||||
|
'generic.guildBlacklisted': 'This server is not allowed to use Nexumi right now.',
|
||||||
'core.help.title': 'Nexumi Help',
|
'core.help.title': 'Nexumi Help',
|
||||||
'core.help.subtitle': 'Commands by module. Optional: `/help module:<name>`.',
|
'core.help.subtitle': 'Commands by module. Optional: `/help module:<name>`.',
|
||||||
'core.help.moduleLine': '**{module}**: {commands}',
|
'core.help.moduleLine': '**{module}**: {commands}',
|
||||||
@@ -664,10 +691,23 @@ const en: Dictionary = {
|
|||||||
'gdpr.delete.doneBody':
|
'gdpr.delete.doneBody':
|
||||||
'Deleted/anonymized: {warnings} warnings, {levels} level rows, {economy} economy rows, {cases} cases anonymized.',
|
'Deleted/anonymized: {warnings} warnings, {levels} level rows, {economy} economy rows, {cases} cases anonymized.',
|
||||||
'moderation.success': 'Action executed successfully.',
|
'moderation.success': 'Action executed successfully.',
|
||||||
|
'moderation.success.case': 'Action executed successfully (case #{caseNumber}).',
|
||||||
|
'moderation.duration.invalid': 'Invalid duration. Use e.g. 30m, 12h, or 7d.',
|
||||||
|
'moderation.duration.invalidTimeout':
|
||||||
|
'Invalid timeout duration. Use e.g. 30m or 1d (max 28 days).',
|
||||||
|
'moderation.hierarchy.self': 'You cannot apply this action to yourself.',
|
||||||
|
'moderation.hierarchy.owner': 'The server owner cannot be moderated.',
|
||||||
|
'moderation.hierarchy.moderator':
|
||||||
|
'You cannot moderate members with equal or higher roles.',
|
||||||
|
'moderation.hierarchy.bot':
|
||||||
|
'The bot cannot moderate this member due to role hierarchy or missing permissions.',
|
||||||
|
'moderation.hierarchy.notMember': 'That member is not on this server.',
|
||||||
'moderation.reason.none': 'No reason provided',
|
'moderation.reason.none': 'No reason provided',
|
||||||
'moderation.warning.created': 'Warning created: {warningId}',
|
'moderation.warning.created': 'Warning created: {warningId}',
|
||||||
'moderation.warning.none': 'No warnings.',
|
'moderation.warning.none': 'No warnings.',
|
||||||
'moderation.warning.defaultReason': 'No reason',
|
'moderation.warning.defaultReason': 'No reason',
|
||||||
|
'moderation.warning.notFound': 'Warning not found.',
|
||||||
|
'moderation.purge.done': 'Deleted {deletedCount} messages (case #{caseNumber}).',
|
||||||
'moderation.note.none': 'No notes.',
|
'moderation.note.none': 'No notes.',
|
||||||
'moderation.case.notFound': 'Case not found.',
|
'moderation.case.notFound': 'Case not found.',
|
||||||
'moderation.escalation.durationRequired': 'Duration is required for TIMEOUT escalation.',
|
'moderation.escalation.durationRequired': 'Duration is required for TIMEOUT escalation.',
|
||||||
@@ -675,6 +715,14 @@ const en: Dictionary = {
|
|||||||
'moderation.escalation.saved':
|
'moderation.escalation.saved':
|
||||||
'Escalation rule saved: {warnCount} warnings => {action}{duration}.',
|
'Escalation rule saved: {warnCount} warnings => {action}{duration}.',
|
||||||
'moderation.escalation.removed': 'Escalation rule removed for {warnCount} warnings.',
|
'moderation.escalation.removed': 'Escalation rule removed for {warnCount} warnings.',
|
||||||
|
'moderation.escalation.defaultReason': 'Auto escalation at {warnCount} warnings',
|
||||||
|
'moderation.escalation.skippedDuration':
|
||||||
|
'Escalation rule for {warnCount} warns skipped (missing duration).',
|
||||||
|
'moderation.escalation.skippedPermission':
|
||||||
|
'Escalation rule for {warnCount} warns skipped (missing bot permissions).',
|
||||||
|
'moderation.escalation.appliedTimeout': 'Auto escalation applied: timeout ({durationMs} ms).',
|
||||||
|
'moderation.escalation.appliedKick': 'Auto escalation applied: kick.',
|
||||||
|
'moderation.escalation.appliedBan': 'Auto escalation applied: ban.',
|
||||||
'moderation.confirm.button.confirm': 'Confirm',
|
'moderation.confirm.button.confirm': 'Confirm',
|
||||||
'moderation.confirm.button.cancel': 'Cancel',
|
'moderation.confirm.button.cancel': 'Cancel',
|
||||||
'moderation.confirm.permanent': 'permanent',
|
'moderation.confirm.permanent': 'permanent',
|
||||||
|
|||||||
Reference in New Issue
Block a user