Merge pull request 'deploy' (#1) from deploy into main

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-07-24 08:41:09 +00:00
197 changed files with 17959 additions and 2360 deletions

View File

@@ -1,9 +1,13 @@
NODE_ENV=development
NODE_ENV=production
# Shared by apps/bot (gateway connection) and apps/webui (narrow REST calls +
# BullMQ job enqueueing for guild backups, giveaways and the scheduler).
BOT_TOKEN=
BOT_CLIENT_ID=
BOT_CLIENT_SECRET=
# Optional: register slash commands only on this guild (instant sync for dev).
# Leave empty in production so commands are global. When set, global commands
# are cleared; when empty, leftover guild-scoped commands are cleared on startup
# so Discord does not show every command twice.
BOT_GUILD_ID=
DATABASE_URL=postgresql://nexumi:nexumi@postgres:5432/nexumi
REDIS_URL=redis://redis:6379
@@ -15,12 +19,15 @@ BACKUP_RETENTION_DAYS=14
BACKUP_CRON=0 3 * * *
BACKUP_DIR=/backups
HEALTH_PORT=8080
# Internal bot health/metrics base (Docker healthcheck). Captcha links use WEBUI_URL.
PUBLIC_BASE_URL=http://localhost:8080
TWITCH_CLIENT_ID=
TWITCH_CLIENT_SECRET=
# WebUI (apps/webui)
WEBUI_URL=http://10.111.0.65:3000
# WebUI (apps/webui) public URL behind Traefik (also used for captcha verification links)
WEBUI_URL=https://dashboard.nexumi.de
# Discord Developer Portal OAuth2 redirect:
# https://dashboard.nexumi.de/api/auth/callback
# Must be at least 32 characters long. Generate e.g. with `openssl rand -hex 32`.
SESSION_SECRET=
# Comma-separated Discord user IDs treated as global bot owners (owner panel, Phase 7 Batch C).
@@ -28,7 +35,28 @@ OWNER_USER_IDS=
# Public site / bot links (Phase 8)
SUPPORT_SERVER_URL=
LEGAL_OPERATOR_NAME=
LEGAL_OPERATOR_ADDRESS=
LEGAL_OPERATOR_EMAIL=
LEGAL_OPERATOR_PHONE=
LEGAL_OPERATOR_NAME=HexaHost Inh. Samuel Müller
LEGAL_OPERATOR_ADDRESS=Richard-Miller-Straße 1, 94051 Hauzenberg, Deutschland
LEGAL_OPERATOR_EMAIL=info@hexahost.de
LEGAL_OPERATOR_PHONE=+49 15566 175855
# ---------------------------------------------------------------------------
# Verification captcha providers (WebUI). Leave empty to disable a provider.
# MATH always works without keys. Per-guild provider is chosen in the dashboard.
# ---------------------------------------------------------------------------
# Google reCAPTCHA v2 (checkbox) https://www.google.com/recaptcha/admin
RECAPTCHA_SITE_KEY=
RECAPTCHA_SECRET_KEY=
# Google reCAPTCHA v3 (score-based). Falls back to RECAPTCHA_* if unset.
RECAPTCHA_V3_SITE_KEY=
RECAPTCHA_V3_SECRET_KEY=
RECAPTCHA_V3_MIN_SCORE=0.5
# hCaptcha https://dashboard.hcaptcha.com
HCAPTCHA_SITE_KEY=
HCAPTCHA_SECRET_KEY=
# Cloudflare Turnstile https://dash.cloudflare.com → Turnstile
TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY=
# Salt for hashing client IPs used in alt-account detection (min 16 chars).
# Generate e.g. with: openssl rand -hex 16
CAPTCHA_IP_HASH_SALT=

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"css.lint.unknownAtRules": "ignore"
}

View File

@@ -1,6 +1,6 @@
# Nexumi
Nexumi is a self-hosted public Discord bot with dashboard architecture, built as a TypeScript monorepo.
Nexumi is a public Discord bot with dashboard architecture, built as a TypeScript monorepo.
## Phase 1 scope

View File

@@ -0,0 +1,36 @@
-- AlterTable
ALTER TABLE "WelcomeConfig" ADD COLUMN IF NOT EXISTS "welcomeComponents" JSONB;
ALTER TABLE "WelcomeConfig" ADD COLUMN IF NOT EXISTS "leaveType" TEXT NOT NULL DEFAULT 'TEXT';
ALTER TABLE "WelcomeConfig" ADD COLUMN IF NOT EXISTS "leaveComponents" JSONB;
-- AlterTable
ALTER TABLE "Tag" ADD COLUMN IF NOT EXISTS "components" JSONB;
-- AlterTable
ALTER TABLE "ScheduledMessage" ADD COLUMN IF NOT EXISTS "components" JSONB;
-- CreateTable
CREATE TABLE IF NOT EXISTS "ComponentMessageBinding" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"messageId" TEXT,
"payload" JSONB NOT NULL,
"createdById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ComponentMessageBinding_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX IF NOT EXISTS "ComponentMessageBinding_guildId_createdAt_idx" ON "ComponentMessageBinding"("guildId", "createdAt");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "ComponentMessageBinding_messageId_idx" ON "ComponentMessageBinding"("messageId");
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "ComponentMessageBinding" ADD CONSTRAINT "ComponentMessageBinding_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@@ -9,13 +9,15 @@
"lint": "eslint src --ext .ts",
"test": "vitest run",
"typecheck": "tsc --noEmit -p tsconfig.json",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate deploy"
"prisma:generate": "prisma generate --schema=prisma/schema.prisma",
"prisma:migrate": "prisma migrate deploy --schema=prisma/schema.prisma",
"profile:banner": "tsx scripts/set-bot-banner.ts"
},
"dependencies": {
"@nexumi/shared": "workspace:*",
"@napi-rs/canvas": "^0.1.65",
"@nexumi/shared": "workspace:*",
"@prisma/client": "^5.22.0",
"@sentry/node": "^10.67.0",
"bullmq": "^5.34.0",
"discord.js": "^14.17.3",
"dotenv": "^16.4.7",

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "GuildSettings" ADD COLUMN "commandAllowedChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[];
ALTER TABLE "GuildSettings" ADD COLUMN "commandDeniedChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[];

View File

@@ -0,0 +1,36 @@
-- AlterTable
ALTER TABLE "WelcomeConfig" ADD COLUMN IF NOT EXISTS "welcomeComponents" JSONB;
ALTER TABLE "WelcomeConfig" ADD COLUMN IF NOT EXISTS "leaveType" TEXT NOT NULL DEFAULT 'TEXT';
ALTER TABLE "WelcomeConfig" ADD COLUMN IF NOT EXISTS "leaveComponents" JSONB;
-- AlterTable
ALTER TABLE "Tag" ADD COLUMN IF NOT EXISTS "components" JSONB;
-- AlterTable
ALTER TABLE "ScheduledMessage" ADD COLUMN IF NOT EXISTS "components" JSONB;
-- CreateTable
CREATE TABLE IF NOT EXISTS "ComponentMessageBinding" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"messageId" TEXT,
"payload" JSONB NOT NULL,
"createdById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ComponentMessageBinding_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX IF NOT EXISTS "ComponentMessageBinding_guildId_createdAt_idx" ON "ComponentMessageBinding"("guildId", "createdAt");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "ComponentMessageBinding_messageId_idx" ON "ComponentMessageBinding"("messageId");
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "ComponentMessageBinding" ADD CONSTRAINT "ComponentMessageBinding_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@@ -0,0 +1,37 @@
-- AlterTable
ALTER TABLE "VerificationConfig" ADD COLUMN IF NOT EXISTS "captchaProvider" TEXT NOT NULL DEFAULT 'MATH';
ALTER TABLE "VerificationConfig" ADD COLUMN IF NOT EXISTS "altDetectionEnabled" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "VerificationConfig" ADD COLUMN IF NOT EXISTS "altBlockOnMatch" BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE "VerificationConfig" ADD COLUMN IF NOT EXISTS "altIpWindowDays" INTEGER NOT NULL DEFAULT 30;
ALTER TABLE "VerificationConfig" ADD COLUMN IF NOT EXISTS "altInviteWindowHours" INTEGER NOT NULL DEFAULT 48;
ALTER TABLE "VerificationConfig" ADD COLUMN IF NOT EXISTS "altMaxAccountsPerInvite" INTEGER NOT NULL DEFAULT 3;
-- CreateTable
CREATE TABLE IF NOT EXISTS "VerificationRecord" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"inviteCode" TEXT,
"inviterId" TEXT,
"ipHash" TEXT,
"userAgentHash" TEXT,
"captchaProvider" TEXT,
"flaggedAsAlt" BOOLEAN NOT NULL DEFAULT false,
"matchedUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "VerificationRecord_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "VerificationRecord_guildId_userId_key" ON "VerificationRecord"("guildId", "userId");
CREATE INDEX IF NOT EXISTS "VerificationRecord_guildId_ipHash_createdAt_idx" ON "VerificationRecord"("guildId", "ipHash", "createdAt");
CREATE INDEX IF NOT EXISTS "VerificationRecord_guildId_inviteCode_createdAt_idx" ON "VerificationRecord"("guildId", "inviteCode", "createdAt");
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "VerificationRecord" ADD CONSTRAINT "VerificationRecord_guildId_fkey"
FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;

View File

@@ -20,6 +20,7 @@ model Guild {
logChannels LogChannel[]
welcomeConfig WelcomeConfig?
verificationConfig VerificationConfig?
verificationRecords VerificationRecord[]
levelingConfig LevelingConfig?
memberLevels MemberLevel[]
levelRewards LevelReward[]
@@ -51,6 +52,7 @@ model Guild {
socialFeeds SocialFeed[]
scheduledMessages ScheduledMessage[]
guildBackups GuildBackup[]
componentMessageBindings ComponentMessageBinding[]
dashboardAccessRules DashboardAccessRule[]
dashboardAuditLogs DashboardAuditLog[]
commandOverrides CommandOverride[]
@@ -64,6 +66,10 @@ model GuildSettings {
moderationEnabled Boolean @default(true)
/// Snipe/editsnipe storage and commands (SPEC: default off for privacy).
snipeEnabled Boolean @default(false)
/// Global command channel whitelist (empty = all channels unless denied).
commandAllowedChannelIds String[] @default([])
/// Global command channel blacklist.
commandDeniedChannelIds String[] @default([])
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
}
@@ -241,8 +247,11 @@ model WelcomeConfig {
welcomeType String @default("TEXT")
welcomeContent String?
welcomeEmbed Json?
welcomeComponents Json?
leaveType String @default("TEXT")
leaveContent String?
leaveEmbed Json?
leaveComponents Json?
welcomeDmEnabled Boolean @default(false)
welcomeDmContent String?
userAutoroleIds String[] @default([])
@@ -255,20 +264,47 @@ model WelcomeConfig {
}
model VerificationConfig {
id String @id @default(cuid())
guildId String @unique
enabled Boolean @default(false)
channelId String?
verifiedRoleId String?
unverifiedRoleId String?
mode String @default("BUTTON")
minAccountAgeDays Int @default(0)
failAction String @default("KICK")
panelChannelId String?
panelMessageId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
id String @id @default(cuid())
guildId String @unique
enabled Boolean @default(false)
channelId String?
verifiedRoleId String?
unverifiedRoleId String?
mode String @default("BUTTON")
/// MATH | RECAPTCHA_V2 | RECAPTCHA_V3 | HCAPTCHA | TURNSTILE
captchaProvider String @default("MATH")
minAccountAgeDays Int @default(0)
failAction String @default("KICK")
altDetectionEnabled Boolean @default(false)
altBlockOnMatch Boolean @default(true)
altIpWindowDays Int @default(30)
altInviteWindowHours Int @default(48)
altMaxAccountsPerInvite Int @default(3)
panelChannelId String?
panelMessageId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
}
/// One row per successful (or blocked-as-alt) verification attempt fingerprint.
model VerificationRecord {
id String @id @default(cuid())
guildId String
userId String
inviteCode String?
inviterId String?
ipHash String?
userAgentHash String?
captchaProvider String?
flaggedAsAlt Boolean @default(false)
matchedUserId String?
createdAt DateTime @default(now())
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@unique([guildId, userId])
@@index([guildId, ipHash, createdAt])
@@index([guildId, inviteCode, createdAt])
}
model LevelingConfig {
@@ -551,6 +587,7 @@ model Tag {
name String
content String?
embed Json?
components Json?
responseType String @default("TEXT")
triggerWord String?
allowedRoleIds String[] @default([])
@@ -770,6 +807,7 @@ model ScheduledMessage {
creatorId String
content String?
embed Json?
components Json?
rolePingId String?
cron String?
runAt DateTime?
@@ -782,6 +820,20 @@ model ScheduledMessage {
@@index([guildId, enabled])
}
model ComponentMessageBinding {
id String @id @default(cuid())
guildId String
channelId String
messageId String?
payload Json
createdById String
createdAt DateTime @default(now())
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@index([guildId, createdAt])
@@index([messageId])
}
model GuildBackup {
id String @id @default(cuid())
guildId String
@@ -854,7 +906,7 @@ model FeatureFlag {
model BotPresenceConfig {
id String @id @default("singleton")
status String @default("online")
activityType String @default("Playing")
activityType String @default("Watching")
activityText String @default("nexumi.de")
rotatingMessages Json?
maintenanceMode Boolean @default(false)

View File

@@ -0,0 +1,60 @@
/**
* Uploads docs/logo/nexumi-banner.png as the bot user banner (and optional bio)
* via Discord API.
*
* Usage (from repo root):
* pnpm --filter @nexumi/bot profile:banner
*
* Requires BOT_TOKEN in the environment (or root/.env / apps/bot/.env).
*/
import { readFileSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { config as loadEnv } from 'dotenv';
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, '../../..');
for (const candidate of [resolve(repoRoot, '.env'), resolve(here, '../.env')]) {
if (existsSync(candidate)) {
loadEnv({ path: candidate });
}
}
const token = process.env.BOT_TOKEN;
if (!token) {
console.error('BOT_TOKEN is missing.');
process.exit(1);
}
const bannerPath = resolve(repoRoot, 'docs/logo/nexumi-banner.png');
if (!existsSync(bannerPath)) {
console.error(`Banner not found: ${bannerPath}`);
process.exit(1);
}
const png = readFileSync(bannerPath);
const bannerDataUri = `data:image/png;base64,${png.toString('base64')}`;
const bio =
'Moderation, Community & Integrationen — in einem Bot.\n' +
'Dashboard: https://dashboard.nexumi.de\n' +
'nexumi.de';
const response = await fetch('https://discord.com/api/v10/users/@me', {
method: 'PATCH',
headers: {
Authorization: `Bot ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ banner: bannerDataUri, bio })
});
const body = await response.text();
if (!response.ok) {
console.error(`Failed (${response.status}): ${body}`);
process.exit(1);
}
console.log('Bot profile updated successfully.');
console.log(body);

View File

@@ -0,0 +1,38 @@
export type CommandGateReason =
| 'disabled'
| 'channel_denied'
| 'channel_not_allowed'
| 'role_denied'
| 'role_not_allowed'
| 'cooldown';
export function isChannelAllowed(
channelId: string | null,
allowed: string[],
denied: string[]
): CommandGateReason | null {
if (!channelId) {
return null;
}
if (denied.includes(channelId)) {
return 'channel_denied';
}
if (allowed.length > 0 && !allowed.includes(channelId)) {
return 'channel_not_allowed';
}
return null;
}
export function isRoleAllowed(
roleIds: string[],
allowed: string[],
denied: string[]
): CommandGateReason | null {
if (denied.some((id) => roleIds.includes(id))) {
return 'role_denied';
}
if (allowed.length > 0 && !allowed.some((id) => roleIds.includes(id))) {
return 'role_not_allowed';
}
return null;
}

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { isChannelAllowed, isRoleAllowed } from './command-gate-rules.js';
describe('command-gates channel rules', () => {
it('allows any channel when allow and deny are empty', () => {
expect(isChannelAllowed('1', [], [])).toBeNull();
});
it('blocks denied channels even when allow is empty', () => {
expect(isChannelAllowed('1', [], ['1'])).toBe('channel_denied');
});
it('requires membership when allow list is set', () => {
expect(isChannelAllowed('1', ['2'], [])).toBe('channel_not_allowed');
expect(isChannelAllowed('2', ['2'], [])).toBeNull();
});
it('prefers deny over allow', () => {
expect(isChannelAllowed('1', ['1'], ['1'])).toBe('channel_denied');
});
});
describe('command-gates role rules', () => {
it('blocks when any denied role is present', () => {
expect(isRoleAllowed(['a', 'b'], [], ['b'])).toBe('role_denied');
});
it('requires an allowed role when allow list is set', () => {
expect(isRoleAllowed(['a'], ['b'], [])).toBe('role_not_allowed');
expect(isRoleAllowed(['a', 'b'], ['b'], [])).toBeNull();
});
});

View File

@@ -0,0 +1,92 @@
import type { ChatInputCommandInteraction, GuildMember } from 'discord.js';
import type { BotContext } from './types.js';
import { redis } from './redis.js';
import {
isChannelAllowed,
isRoleAllowed,
type CommandGateReason
} from './command-gate-rules.js';
export type { CommandGateReason };
export type CommandGateResult =
| { ok: true }
| { ok: false; reason: CommandGateReason; retryAfterSeconds?: number };
function memberRoleIds(member: GuildMember | null): string[] {
if (!member) {
return [];
}
return [...member.roles.cache.keys()];
}
/**
* Applies guild-wide command channel rules and per-command overrides.
* Empty allow-lists mean "no restriction"; deny-lists always block.
* Per-command allow/deny is evaluated after global channel rules.
*/
export async function evaluateCommandGate(
interaction: ChatInputCommandInteraction,
context: BotContext
): Promise<CommandGateResult> {
if (!interaction.guildId) {
return { ok: true };
}
const [settings, override] = await Promise.all([
context.prisma.guildSettings.findUnique({ where: { guildId: interaction.guildId } }),
context.prisma.commandOverride.findUnique({
where: {
guildId_commandName: {
guildId: interaction.guildId,
commandName: interaction.commandName
}
}
})
]);
if (override && !override.enabled) {
return { ok: false, reason: 'disabled' };
}
const channelId = interaction.channelId;
const globalChannelBlock = isChannelAllowed(
channelId,
settings?.commandAllowedChannelIds ?? [],
settings?.commandDeniedChannelIds ?? []
);
if (globalChannelBlock) {
return { ok: false, reason: globalChannelBlock };
}
if (override) {
const perCommandChannelBlock = isChannelAllowed(
channelId,
override.allowedChannelIds,
override.deniedChannelIds
);
if (perCommandChannelBlock) {
return { ok: false, reason: perCommandChannelBlock };
}
const member =
interaction.member && 'roles' in interaction.member
? (interaction.member as GuildMember)
: null;
const roleIds = memberRoleIds(member);
const roleBlock = isRoleAllowed(roleIds, override.allowedRoleIds, override.deniedRoleIds);
if (roleBlock) {
return { ok: false, reason: roleBlock };
}
if (override.cooldownSeconds && override.cooldownSeconds > 0) {
const key = `command:cooldown:${interaction.guildId}:${interaction.commandName}:${interaction.user.id}`;
const existing = await redis.ttl(key);
if (existing > 0) {
return { ok: false, reason: 'cooldown', retryAfterSeconds: existing };
}
await redis.set(key, '1', 'EX', override.cooldownSeconds);
}
}
return { ok: true };
}

View File

@@ -4,11 +4,20 @@ import {
type ChatInputCommandInteraction,
type MessageContextMenuCommandInteraction
} from 'discord.js';
import { t } from '@nexumi/shared';
import { t, tf, type Locale } from '@nexumi/shared';
import { env } from './env.js';
import { logger } from './logger.js';
import { getGuildLocale } from './i18n.js';
import { evaluateCommandGate, type CommandGateReason } from './command-gates.js';
import {
isGuildBlacklisted,
isModuleEnabled,
isUserBlacklisted,
moduleForCommand
} from './module-gates.js';
import { isMaintenanceMode } from './presence.js';
import { recordCommandMetric } from './metrics.js';
import { captureException } from './sentry.js';
import { moderationCommands } from './modules/moderation/commands.js';
import { automodCommands } from './modules/automod/commands.js';
import { welcomeCommands } from './modules/welcome/commands.js';
@@ -60,67 +69,208 @@ const commands: SlashCommand[] = [
const map = new Map(commands.map((c) => [c.data.name, c]));
const contextMenuMap = new Map(utilityContextMenus.map((c) => [c.data.name, c]));
function buildCommandBody(): object[] {
const seen = new Set<string>();
const body: object[] = [];
for (const command of [...commands, ...utilityContextMenus]) {
const json = command.data.toJSON() as { name: string; type?: number };
const key = `${json.type ?? 1}:${json.name}`;
if (seen.has(key)) {
logger.warn({ name: json.name, type: json.type }, 'Skipping duplicate command definition');
continue;
}
seen.add(key);
body.push(json);
}
return body;
}
/**
* Guild-scoped leftovers + global commands both appear in Discord's picker.
* Always clear the unused scope so each command shows once.
*/
async function clearGuildCommandLeftovers(rest: REST, clientId: string): Promise<number> {
const guilds = (await rest.get(Routes.userGuilds())) as Array<{ id: string }>;
let cleared = 0;
for (const guild of guilds) {
const existing = (await rest.get(
Routes.applicationGuildCommands(clientId, guild.id)
)) as unknown[];
if (existing.length === 0) {
continue;
}
await rest.put(Routes.applicationGuildCommands(clientId, guild.id), { body: [] });
cleared += 1;
}
return cleared;
}
export async function registerCommands() {
const rest = new REST({ version: '10' }).setToken(env.BOT_TOKEN);
const body = [
...commands.map((c) => c.data.toJSON()),
...utilityContextMenus.map((c) => c.data.toJSON())
];
const body = buildCommandBody();
// Guild commands sync instantly; global can take up to ~1h.
if (env.BOT_GUILD_ID) {
await rest.put(Routes.applicationGuildCommands(env.BOT_CLIENT_ID, env.BOT_GUILD_ID), {
body
});
// Remove global copies so the guild does not show every command twice.
await rest.put(Routes.applicationCommands(env.BOT_CLIENT_ID), { body: [] });
logger.info(
{
scope: 'guild',
guildId: env.BOT_GUILD_ID,
count: commands.length,
contextMenus: utilityContextMenus.length
count: body.length,
clearedGlobal: true
},
'Registered guild application commands'
'Registered guild application commands and cleared global commands'
);
return;
}
await rest.put(Routes.applicationCommands(env.BOT_CLIENT_ID), { body });
// Wipe leftover guild-scoped registrations from earlier BOT_GUILD_ID / shard runs.
let clearedGuilds = 0;
try {
clearedGuilds = await clearGuildCommandLeftovers(rest, env.BOT_CLIENT_ID);
} catch (error) {
logger.warn({ error }, 'Failed to clear leftover guild application commands');
}
logger.info(
{
scope: 'global',
count: commands.length,
contextMenus: utilityContextMenus.length
count: body.length,
clearedGuilds
},
'Registered global application commands'
'Registered global application commands and cleared guild-scoped leftovers'
);
}
export async function routeCommand(interaction: ChatInputCommandInteraction, context: BotContext) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const maintenance = await isMaintenanceMode(context);
const ownerIds = env.OWNER_USER_IDS ?? [];
if (maintenance.enabled && !ownerIds.includes(interaction.user.id)) {
await interaction.reply({
content: maintenance.message?.trim() || t(locale, 'generic.maintenance'),
ephemeral: true
});
return;
function gateMessage(locale: Locale, reason: CommandGateReason, retryAfterSeconds?: number): string {
switch (reason) {
case 'disabled':
return t(locale, 'generic.commandDisabled');
case 'channel_denied':
case 'channel_not_allowed':
return t(locale, 'generic.commandChannelBlocked');
case 'role_denied':
case 'role_not_allowed':
return t(locale, 'generic.commandRoleBlocked');
case 'cooldown':
return tf(locale, 'generic.commandCooldown', {
seconds: retryAfterSeconds ?? 1
});
default:
return t(locale, 'generic.noPermission');
}
}
const command = map.get(interaction.commandName);
if (!command) {
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });
return;
export async function routeCommand(interaction: ChatInputCommandInteraction, context: BotContext) {
const started = Date.now();
let ok = true;
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 ownerIds = env.OWNER_USER_IDS ?? [];
if (maintenance.enabled && !ownerIds.includes(interaction.user.id)) {
await interaction.reply({
content: maintenance.message?.trim() || t(locale, 'generic.maintenance'),
ephemeral: true
});
return;
}
const command = map.get(interaction.commandName);
if (!command) {
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });
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);
if (!gate.ok) {
await interaction.reply({
content: gateMessage(locale, gate.reason, gate.retryAfterSeconds),
ephemeral: true
});
return;
}
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);
}
await command.execute(interaction, context);
}
export async function routeContextMenu(
interaction: MessageContextMenuCommandInteraction,
context: BotContext
) {
const command = contextMenuMap.get(interaction.commandName);
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) {
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });
return;

View 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();
});
});

View 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;
}
}

View File

@@ -19,6 +19,7 @@ const EnvSchema = z.object({
BACKUP_CRON: z.string().default('0 3 * * *'),
BACKUP_DIR: z.string().default('/backups'),
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),
PUBLIC_BASE_URL: z.string().url().default('http://localhost:8080'),
TWITCH_CLIENT_ID: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
@@ -38,7 +39,9 @@ const EnvSchema = z.object({
)
),
WEBUI_URL: z.preprocess(emptyToUndefined, z.string().url().optional()),
SUPPORT_SERVER_URL: z.preprocess(emptyToUndefined, z.string().url().optional())
SUPPORT_SERVER_URL: z.preprocess(emptyToUndefined, z.string().url().optional()),
/// Salt for hashing client IPs in verification alt-detection (shared with WebUI).
CAPTCHA_IP_HASH_SALT: z.preprocess(emptyToUndefined, z.string().min(16).optional())
});
export const env = EnvSchema.parse(process.env);
@@ -46,7 +49,7 @@ export const env = EnvSchema.parse(process.env);
export function buildBotInviteUrl(clientId = env.BOT_CLIENT_ID): string {
const url = new URL('https://discord.com/api/oauth2/authorize');
url.searchParams.set('client_id', clientId);
url.searchParams.set('permissions', '8');
url.searchParams.set('permissions', '4786708802825463');
url.searchParams.set('scope', 'bot applications.commands');
return url.toString();
}

View 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');
});
});

View File

@@ -6,7 +6,17 @@ import {
getCaptchaChallenge,
hashCaptchaAnswer
} 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> {
return new Promise((resolve, reject) => {
@@ -61,9 +71,17 @@ async function handleCaptchaGet(url: URL, res: ServerResponse): Promise<void> {
res.end('Captcha expired');
return;
}
// External providers (reCAPTCHA / hCaptcha / Turnstile) are served by the WebUI.
if (challenge.provider !== 'MATH' && env.WEBUI_URL) {
const target = `${env.WEBUI_URL.replace(/\/$/, '')}/verify/captcha?token=${encodeURIComponent(token)}`;
res.statusCode = 302;
res.setHeader('Location', target);
res.end();
return;
}
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(renderCaptchaPage(token, challenge.question));
res.end(renderCaptchaPage(token, challenge.question ?? '?'));
}
async function handleCaptchaPost(req: IncomingMessage, res: ServerResponse): Promise<void> {
@@ -82,16 +100,32 @@ async function handleCaptchaPost(req: IncomingMessage, res: ServerResponse): Pro
res.end('Captcha expired');
return;
}
if (hashCaptchaAnswer(answer) !== challenge.answerHash) {
if (challenge.provider !== 'MATH') {
if (env.WEBUI_URL) {
const target = `${env.WEBUI_URL.replace(/\/$/, '')}/verify/captcha?token=${encodeURIComponent(token)}`;
res.statusCode = 302;
res.setHeader('Location', target);
res.end();
return;
}
res.statusCode = 400;
res.end('Use the WebUI captcha URL for this provider');
return;
}
if (!challenge.answerHash || hashCaptchaAnswer(answer) !== challenge.answerHash) {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(renderCaptchaPage(token, challenge.question, 'Wrong answer. Try again.'));
res.end(renderCaptchaPage(token, challenge.question ?? '?', 'Wrong answer. Try again.'));
return;
}
await deleteCaptchaChallenge(redis, token);
await verificationQueue.add(
'verificationComplete',
{ guildId: challenge.guildId, userId: challenge.userId },
{
guildId: challenge.guildId,
userId: challenge.userId,
captchaProvider: challenge.provider
},
{ removeOnComplete: 1000, removeOnFail: 500 }
);
res.statusCode = 200;
@@ -99,6 +133,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>');
}
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 {
const server = createServer(async (req, res) => {
if (!req.url) {
@@ -121,8 +168,16 @@ export function startHealthServer(): void {
res.end('Unauthorized');
return;
}
res.statusCode = 200;
res.end('# Prometheus metrics scaffold\nnexumi_up 1\n');
try {
const snapshot = await collectMetricsSnapshot(redis);
snapshot.queueWaiting = await approximateQueueWaiting();
res.statusCode = 200;
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;
}

141
apps/bot/src/hierarchy.ts Normal file
View 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;
}

View File

@@ -14,60 +14,30 @@ import { registerCommands, routeCommand, routeContextMenu } from './commands.js'
import { ensureRecurringJobs, startWorkers } from './jobs.js';
import type { BotContext } from './types.js';
import { startHealthServer } from './health.js';
import {
handleModerationConfirmation,
isModerationConfirmation
} from './modules/moderation/confirmations.js';
import { getGuildLocale } from './i18n.js';
import { t } from '@nexumi/shared';
import { handleAutoModMessage } from './modules/automod/actions.js';
import { registerLoggingEvents } from './modules/logging/events.js';
import { registerWelcomeEvents } from './modules/welcome/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 {
handleBlackjackButton,
isBlackjackButton
} 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 { registerUtilityEvents } from './modules/utility/index.js';
import { registerTicketEvents } from './modules/tickets/index.js';
import { registerSelfRoleEvents } from './modules/selfroles/index.js';
import { registerTagEvents } from './modules/tags/index.js';
import { registerStarboardEvents } from './modules/starboard/index.js';
import {
handleSuggestionButton,
isSuggestionButton
} from './modules/suggestions/index.js';
import {
handleTempVoiceInteraction,
isTempVoiceInteraction,
isTempVoiceModal,
registerTempVoiceEvents
} from './modules/tempvoice/index.js';
import { registerTempVoiceEvents } from './modules/tempvoice/index.js';
import { registerStatsEvents } from './modules/stats/index.js';
import { handleGuildBackupButton, isGuildBackupButton } from './modules/guildbackup/index.js';
import { routeComponentInteraction } from './interaction-registry.js';
import { isPrimaryShard, runOncePerCluster } from './shard-lifecycle.js';
import { isGuildBlacklisted, isUserBlacklisted } from './module-gates.js';
import { publishShardMetrics, recordEventMetric } from './metrics.js';
import { captureException, initSentry } from './sentry.js';
const isShard = process.argv.includes('--shard');
if (!isShard) {
initSentry('bot-manager');
startHealthServer();
const manager = new ShardingManager(new URL('./index.js', import.meta.url).pathname, {
token: env.BOT_TOKEN,
@@ -75,8 +45,25 @@ if (!isShard) {
shardArgs: ['--shard']
});
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();
} else {
initSentry('bot-shard');
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
@@ -99,7 +86,7 @@ if (!isShard) {
const context: BotContext = { client, prisma, redis };
startWorkers(context);
await ensureRecurringJobs();
registerLoggingEvents(context);
registerWelcomeEvents(context);
registerVerificationEvents(context);
@@ -113,12 +100,24 @@ if (!isShard) {
registerStatsEvents(context);
client.on(Events.ClientReady, async () => {
logger.info({ tag: client.user?.tag }, 'Bot ready');
try {
await registerCommands();
} catch (error) {
logger.error({ error }, 'Failed to register application commands');
logger.info({ tag: client.user?.tag, shards: client.shard?.ids }, 'Bot ready');
if (isPrimaryShard(client)) {
try {
await runOncePerCluster(
'nexumi:lock:ensure-recurring-jobs',
120,
async () => {
await ensureRecurringJobs();
},
'Recurring job registration'
);
} catch (error) {
logger.error({ error }, 'Failed to ensure recurring jobs');
captureException(error, { phase: 'ensureRecurringJobs' });
}
}
try {
const { applyBotPresence, publishBotStatus } = await import('./presence.js');
await applyBotPresence(context);
@@ -126,18 +125,61 @@ if (!isShard) {
} catch (error) {
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) => {
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);
} catch (error) {
logger.error({ error, messageId: message.id }, 'AutoMod message handler failed');
captureException(error, { messageId: message.id });
}
});
client.on(Events.InteractionCreate, async (interaction: Interaction) => {
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()) {
await routeCommand(interaction, context);
return;
@@ -148,80 +190,23 @@ if (!isShard) {
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 (
(interaction.isButton() ||
interaction.isStringSelectMenu() ||
interaction.isModalSubmit()) &&
isTicketInteraction(interaction.customId)
interaction.isButton() ||
interaction.isStringSelectMenu() ||
interaction.isUserSelectMenu() ||
interaction.isRoleSelectMenu() ||
interaction.isChannelSelectMenu() ||
interaction.isMentionableSelectMenu() ||
interaction.isModalSubmit()
) {
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;
await routeComponentInteraction(interaction, context);
}
} catch (error) {
logger.error({ error }, 'Interaction handling failed');
captureException(error, {
interactionType: interaction.type,
guildId: interaction.guildId
});
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const message = t(locale, 'generic.error');
if (interaction.isRepliable()) {

View 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;
}

View File

@@ -0,0 +1,6 @@
import { MessageFlags } from 'discord.js';
/** Discord.js v14+ prefers flags over the deprecated `ephemeral` option. */
export function ephemeral(): { flags: typeof MessageFlags.Ephemeral } {
return { flags: MessageFlags.Ephemeral };
}

View File

@@ -8,16 +8,18 @@ import { logger } from './logger.js';
import type { BotContext } from './types.js';
import { env } from './env.js';
import { refreshPhishingDomains } from './modules/automod/filters.js';
import { activateLockdown, deactivateLockdown } from './modules/automod/actions.js';
import { completeVerification } from './modules/verification/handlers.js';
import { closePoll } from './modules/utility/poll.js';
import { sendReminder } from './modules/utility/reminders.js';
import { expireSelfRole } from './modules/selfroles/service.js';
import { endGiveaway, runGiveawayCreateJob } from './modules/giveaways/index.js';
import { endGiveaway, runGiveawayCreateJob, GiveawayError } from './modules/giveaways/index.js';
import { closeInactiveTickets } from './modules/tickets/index.js';
import { expireBirthdayRole, runBirthdayCheck } from './modules/birthdays/index.js';
import { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/index.js';
import { pollAllFeeds } from './modules/feeds/index.js';
import { sendScheduledMessage } from './modules/scheduler/index.js';
import { sendDashboardMessage, type DashboardMessageSendJob } from './modules/components-v2/send.js';
import { runGuildBackupRestoreJob } from './modules/guildbackup/index.js';
import { runSuggestionStatusUpdateJob } from './modules/suggestions/index.js';
import { runPresenceRefreshJob } from './presence.js';
@@ -32,6 +34,7 @@ import {
feedsQueueName,
giveawayQueueName,
guildBackupQueueName,
messagesQueueName,
moderationQueueName,
presenceQueue,
presenceQueueName,
@@ -63,6 +66,9 @@ type TempBanJob = {
type VerificationCompleteJob = {
guildId: string;
userId: string;
ipHash?: string | null;
userAgentHash?: string | null;
captchaProvider?: string | null;
};
type ReminderSendJob = {
@@ -117,12 +123,70 @@ export function startWorkers(context: BotContext): Worker[] {
return;
}
const guild = await context.client.guilds.fetch(job.data.guildId);
const me = await guild.members.fetchMe();
if (!me.permissions.has(PermissionFlagsBits.BanMembers)) {
throw new Error('Missing BanMembers permission for temp ban expiration');
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();
if (!me.permissions.has(PermissionFlagsBits.BanMembers)) {
throw new Error('Missing BanMembers permission for temp ban expiration');
}
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;
}
}
}
await guild.members.unban(job.data.userId, job.data.reason ?? 'Temporary ban expired');
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 }
);
@@ -149,11 +213,28 @@ export function startWorkers(context: BotContext): Worker[] {
const automodWorker = new Worker(
automodQueueName,
async (job) => {
if (job.name !== 'refreshPhishingList') {
if (job.name === 'refreshPhishingList') {
const count = await refreshPhishingDomains(redis);
logger.info({ domainCount: count }, 'Phishing domain list refreshed');
return;
}
const count = await refreshPhishingDomains(redis);
logger.info({ domainCount: count }, 'Phishing domain list refreshed');
if (job.name === 'deactivateLockdown' || job.name === 'activateLockdown') {
const guildId = (job.data as { guildId?: string }).guildId;
if (!guildId) {
return;
}
const guild = await context.client.guilds.fetch(guildId).catch(() => null);
if (!guild) {
return;
}
if (job.name === 'deactivateLockdown') {
await deactivateLockdown(guild);
logger.info({ guildId }, 'Lockdown deactivated via dashboard');
return;
}
await activateLockdown(guild);
logger.info({ guildId }, 'Lockdown activated via dashboard');
}
},
{ connection: redis }
);
@@ -168,7 +249,11 @@ export function startWorkers(context: BotContext): Worker[] {
if (job.name !== 'verificationComplete') {
return;
}
await completeVerification(context, job.data.guildId, job.data.userId);
await completeVerification(context, job.data.guildId, job.data.userId, {
ipHash: job.data.ipHash,
userAgentHash: job.data.userAgentHash,
captchaProvider: job.data.captchaProvider
});
},
{ connection: redis }
);
@@ -218,7 +303,17 @@ export function startWorkers(context: BotContext): Worker[] {
giveawayQueueName,
async (job) => {
if (job.name === 'giveawayEnd') {
await endGiveaway(context, (job.data as GiveawayEndJob).giveawayId);
try {
await endGiveaway(context, (job.data as GiveawayEndJob).giveawayId);
} catch (error) {
if (
error instanceof GiveawayError &&
(error.code === 'already_ended' || error.code === 'not_found')
) {
return;
}
throw error;
}
return;
}
if (job.name === 'giveawayCreate') {
@@ -281,6 +376,20 @@ export function startWorkers(context: BotContext): Worker[] {
logger.error({ jobId: job?.id, error }, 'Schedule job failed');
});
const messagesWorker = new Worker<DashboardMessageSendJob>(
messagesQueueName,
async (job) => {
if (job.name !== 'dashboardMessageSend') {
return;
}
return sendDashboardMessage(context, job.data);
},
{ connection: redis }
);
messagesWorker.on('failed', (job, error) => {
logger.error({ jobId: job?.id, error }, 'Dashboard message send job failed');
});
const guildBackupWorker = new Worker<{ backupId: string; guildId: string }>(
guildBackupQueueName,
async (job) => {
@@ -349,6 +458,7 @@ export function startWorkers(context: BotContext): Worker[] {
statsWorker,
feedsWorker,
scheduleWorker,
messagesWorker,
guildBackupWorker,
suggestionsWorker,
presenceWorker,

View File

@@ -0,0 +1,426 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ChannelSelectMenuBuilder,
ContainerBuilder,
MediaGalleryBuilder,
MediaGalleryItemBuilder,
MentionableSelectMenuBuilder,
MessageFlags,
RoleSelectMenuBuilder,
SectionBuilder,
SeparatorBuilder,
SeparatorSpacingSize,
StringSelectMenuBuilder,
TextDisplayBuilder,
ThumbnailBuilder,
UserSelectMenuBuilder,
type MessageActionRowComponentBuilder,
type MessageCreateOptions
} from 'discord.js';
import {
encodeComponentCustomId,
mapComponentsV2TextFields,
MessageComponentsV2Schema,
type ComponentButton,
type ComponentButtonStyle,
type ComponentSelect,
type ComponentV2Node,
type ComponentV2Source,
type MessageComponentsV2,
type SeparatorSpacing
} from '@nexumi/shared';
function isHttpUrl(value: string | undefined): value is string {
if (!value) {
return false;
}
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
const BUTTON_STYLE_MAP: Record<ComponentButtonStyle, ButtonStyle> = {
Primary: ButtonStyle.Primary,
Secondary: ButtonStyle.Secondary,
Success: ButtonStyle.Success,
Danger: ButtonStyle.Danger,
Link: ButtonStyle.Link
};
const SPACING_MAP: Record<SeparatorSpacing, SeparatorSpacingSize> = {
Small: SeparatorSpacingSize.Small,
Large: SeparatorSpacingSize.Large
};
export interface BuildComponentsV2Options {
source: ComponentV2Source;
ref: string;
renderText?: (value: string) => string;
}
function buildButton(
button: ComponentButton,
options: BuildComponentsV2Options
): ButtonBuilder | null {
const builder = new ButtonBuilder()
.setLabel(button.label.slice(0, 80))
.setDisabled(Boolean(button.disabled));
if (button.emoji) {
builder.setEmoji(button.emoji);
}
if (button.style === 'Link') {
if (!isHttpUrl(button.url)) {
return null;
}
builder.setStyle(ButtonStyle.Link).setURL(button.url);
return builder;
}
if (!button.actionId) {
return null;
}
builder
.setStyle(BUTTON_STYLE_MAP[button.style] ?? ButtonStyle.Secondary)
.setCustomId(encodeComponentCustomId(options.source, options.ref, button.actionId));
return builder;
}
function buildSelect(
select: ComponentSelect,
options: BuildComponentsV2Options
): MessageActionRowComponentBuilder | null {
const placeholder = select.placeholder?.slice(0, 150);
const minValues = select.minValues;
const maxValues = select.maxValues;
if (select.type === 'string_select') {
const menu = new StringSelectMenuBuilder()
.setCustomId(
encodeComponentCustomId(
options.source,
options.ref,
select.actionId ?? select.id ?? 'select'
)
)
.setDisabled(Boolean(select.disabled))
.addOptions(
select.options.slice(0, 25).map((option) => {
const entry: {
label: string;
value: string;
description?: string;
emoji?: string;
} = {
label: option.label.slice(0, 100),
value: option.value.slice(0, 100)
};
if (option.description) {
entry.description = option.description.slice(0, 100);
}
if (option.emoji) {
entry.emoji = option.emoji;
}
return entry;
})
);
if (placeholder) {
menu.setPlaceholder(placeholder);
}
if (minValues !== undefined) {
menu.setMinValues(minValues);
}
if (maxValues !== undefined) {
menu.setMaxValues(maxValues);
}
return menu;
}
const customId = encodeComponentCustomId(options.source, options.ref, select.actionId);
let menu:
| UserSelectMenuBuilder
| RoleSelectMenuBuilder
| ChannelSelectMenuBuilder
| MentionableSelectMenuBuilder;
switch (select.type) {
case 'user_select':
menu = new UserSelectMenuBuilder().setCustomId(customId);
break;
case 'role_select':
menu = new RoleSelectMenuBuilder().setCustomId(customId);
break;
case 'channel_select':
menu = new ChannelSelectMenuBuilder().setCustomId(customId);
break;
case 'mentionable_select':
menu = new MentionableSelectMenuBuilder().setCustomId(customId);
break;
default:
return null;
}
menu.setDisabled(Boolean(select.disabled));
if (placeholder) {
menu.setPlaceholder(placeholder);
}
if (minValues !== undefined) {
menu.setMinValues(minValues);
}
if (maxValues !== undefined) {
menu.setMaxValues(maxValues);
}
return menu;
}
function buildThumbnail(url: string, description?: string, spoiler?: boolean): ThumbnailBuilder | null {
if (!isHttpUrl(url)) {
return null;
}
const thumb = new ThumbnailBuilder().setURL(url);
if (description) {
thumb.setDescription(description.slice(0, 1024));
}
if (spoiler) {
thumb.setSpoiler(true);
}
return thumb;
}
type TopLevelBuilder =
| ContainerBuilder
| TextDisplayBuilder
| SeparatorBuilder
| MediaGalleryBuilder
| SectionBuilder
| ActionRowBuilder<MessageActionRowComponentBuilder>;
function buildNode(node: ComponentV2Node, options: BuildComponentsV2Options): TopLevelBuilder | null {
switch (node.type) {
case 'text_display':
return new TextDisplayBuilder().setContent(node.content.slice(0, 4000));
case 'separator': {
const sep = new SeparatorBuilder();
if (node.divider !== undefined) {
sep.setDivider(node.divider);
}
if (node.spacing) {
sep.setSpacing(SPACING_MAP[node.spacing]);
}
return sep;
}
case 'media_gallery': {
const gallery = new MediaGalleryBuilder();
for (const item of node.items) {
if (!isHttpUrl(item.url)) {
continue;
}
const media = new MediaGalleryItemBuilder().setURL(item.url);
if (item.description) {
media.setDescription(item.description.slice(0, 1024));
}
if (item.spoiler) {
media.setSpoiler(true);
}
gallery.addItems(media);
}
return gallery;
}
case 'section': {
const section = new SectionBuilder();
const texts = Array.isArray(node.text) ? node.text : [node.text];
for (const text of texts.slice(0, 3)) {
section.addTextDisplayComponents(new TextDisplayBuilder().setContent(text.slice(0, 4000)));
}
if (node.accessory.type === 'thumbnail') {
const thumb = buildThumbnail(
node.accessory.url,
node.accessory.description,
node.accessory.spoiler
);
if (thumb) {
section.setThumbnailAccessory(thumb);
}
} else {
const button = buildButton(node.accessory, options);
if (button) {
section.setButtonAccessory(button);
}
}
return section;
}
case 'action_row': {
const row = new ActionRowBuilder<MessageActionRowComponentBuilder>();
for (const child of node.components) {
if (child.type === 'button') {
const button = buildButton(child, options);
if (button) {
row.addComponents(button);
}
} else {
const select = buildSelect(child, options);
if (select) {
row.addComponents(select);
}
}
}
return row.components.length > 0 ? row : null;
}
case 'button': {
const button = buildButton(node, options);
if (!button) {
return null;
}
return new ActionRowBuilder<MessageActionRowComponentBuilder>().addComponents(button);
}
case 'string_select':
case 'user_select':
case 'role_select':
case 'channel_select':
case 'mentionable_select': {
const select = buildSelect(node, options);
if (!select) {
return null;
}
return new ActionRowBuilder<MessageActionRowComponentBuilder>().addComponents(select);
}
case 'thumbnail':
// Thumbnails must be section accessories; skip top-level.
return null;
case 'container': {
const container = new ContainerBuilder();
if (node.accentColor !== undefined) {
container.setAccentColor(node.accentColor);
}
if (node.spoiler) {
container.setSpoiler(true);
}
for (const child of node.components) {
switch (child.type) {
case 'text_display':
container.addTextDisplayComponents(
new TextDisplayBuilder().setContent(child.content.slice(0, 4000))
);
break;
case 'separator': {
const sep = new SeparatorBuilder();
if (child.divider !== undefined) {
sep.setDivider(child.divider);
}
if (child.spacing) {
sep.setSpacing(SPACING_MAP[child.spacing]);
}
container.addSeparatorComponents(sep);
break;
}
case 'media_gallery': {
const gallery = new MediaGalleryBuilder();
for (const item of child.items) {
if (!isHttpUrl(item.url)) {
continue;
}
const media = new MediaGalleryItemBuilder().setURL(item.url);
if (item.description) {
media.setDescription(item.description.slice(0, 1024));
}
if (item.spoiler) {
media.setSpoiler(true);
}
gallery.addItems(media);
}
container.addMediaGalleryComponents(gallery);
break;
}
case 'section': {
const section = buildNode(child, options);
if (section instanceof SectionBuilder) {
container.addSectionComponents(section);
}
break;
}
case 'action_row':
case 'button':
case 'string_select':
case 'user_select':
case 'role_select':
case 'channel_select':
case 'mentionable_select': {
const row = buildNode(child, options);
if (row instanceof ActionRowBuilder) {
container.addActionRowComponents(row);
}
break;
}
case 'container':
for (const nested of child.components) {
const sibling = buildNode(nested, options);
if (sibling instanceof TextDisplayBuilder) {
container.addTextDisplayComponents(sibling);
} else if (sibling instanceof SeparatorBuilder) {
container.addSeparatorComponents(sibling);
} else if (sibling instanceof MediaGalleryBuilder) {
container.addMediaGalleryComponents(sibling);
} else if (sibling instanceof SectionBuilder) {
container.addSectionComponents(sibling);
} else if (sibling instanceof ActionRowBuilder) {
container.addActionRowComponents(sibling);
}
}
break;
default:
break;
}
}
return container;
}
default:
return null;
}
}
/**
* Builds a discord.js Components V2 message payload from shared JSON.
* Sets MessageFlags.IsComponentsV2; never includes content/embeds.
*/
export function buildComponentsV2Payload(
raw: MessageComponentsV2 | null | undefined,
options: BuildComponentsV2Options
): MessageCreateOptions | null {
const parsed = MessageComponentsV2Schema.safeParse(raw);
if (!parsed.success) {
return null;
}
const data = options.renderText
? mapComponentsV2TextFields(parsed.data, options.renderText)
: parsed.data;
const components: TopLevelBuilder[] = [];
for (const node of data.components) {
const built = buildNode(node, options);
if (built) {
components.push(built);
}
}
if (components.length === 0) {
return null;
}
return {
components,
flags: MessageFlags.IsComponentsV2
};
}
export function parseStoredComponentsV2(raw: unknown): MessageComponentsV2 | null {
const parsed = MessageComponentsV2Schema.safeParse(raw);
return parsed.success ? parsed.data : null;
}

View File

@@ -0,0 +1,89 @@
import { EmbedBuilder } from 'discord.js';
import {
embedHasContent,
mapEmbedTextFields,
type WelcomeEmbed
} from '@nexumi/shared';
function isHttpUrl(value: string | undefined): value is string {
if (!value) {
return false;
}
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
export interface ApplyEmbedOptions {
/** Called for every text/URL field before applying to the builder. */
renderText: (value: string) => string;
/**
* Used when `thumbnailUrl` is unset (Welcome default: member avatar).
* Pass `null` to force no thumbnail.
*/
defaultThumbnailUrl?: string | null;
}
/**
* Builds a discord.js EmbedBuilder from our shared WelcomeEmbed payload.
* Invalid/non-http URLs are skipped so bad dashboard input never crashes send.
*/
export function buildEmbedFromPayload(
raw: WelcomeEmbed | null | undefined,
options: ApplyEmbedOptions
): EmbedBuilder | null {
if (!raw || (!embedHasContent(raw) && raw.color === undefined)) {
return null;
}
const data = mapEmbedTextFields(raw, options.renderText);
const embed = new EmbedBuilder();
if (data.color !== undefined) {
embed.setColor(data.color);
}
if (data.title) {
embed.setTitle(data.title);
}
if (data.description) {
embed.setDescription(data.description);
}
if (isHttpUrl(data.url)) {
embed.setURL(data.url);
}
if (data.authorName) {
embed.setAuthor({
name: data.authorName,
iconURL: isHttpUrl(data.authorIconUrl) ? data.authorIconUrl : undefined,
url: isHttpUrl(data.authorUrl) ? data.authorUrl : undefined
});
}
const thumbnail =
isHttpUrl(data.thumbnailUrl)
? data.thumbnailUrl
: options.defaultThumbnailUrl === null
? undefined
: options.defaultThumbnailUrl;
if (isHttpUrl(thumbnail)) {
embed.setThumbnail(thumbnail);
}
if (isHttpUrl(data.imageUrl)) {
embed.setImage(data.imageUrl);
}
if (data.footerText) {
embed.setFooter({
text: data.footerText,
iconURL: isHttpUrl(data.footerIconUrl) ? data.footerIconUrl : undefined
});
}
if (data.timestamp) {
embed.setTimestamp(new Date());
}
return embed;
}

129
apps/bot/src/metrics.ts Normal file
View 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`;
}

View 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);
});
});

View 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);
}

View File

@@ -204,6 +204,16 @@ export async function handleAntiRaidJoin(
return;
}
// While VERIFY raid-response is active, keep gating new joins with the unverified role.
if (config.lockdownActive && config.antiRaidAction === 'VERIFY') {
await applyRaidVerifyRole(context, member);
return;
}
if (config.lockdownActive) {
return;
}
const key = `automod:raid:${member.guild.id}`;
const count = await context.redis.incr(key);
if (count === 1) {
@@ -214,10 +224,6 @@ export async function handleAntiRaidJoin(
return;
}
if (config.lockdownActive) {
return;
}
await context.prisma.autoModConfig.update({
where: { guildId: member.guild.id },
data: { lockdownActive: true }
@@ -225,16 +231,45 @@ export async function handleAntiRaidJoin(
if (config.antiRaidAction === 'LOCKDOWN') {
await activateLockdown(member.guild);
} else if (config.antiRaidAction === 'VERIFY') {
await applyRaidVerifyRole(context, member);
}
const alertChannel = member.guild.systemChannel ?? member.guild.publicUpdatesChannel;
if (alertChannel && alertChannel.isTextBased()) {
await (alertChannel as TextChannel).send(
`Anti-raid triggered: ${count} joins in ${config.antiRaidWindowSeconds}s.`
`Anti-raid triggered: ${count} joins in ${config.antiRaidWindowSeconds}s (${config.antiRaidAction}).`
);
}
}
async function applyRaidVerifyRole(context: BotContext, member: GuildMember): Promise<void> {
const verification = await context.prisma.verificationConfig.findUnique({
where: { guildId: member.guild.id }
});
const roleId = verification?.unverifiedRoleId;
if (!roleId) {
logger.warn(
{ guildId: member.guild.id },
'Anti-raid VERIFY triggered but no unverified role is configured'
);
return;
}
const me = member.guild.members.me;
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
return;
}
const role = member.guild.roles.cache.get(roleId);
if (!role || role.position >= me.roles.highest.position) {
return;
}
if (!member.roles.cache.has(roleId)) {
await member.roles.add(roleId).catch((error) => {
logger.warn({ error, memberId: member.id }, 'Failed to apply unverified role during raid verify');
});
}
}
export async function handleAntiNukeAction(
context: BotContext,
guildId: string,

View File

@@ -18,12 +18,17 @@ export async function getAutoModConfig(prisma: PrismaClient, guildId: string) {
export async function ensureDefaultAutoModRules(prisma: PrismaClient, guildId: string) {
await ensureGuild(prisma, guildId);
const existing = await prisma.autoModRule.count({ where: { guildId } });
if (existing > 0) {
const existing = await prisma.autoModRule.findMany({
where: { guildId },
select: { ruleType: true }
});
const have = new Set(existing.map((row) => row.ruleType));
const missing = DEFAULT_AUTOMOD_RULES.filter((rule) => !have.has(rule.ruleType));
if (missing.length === 0) {
return;
}
await prisma.autoModRule.createMany({
data: DEFAULT_AUTOMOD_RULES.map((rule) => ({
data: missing.map((rule) => ({
guildId,
ruleType: rule.ruleType,
action: rule.action,

View File

@@ -0,0 +1,4 @@
export {
handleComponentsV2Interaction,
isComponentsV2Interaction
} from './interactions.js';

View File

@@ -0,0 +1,335 @@
import {
PermissionFlagsBits,
type ButtonInteraction,
type GuildMember,
type Interaction,
type MessageComponentInteraction,
type RoleSelectMenuInteraction,
type StringSelectMenuInteraction
} from 'discord.js';
import {
decodeComponentCustomId,
mapComponentsV2TextFields,
parseMessageComponentsV2,
t,
type ComponentAction,
type ComponentV2Source,
type Locale,
type MessageComponentsV2
} from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { logger } from '../../logger.js';
export function isComponentsV2Interaction(customId: string): boolean {
return decodeComponentCustomId(customId) !== null;
}
async function loadPayload(
context: BotContext,
source: ComponentV2Source,
ref: string,
guildId: string
): Promise<MessageComponentsV2 | null> {
switch (source) {
case 'w': {
const config = await context.prisma.welcomeConfig.findUnique({ where: { guildId: ref } });
if (!config || config.guildId !== guildId) {
return null;
}
return parseMessageComponentsV2(config.welcomeComponents);
}
case 'l': {
const config = await context.prisma.welcomeConfig.findUnique({ where: { guildId: ref } });
if (!config || config.guildId !== guildId) {
return null;
}
return parseMessageComponentsV2(config.leaveComponents);
}
case 't': {
const tag = await context.prisma.tag.findUnique({ where: { id: ref } });
if (!tag || tag.guildId !== guildId) {
return null;
}
return parseMessageComponentsV2(tag.components);
}
case 's': {
const schedule = await context.prisma.scheduledMessage.findUnique({ where: { id: ref } });
if (!schedule || schedule.guildId !== guildId) {
return null;
}
return parseMessageComponentsV2(schedule.components);
}
case 'm': {
const binding = await context.prisma.componentMessageBinding.findUnique({ where: { id: ref } });
if (!binding || binding.guildId !== guildId) {
return null;
}
return parseMessageComponentsV2(binding.payload);
}
default:
return null;
}
}
function resolveStringSelectAction(
payload: MessageComponentsV2,
actionId: string,
selectedValues: string[]
): ComponentAction | null {
const direct = payload.actions[actionId];
if (direct && selectedValues.length === 0) {
return direct;
}
// Prefer per-option actionId when present.
const findOptionAction = (nodes: MessageComponentsV2['components']): ComponentAction | null => {
for (const node of nodes) {
if (node.type === 'string_select') {
for (const option of node.options) {
if (selectedValues.includes(option.value) && option.actionId) {
return payload.actions[option.actionId] ?? null;
}
}
}
if (node.type === 'action_row') {
const nested = findOptionAction(node.components as MessageComponentsV2['components']);
if (nested) {
return nested;
}
}
if (node.type === 'container') {
const nested = findOptionAction(node.components);
if (nested) {
return nested;
}
}
}
return null;
};
return findOptionAction(payload.components) ?? payload.actions[actionId] ?? null;
}
async function assertRoleAssignable(member: GuildMember, roleId: string, locale: Locale): Promise<string | null> {
const me = member.guild.members.me;
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
return t(locale, 'componentsV2.error.botMissingManageRoles');
}
const role = member.guild.roles.cache.get(roleId) ?? (await member.guild.roles.fetch(roleId).catch(() => null));
if (!role) {
return t(locale, 'componentsV2.error.roleNotFound');
}
if (role.managed) {
return t(locale, 'componentsV2.error.roleManaged');
}
if (role.position >= me.roles.highest.position) {
return t(locale, 'componentsV2.error.roleHierarchy');
}
return null;
}
async function executeAction(
interaction: MessageComponentInteraction,
action: ComponentAction,
locale: Locale,
selectedRoleIds: string[]
): Promise<void> {
const member = interaction.member;
if (!member || !interaction.guild || typeof member === 'string') {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
const guildMember =
'roles' in member && typeof (member as GuildMember).roles?.add === 'function'
? (member as GuildMember)
: await interaction.guild.members.fetch(interaction.user.id);
switch (action.type) {
case 'NONE':
await interaction.deferUpdate();
return;
case 'EPHEMERAL_REPLY':
await interaction.reply({ content: action.content ?? '—', ephemeral: true });
return;
case 'PUBLIC_REPLY':
await interaction.reply({ content: action.content ?? '—' });
return;
case 'ASSIGN_ROLE': {
const err = await assertRoleAssignable(guildMember, action.roleId!, locale);
if (err) {
await interaction.reply({ content: err, ephemeral: true });
return;
}
await guildMember.roles.add(action.roleId!);
logger.info(
{ guildId: interaction.guildId, userId: interaction.user.id, roleId: action.roleId, action: 'ASSIGN_ROLE' },
'Components V2 role action'
);
await interaction.reply({
content: t(locale, 'componentsV2.role.assigned'),
ephemeral: true
});
return;
}
case 'REMOVE_ROLE': {
const err = await assertRoleAssignable(guildMember, action.roleId!, locale);
if (err) {
await interaction.reply({ content: err, ephemeral: true });
return;
}
await guildMember.roles.remove(action.roleId!);
logger.info(
{ guildId: interaction.guildId, userId: interaction.user.id, roleId: action.roleId, action: 'REMOVE_ROLE' },
'Components V2 role action'
);
await interaction.reply({
content: t(locale, 'componentsV2.role.removed'),
ephemeral: true
});
return;
}
case 'TOGGLE_ROLE': {
const err = await assertRoleAssignable(guildMember, action.roleId!, locale);
if (err) {
await interaction.reply({ content: err, ephemeral: true });
return;
}
const hasRole = guildMember.roles.cache.has(action.roleId!);
if (hasRole) {
await guildMember.roles.remove(action.roleId!);
await interaction.reply({
content: t(locale, 'componentsV2.role.removed'),
ephemeral: true
});
} else {
await guildMember.roles.add(action.roleId!);
await interaction.reply({
content: t(locale, 'componentsV2.role.assigned'),
ephemeral: true
});
}
logger.info(
{
guildId: interaction.guildId,
userId: interaction.user.id,
roleId: action.roleId,
action: 'TOGGLE_ROLE',
removed: hasRole
},
'Components V2 role action'
);
return;
}
case 'ASSIGN_SELECTED_ROLES': {
if (selectedRoleIds.length === 0) {
await interaction.reply({
content: t(locale, 'componentsV2.error.noRolesSelected'),
ephemeral: true
});
return;
}
const assigned: string[] = [];
for (const roleId of selectedRoleIds) {
const err = await assertRoleAssignable(guildMember, roleId, locale);
if (err) {
continue;
}
await guildMember.roles.add(roleId);
assigned.push(roleId);
}
logger.info(
{ guildId: interaction.guildId, userId: interaction.user.id, roleIds: assigned, action: 'ASSIGN_SELECTED_ROLES' },
'Components V2 role action'
);
if (assigned.length === 0) {
await interaction.reply({
content: t(locale, 'componentsV2.error.roleNotFound'),
ephemeral: true
});
return;
}
await interaction.reply({
content: t(locale, 'componentsV2.role.assignedSelected'),
ephemeral: true
});
return;
}
default:
await interaction.reply({ content: t(locale, 'generic.error'), ephemeral: true });
}
}
export async function handleComponentsV2Interaction(
interaction: Interaction,
context: BotContext
): Promise<void> {
if (
!interaction.isButton() &&
!interaction.isStringSelectMenu() &&
!interaction.isUserSelectMenu() &&
!interaction.isRoleSelectMenu() &&
!interaction.isChannelSelectMenu() &&
!interaction.isMentionableSelectMenu()
) {
return;
}
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild() || !interaction.guildId) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
const decoded = decodeComponentCustomId(interaction.customId);
if (!decoded) {
return;
}
const payload = await loadPayload(context, decoded.source, decoded.ref, interaction.guildId);
if (!payload) {
await interaction.reply({
content: t(locale, 'componentsV2.error.notFound'),
ephemeral: true
});
return;
}
let action: ComponentAction | null = null;
let selectedRoleIds: string[] = [];
if (interaction.isStringSelectMenu()) {
action = resolveStringSelectAction(payload, decoded.actionId, interaction.values);
} else if (interaction.isRoleSelectMenu()) {
action = payload.actions[decoded.actionId] ?? null;
selectedRoleIds = (interaction as RoleSelectMenuInteraction).values;
} else if (interaction.isButton()) {
action = payload.actions[decoded.actionId] ?? null;
} else {
action = payload.actions[decoded.actionId] ?? null;
}
if (!action) {
await interaction.reply({
content: t(locale, 'componentsV2.error.actionMissing'),
ephemeral: true
});
return;
}
// Apply placeholders for reply content when possible.
const renderedActions = mapComponentsV2TextFields(payload, (value) => value).actions;
const rendered = renderedActions[decoded.actionId] ?? action;
await executeAction(
interaction as MessageComponentInteraction,
interaction.isStringSelectMenu() ? action : rendered,
locale,
selectedRoleIds
);
}
// Re-export for typing convenience in index routing.
export type ComponentsV2ButtonInteraction = ButtonInteraction;
export type ComponentsV2StringSelectInteraction = StringSelectMenuInteraction;

View File

@@ -0,0 +1,120 @@
import {
PermissionFlagsBits,
type GuildTextBasedChannel,
type Message,
type TextChannel
} from 'discord.js';
import {
DashboardMessageSendSchema,
MessageComponentsV2Schema,
WelcomeEmbedSchema,
type MessageComponentsV2
} from '@nexumi/shared';
import { ensureGuild } from '../../guild.js';
import { buildEmbedFromPayload } from '../../lib/embed-payload.js';
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
import { logger } from '../../logger.js';
import type { BotContext } from '../../types.js';
export class MessageSendError extends Error {
constructor(public readonly code: string) {
super(code);
}
}
async function assertSendableChannel(
context: BotContext,
channelId: string
): Promise<GuildTextBasedChannel> {
const channel = await context.client.channels.fetch(channelId);
if (!channel?.isTextBased() || channel.isDMBased()) {
throw new MessageSendError('invalid_channel');
}
const me = channel.guild.members.me;
if (
!me?.permissionsIn(channel).has(PermissionFlagsBits.SendMessages) ||
!me.permissionsIn(channel).has(PermissionFlagsBits.ViewChannel)
) {
throw new MessageSendError('channel_unavailable');
}
return channel;
}
export type DashboardMessageSendJob = {
guildId: string;
channelId: string;
createdById: string;
mode: 'EMBED' | 'COMPONENTS_V2';
embed?: unknown;
components?: unknown;
bindingId?: string | null;
};
export type DashboardMessageSendResult = {
bindingId: string | null;
messageId: string | null;
channelId: string;
};
export async function sendDashboardMessage(
context: BotContext,
job: DashboardMessageSendJob
): Promise<DashboardMessageSendResult> {
await ensureGuild(context.prisma, job.guildId);
const input = DashboardMessageSendSchema.parse({
channelId: job.channelId,
mode: job.mode,
embed: job.embed ?? null,
components: job.components ?? null
});
const channel = await assertSendableChannel(context, input.channelId);
if (input.mode === 'EMBED') {
const embedData = WelcomeEmbedSchema.parse(input.embed);
const embed = buildEmbedFromPayload(embedData, { renderText: (value) => value });
if (!embed) {
throw new MessageSendError('content_required');
}
const message = await (channel as TextChannel).send({ embeds: [embed] });
return { bindingId: null, messageId: message.id, channelId: channel.id };
}
const components = MessageComponentsV2Schema.parse(input.components) as MessageComponentsV2;
let bindingId = job.bindingId ?? null;
if (!bindingId) {
const binding = await context.prisma.componentMessageBinding.create({
data: {
guildId: job.guildId,
channelId: input.channelId,
payload: components,
createdById: job.createdById
}
});
bindingId = binding.id;
}
const payload = buildComponentsV2Payload(components, {
source: 'm',
ref: bindingId
});
if (!payload) {
throw new MessageSendError('content_required');
}
const message: Message = await (channel as TextChannel).send(payload);
await context.prisma.componentMessageBinding.update({
where: { id: bindingId },
data: { messageId: message.id }
});
logger.info(
{ guildId: job.guildId, channelId: channel.id, messageId: message.id, bindingId },
'Dashboard Components V2 message sent'
);
return { bindingId, messageId: message.id, channelId: channel.id };
}

View File

@@ -1,3 +1,3 @@
export { giveawayCommands } from './commands.js';
export { isGiveawayButton, handleGiveawayButton } from './buttons.js';
export { endGiveaway, scheduleGiveawayEnd, runGiveawayCreateJob } from './service.js';
export { endGiveaway, scheduleGiveawayEnd, runGiveawayCreateJob, GiveawayError } from './service.js';

View File

@@ -59,6 +59,11 @@ function requirementLines(locale: 'de' | 'en', giveaway: Giveaway): string[] {
export function buildGiveawayEmbed(locale: 'de' | 'en', giveaway: Giveaway): EmbedBuilder {
const ended = giveaway.endedAt !== null;
const endsAtUnix = Math.floor(giveaway.endsAt.getTime() / 1000);
// Discord does not parse <t:…> timestamps in embed footers — only in
// description/fields/content. Keep relative + absolute for clarity.
const endsAtText = `<t:${endsAtUnix}:R> (<t:${endsAtUnix}:f>)`;
const embed = new EmbedBuilder()
.setTitle(t(locale, 'giveaway.embed.title'))
.setColor(ended ? 0x6b7280 : 0x6366f1)
@@ -86,19 +91,17 @@ export function buildGiveawayEmbed(locale: 'de' | 'en', giveaway: Giveaway): Emb
);
embed.setFooter({ text: t(locale, 'giveaway.embed.ended') });
} else if (giveaway.paused) {
embed.setDescription(t(locale, 'giveaway.embed.paused'));
embed.setFooter({
text: tf(locale, 'giveaway.embed.endsAt', {
time: `<t:${Math.floor(giveaway.endsAt.getTime() / 1000)}:R>`
})
});
embed.setDescription(
`${t(locale, 'giveaway.embed.paused')}\n\n${tf(locale, 'giveaway.embed.endsAt', {
time: endsAtText
})}`
);
} else {
embed.setDescription(t(locale, 'giveaway.embed.active'));
embed.setFooter({
text: tf(locale, 'giveaway.embed.endsAt', {
time: `<t:${Math.floor(giveaway.endsAt.getTime() / 1000)}:R>`
})
});
embed.setDescription(
`${t(locale, 'giveaway.embed.active')}\n\n${tf(locale, 'giveaway.embed.endsAt', {
time: endsAtText
})}`
);
}
const requirements = requirementLines(locale, giveaway);
@@ -313,10 +316,13 @@ async function dmWinners(
}
}
export async function endGiveaway(context: BotContext, giveawayId: string): Promise<void> {
export async function endGiveaway(context: BotContext, giveawayId: string): Promise<Giveaway> {
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
if (!giveaway || giveaway.endedAt) {
return;
if (!giveaway) {
throw new GiveawayError('not_found');
}
if (giveaway.endedAt) {
throw new GiveawayError('already_ended');
}
await cancelGiveawayJob(giveawayId);
@@ -338,6 +344,7 @@ export async function endGiveaway(context: BotContext, giveawayId: string): Prom
if (winners.length > 0) {
await dmWinners(context, updated, winners, locale);
}
return updated;
}
export async function rerollGiveaway(

View File

@@ -154,7 +154,7 @@ const backupCommand: SlashCommand = {
return;
}
await promptDeleteConfirmation(interaction, locale, backupId);
await promptDeleteConfirmation(interaction, context, locale, backupId);
return;
}
@@ -195,7 +195,7 @@ const backupCommand: SlashCommand = {
return;
}
await promptRestoreConfirmation(interaction, locale, backupId);
await promptRestoreConfirmation(interaction, context, locale, backupId);
}
}
};

View File

@@ -9,6 +9,12 @@ import { randomUUID } from 'node:crypto';
import { type Locale, t, tf } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import {
deleteConfirmation,
getConfirmation,
setConfirmation,
takeConfirmation
} from '../../confirmation-store.js';
import {
deleteGuildBackup,
enqueueGuildBackupRestore,
@@ -22,7 +28,8 @@ import {
const DELETE_CONFIRM_PREFIX = 'gbackup:confirm:delete:';
const RESTORE_CONFIRM_PREFIX = 'gbackup:confirm:restore:';
const CANCEL_PREFIX = 'gbackup:cancel:';
const TTL_MS = 120_000;
const NAMESPACE = 'guildbackup';
const TTL_SECONDS = 120;
type PendingDelete = {
action: 'delete';
@@ -30,7 +37,6 @@ type PendingDelete = {
userId: string;
guildId: string;
locale: Locale;
expiresAt: number;
};
type PendingRestore = {
@@ -40,22 +46,10 @@ type PendingRestore = {
userId: string;
guildId: string;
locale: Locale;
expiresAt: number;
};
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>[] {
return [
new ActionRowBuilder<ButtonBuilder>().addComponents(
@@ -73,19 +67,24 @@ function buildRows(token: string, locale: Locale, confirmPrefix: string): Action
export async function promptDeleteConfirmation(
interaction: ChatInputCommandInteraction,
context: BotContext,
locale: Locale,
backupId: string
): Promise<void> {
pruneExpired();
const token = randomUUID();
pending.set(token, {
action: 'delete',
backupId,
userId: interaction.user.id,
guildId: interaction.guildId!,
locale,
expiresAt: Date.now() + TTL_MS
});
await setConfirmation(
context.redis,
NAMESPACE,
token,
{
action: 'delete',
backupId,
userId: interaction.user.id,
guildId: interaction.guildId!,
locale
} satisfies PendingDelete,
TTL_SECONDS
);
await interaction.reply({
content: tf(locale, 'guildbackup.confirm.delete', { id: backupId }),
@@ -96,20 +95,25 @@ export async function promptDeleteConfirmation(
export async function promptRestoreConfirmation(
interaction: ChatInputCommandInteraction,
context: BotContext,
locale: Locale,
backupId: string
): Promise<void> {
pruneExpired();
const token = randomUUID();
pending.set(token, {
action: 'restore',
step: 1,
backupId,
userId: interaction.user.id,
guildId: interaction.guildId!,
locale,
expiresAt: Date.now() + TTL_MS
});
await setConfirmation(
context.redis,
NAMESPACE,
token,
{
action: 'restore',
step: 1,
backupId,
userId: interaction.user.id,
guildId: interaction.guildId!,
locale
} satisfies PendingRestore,
TTL_SECONDS
);
await interaction.reply({
content: tf(locale, 'guildbackup.confirm.restore.step1', { id: backupId }),
@@ -122,8 +126,6 @@ export async function handleGuildBackupButton(
interaction: ButtonInteraction,
context: BotContext
): Promise<void> {
pruneExpired();
const customId = interaction.customId;
const isDeleteConfirm = customId.startsWith(DELETE_CONFIRM_PREFIX);
const isRestoreConfirm = customId.startsWith(RESTORE_CONFIRM_PREFIX);
@@ -141,48 +143,57 @@ export async function handleGuildBackupButton(
: CANCEL_PREFIX.length
);
const entry = pending.get(token);
if (!entry) {
const peeked = await getConfirmation<PendingEntry>(context.redis, NAMESPACE, token);
if (!peeked) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
await interaction.reply({ content: t(locale, 'guildbackup.confirm.expired'), ephemeral: true });
return;
}
if (interaction.user.id !== entry.userId) {
if (interaction.user.id !== peeked.userId) {
await interaction.reply({
content: t(entry.locale, 'guildbackup.confirm.unauthorized'),
content: t(peeked.locale, 'guildbackup.confirm.unauthorized'),
ephemeral: true
});
return;
}
if (isCancel) {
pending.delete(token);
await deleteConfirmation(context.redis, NAMESPACE, token);
await interaction.update({
content: t(entry.locale, 'guildbackup.confirm.cancelled'),
content: t(peeked.locale, 'guildbackup.confirm.cancelled'),
components: []
});
return;
}
if (entry.action === 'delete') {
pending.delete(token);
if (peeked.action === 'delete') {
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);
return;
}
if (entry.step === 1) {
entry.step = 2;
entry.expiresAt = Date.now() + TTL_MS;
pending.set(token, entry);
if (peeked.step === 1) {
const next: PendingRestore = { ...peeked, step: 2 };
await setConfirmation(context.redis, NAMESPACE, token, next, TTL_SECONDS);
await interaction.update({
content: tf(entry.locale, 'guildbackup.confirm.restore.step2', { id: entry.backupId }),
components: buildRows(token, entry.locale, RESTORE_CONFIRM_PREFIX)
content: tf(peeked.locale, 'guildbackup.confirm.restore.step2', { id: peeked.backupId }),
components: buildRows(token, peeked.locale, RESTORE_CONFIRM_PREFIX)
});
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);
}

View File

@@ -273,7 +273,7 @@ export async function applyLevelRewards(
}
}
} else {
const reward = rewards.find((entry) => entry.level === newLevel);
const reward = [...rewards].reverse().find((entry) => entry.level <= newLevel);
const removable = rewards
.map((entry) => entry.roleId)
.filter((roleId) => member.roles.cache.has(roleId) && rewardRoleIds.has(roleId));

View File

@@ -1,8 +1,9 @@
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 { assertBannableForConfirm } from '../../hierarchy.js';
import { createCase, scheduleTempBanExpire } from './service.js';
import { parseDuration } from './duration.js';
import { tryParseDuration } from './duration.js';
type BanPayload = {
userId: string;
@@ -36,8 +37,24 @@ export async function executeBan(
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 createCase(context, {
const modCase = await createCase(context, {
guildId: guild.id,
targetUserId: payload.userId,
moderatorId: interaction.user.id,
@@ -47,21 +64,17 @@ export async function executeBan(
source: 'button_confirmation',
metadata: {
confirmed: true,
duration: payload.duration
duration: payload.duration,
durationMs
}
});
if (payload.duration) {
await scheduleTempBanExpire(
guild.id,
payload.userId,
parseDuration(payload.duration),
payload.reason
);
if (durationMs !== null) {
await scheduleTempBanExpire(guild.id, payload.userId, durationMs, payload.reason);
}
await interaction.update({
content: t(locale, 'moderation.success'),
content: tf(locale, 'moderation.success.case', { caseNumber: modCase.caseNumber }),
components: []
});
}
@@ -97,7 +110,7 @@ export async function executePurge(
deletedCount = deleted.size;
}
await createCase(context, {
const modCase = await createCase(context, {
guildId: guild.id,
targetUserId: interaction.user.id,
moderatorId: interaction.user.id,
@@ -122,7 +135,10 @@ export async function executePurge(
});
await interaction.update({
content: t(locale, 'moderation.success'),
content: tf(locale, 'moderation.purge.done', {
deletedCount,
caseNumber: modCase.caseNumber
}),
components: []
});
}
@@ -143,7 +159,7 @@ export async function executeCaseDelete(
where: { guildId_caseNumber: { guildId, caseNumber: payload.caseNumber } }
});
if (!existing) {
if (!existing || existing.deletedAt) {
await interaction.update({
content: t(locale, 'moderation.case.notFound'),
components: []
@@ -156,7 +172,7 @@ export async function executeCaseDelete(
data: { deletedAt: new Date() }
});
await createCase(context, {
const modCase = await createCase(context, {
guildId,
targetUserId: existing.targetUserId,
moderatorId: interaction.user.id,
@@ -172,7 +188,7 @@ export async function executeCaseDelete(
});
await interaction.update({
content: t(locale, 'moderation.success'),
content: tf(locale, 'moderation.success.case', { caseNumber: modCase.caseNumber }),
components: []
});
}

View File

@@ -1,4 +1,5 @@
import {
PermissionFlagsBits,
SlashCommandBuilder,
type SlashCommandStringOption,
type SlashCommandUserOption
@@ -26,7 +27,9 @@ function reasonOpt(o: SlashCommandStringOption, required = false) {
}
export const banCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('ban'),
new SlashCommandBuilder()
.setName('ban')
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers),
'moderation.ban.description'
)
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
@@ -36,7 +39,9 @@ export const banCommandData = applyCommandDescription(
);
export const unbanCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('unban'),
new SlashCommandBuilder()
.setName('unban')
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers),
'moderation.unban.description'
)
.addStringOption((o) =>
@@ -45,14 +50,18 @@ export const unbanCommandData = applyCommandDescription(
.addStringOption((o) => reasonOpt(o));
export const kickCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('kick'),
new SlashCommandBuilder()
.setName('kick')
.setDefaultMemberPermissions(PermissionFlagsBits.KickMembers),
'moderation.kick.description'
)
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
.addStringOption((o) => reasonOpt(o));
export const timeoutCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('timeout'),
new SlashCommandBuilder()
.setName('timeout')
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
'moderation.timeout.description'
)
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
@@ -62,14 +71,18 @@ export const timeoutCommandData = applyCommandDescription(
.addStringOption((o) => reasonOpt(o));
export const untimeoutCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('untimeout'),
new SlashCommandBuilder()
.setName('untimeout')
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
'moderation.untimeout.description'
)
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
.addStringOption((o) => reasonOpt(o));
export const warnCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('warn'),
new SlashCommandBuilder()
.setName('warn')
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
'moderation.warn.description'
)
.addSubcommand((s) =>
@@ -123,7 +136,9 @@ export const warnCommandData = applyCommandDescription(
);
export const purgeCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('purge'),
new SlashCommandBuilder()
.setName('purge')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages),
'moderation.purge.description'
)
.addIntegerOption((o) =>
@@ -138,24 +153,38 @@ export const purgeCommandData = applyCommandDescription(
.addStringOption((o) => applyOptionDescription(o.setName('regex'), 'moderation.purge.options.regex'));
export const slowmodeCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('slowmode'),
new SlashCommandBuilder()
.setName('slowmode')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels),
'moderation.slowmode.description'
).addIntegerOption((o) =>
applyOptionDescription(o.setName('seconds').setRequired(true).setMinValue(0).setMaxValue(21600), 'moderation.slowmode.options.seconds')
);
export const lockCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('lock'),
new SlashCommandBuilder()
.setName('lock')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels)
.addBooleanOption((o) =>
applyOptionDescription(o.setName('server'), 'moderation.lock.options.server')
),
'moderation.lock.description'
);
export const unlockCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('unlock'),
new SlashCommandBuilder()
.setName('unlock')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels)
.addBooleanOption((o) =>
applyOptionDescription(o.setName('server'), 'moderation.unlock.options.server')
),
'moderation.unlock.description'
);
export const nickCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('nick'),
new SlashCommandBuilder()
.setName('nick')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageNicknames),
'moderation.nick.description'
)
.addSubcommand((s) =>
@@ -172,7 +201,9 @@ export const nickCommandData = applyCommandDescription(
);
export const caseCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('case'),
new SlashCommandBuilder()
.setName('case')
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
'moderation.case.description'
)
.addSubcommand((s) =>
@@ -196,7 +227,9 @@ export const caseCommandData = applyCommandDescription(
);
export const modNoteCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('modnote'),
new SlashCommandBuilder()
.setName('modnote')
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
'moderation.modnote.description'
)
.addSubcommand((s) =>

View File

@@ -2,14 +2,14 @@ import {
ChannelType,
PermissionFlagsBits,
TextChannel,
type ChatInputCommandInteraction,
type GuildMember
type ChatInputCommandInteraction
} from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { requirePermission } from '../../permissions.js';
import { assertModerableMember } from '../../hierarchy.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 { promptDestructiveConfirmation } from './confirmations.js';
import {
@@ -51,6 +51,17 @@ async function ensureMod(
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 = {
data: banCommandData,
async execute(interaction, context) {
@@ -59,7 +70,22 @@ const banCommand: SlashCommand = {
const user = interaction.options.getUser('user', true);
const reason = reasonFrom(interaction, locale);
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',
payload: {
userId: user.id,
@@ -78,7 +104,7 @@ const unbanCommand: SlashCommand = {
const userId = interaction.options.getString('user_id', true);
const reason = reasonFrom(interaction, locale);
await interaction.guild!.members.unban(userId, reason);
await createCase(context, {
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: userId,
moderatorId: interaction.user.id,
@@ -86,7 +112,7 @@ const unbanCommand: SlashCommand = {
action: 'UNBAN',
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;
const user = interaction.options.getUser('user', true);
const reason = reasonFrom(interaction, locale);
const member = (await interaction.guild!.members.fetch(user.id)) as GuildMember;
await member.kick(reason);
await createCase(context, {
const hierarchy = await assertModerableMember(interaction, user.id, locale, 'kick');
if (!hierarchy.ok || !hierarchy.member) return;
await hierarchy.member.kick(reason);
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: user.id,
moderatorId: interaction.user.id,
@@ -107,7 +134,7 @@ const kickCommand: SlashCommand = {
action: 'KICK',
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);
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return;
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 member = await interaction.guild!.members.fetch(user.id);
await member.timeout(duration, reason);
await createCase(context, {
const hierarchy = await assertModerableMember(interaction, user.id, locale, 'timeout');
if (!hierarchy.ok || !hierarchy.member) return;
await hierarchy.member.timeout(duration, reason);
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: user.id,
moderatorId: interaction.user.id,
@@ -130,7 +167,7 @@ const timeoutCommand: SlashCommand = {
reason,
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;
const user = interaction.options.getUser('user', true);
const reason = reasonFrom(interaction, locale);
const member = await interaction.guild!.members.fetch(user.id);
await member.timeout(null, reason);
await createCase(context, {
const hierarchy = await assertModerableMember(interaction, user.id, locale, 'timeout');
if (!hierarchy.ok || !hierarchy.member) return;
await hierarchy.member.timeout(null, reason);
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: user.id,
moderatorId: interaction.user.id,
@@ -151,7 +189,7 @@ const untimeoutCommand: SlashCommand = {
action: 'UN_TIMEOUT',
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 durationInput = interaction.options.getString('duration');
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) {
await interaction.reply({
content: t(locale, 'moderation.escalation.durationRequired'),
@@ -267,7 +317,8 @@ const warnCommand: SlashCommand = {
interaction,
context,
user.id,
interaction.user.id
interaction.user.id,
locale
);
await interaction.reply({
content: escalationResult
@@ -300,8 +351,18 @@ const warnCommand: SlashCommand = {
if (sub === 'remove') {
const warningId = interaction.options.getString('warning_id', true);
const warning = await context.prisma.warning.delete({ where: { id: warningId } });
await createCase(context, {
const warning = await context.prisma.warning.findFirst({
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!,
targetUserId: warning.userId,
moderatorId: interaction.user.id,
@@ -309,13 +370,15 @@ const warnCommand: SlashCommand = {
action: 'WARN_REMOVE',
reason: `Warning ${warningId} removed`
});
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
await replySuccessCase(interaction, locale, modCase.caseNumber);
return;
}
const user = interaction.options.getUser('user', true);
await context.prisma.warning.deleteMany({ where: { guildId: interaction.guildId!, userId: user.id } });
await createCase(context, {
await context.prisma.warning.deleteMany({
where: { guildId: interaction.guildId!, userId: user.id }
});
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: user.id,
moderatorId: interaction.user.id,
@@ -323,7 +386,7 @@ const warnCommand: SlashCommand = {
action: 'WARN_CLEAR',
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;
}
await promptDestructiveConfirmation(interaction, locale, {
await promptDestructiveConfirmation(interaction, context, locale, {
action: 'purge',
payload: {
channelId: interaction.channel.id,
@@ -376,7 +439,7 @@ const slowmodeCommand: SlashCommand = {
if (interaction.channel instanceof TextChannel) {
await interaction.channel.setRateLimitPerUser(seconds);
}
await createCase(context, {
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: interaction.user.id,
moderatorId: interaction.user.id,
@@ -384,7 +447,7 @@ const slowmodeCommand: SlashCommand = {
action: 'SLOWMODE',
reason: `Set slowmode to ${seconds}s`
});
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
await replySuccessCase(interaction, locale, modCase.caseNumber);
}
};
@@ -393,20 +456,33 @@ const lockCommand: SlashCommand = {
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels, locale))) return;
if (interaction.channel?.type === ChannelType.GuildText) {
await interaction.channel.permissionOverwrites.edit(interaction.guild!.roles.everyone, {
const serverWide = interaction.options.getBoolean('server') ?? false;
const guild = interaction.guild!;
if (serverWide) {
for (const channel of guild.channels.cache.values()) {
if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) {
continue;
}
await channel.permissionOverwrites.edit(guild.roles.everyone, {
SendMessages: false
});
}
} else if (interaction.channel?.type === ChannelType.GuildText) {
await interaction.channel.permissionOverwrites.edit(guild.roles.everyone, {
SendMessages: false
});
}
await createCase(context, {
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: interaction.user.id,
moderatorId: interaction.user.id,
...auditFrom(interaction),
action: 'LOCK',
reason: 'Channel locked'
reason: serverWide ? 'Server locked' : 'Channel locked'
});
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
await replySuccessCase(interaction, locale, modCase.caseNumber);
}
};
@@ -415,20 +491,33 @@ const unlockCommand: SlashCommand = {
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels, locale))) return;
if (interaction.channel?.type === ChannelType.GuildText) {
await interaction.channel.permissionOverwrites.edit(interaction.guild!.roles.everyone, {
const serverWide = interaction.options.getBoolean('server') ?? false;
const guild = interaction.guild!;
if (serverWide) {
for (const channel of guild.channels.cache.values()) {
if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) {
continue;
}
await channel.permissionOverwrites.edit(guild.roles.everyone, {
SendMessages: null
});
}
} else if (interaction.channel?.type === ChannelType.GuildText) {
await interaction.channel.permissionOverwrites.edit(guild.roles.everyone, {
SendMessages: null
});
}
await createCase(context, {
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: interaction.user.id,
moderatorId: interaction.user.id,
...auditFrom(interaction),
action: 'UNLOCK',
reason: 'Channel unlocked'
reason: serverWide ? 'Server unlocked' : 'Channel unlocked'
});
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
await replySuccessCase(interaction, locale, modCase.caseNumber);
}
};
@@ -439,11 +528,13 @@ const nickCommand: SlashCommand = {
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageNicknames, locale))) return;
const sub = interaction.options.getSubcommand();
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') {
const nickname = interaction.options.getString('nickname', true);
await member.setNickname(nickname);
await createCase(context, {
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: user.id,
moderatorId: interaction.user.id,
@@ -451,9 +542,10 @@ const nickCommand: SlashCommand = {
action: 'NICK_SET',
reason: nickname
});
await replySuccessCase(interaction, locale, modCase.caseNumber);
} else {
await member.setNickname(null);
await createCase(context, {
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: user.id,
moderatorId: interaction.user.id,
@@ -461,8 +553,8 @@ const nickCommand: SlashCommand = {
action: 'NICK_RESET',
reason: 'Nickname reset'
});
await replySuccessCase(interaction, locale, modCase.caseNumber);
}
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
}
};
@@ -474,8 +566,12 @@ const caseCommand: SlashCommand = {
const sub = interaction.options.getSubcommand();
const caseNumber = interaction.options.getInteger('id', true);
if (sub === 'view') {
const found = await context.prisma.case.findUnique({
where: { guildId_caseNumber: { guildId: interaction.guildId!, caseNumber } }
const found = await context.prisma.case.findFirst({
where: {
guildId: interaction.guildId!,
caseNumber,
deletedAt: null
}
});
await interaction.reply({
content: found ? JSON.stringify(found, null, 2) : t(locale, 'moderation.case.notFound'),
@@ -485,14 +581,31 @@ const caseCommand: SlashCommand = {
}
if (sub === 'edit') {
const reason = interaction.options.getString('reason', true);
const existing = await context.prisma.case.findFirst({
where: {
guildId: interaction.guildId!,
caseNumber,
deletedAt: null
}
});
if (!existing) {
await interaction.reply({
content: t(locale, 'moderation.case.notFound'),
ephemeral: true
});
return;
}
await context.prisma.case.update({
where: { guildId_caseNumber: { guildId: interaction.guildId!, caseNumber } },
where: { id: existing.id },
data: { reason }
});
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
await interaction.reply({
content: tf(locale, 'moderation.success.case', { caseNumber }),
ephemeral: true
});
return;
}
await promptDestructiveConfirmation(interaction, locale, {
await promptDestructiveConfirmation(interaction, context, locale, {
action: 'case_delete',
payload: { caseNumber }
});
@@ -517,7 +630,7 @@ const modNoteCommand: SlashCommand = {
note
}
});
await createCase(context, {
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: user.id,
moderatorId: interaction.user.id,
@@ -525,7 +638,7 @@ const modNoteCommand: SlashCommand = {
action: 'MODNOTE_ADD',
reason: note
});
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
await replySuccessCase(interaction, locale, modCase.caseNumber);
return;
}
const notes = await context.prisma.modNote.findMany({

View File

@@ -9,11 +9,18 @@ import { randomUUID } from 'node:crypto';
import { type Locale, t, tf } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import {
deleteConfirmation,
getConfirmation,
setConfirmation,
takeConfirmation
} from '../../confirmation-store.js';
import { executeBan, executeCaseDelete, executePurge } from './actions.js';
const CONFIRM_PREFIX = 'mod:confirm:';
const CANCEL_PREFIX = 'mod:cancel:';
const TTL_MS = 60_000;
const NAMESPACE = 'moderation';
const TTL_SECONDS = 60;
type BanPayload = {
userId: string;
@@ -44,20 +51,8 @@ type PendingEntry = PendingConfirmation & {
moderatorId: string;
guildId: string;
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 {
if (entry.action === 'ban') {
return tf(locale, 'moderation.confirm.ban', {
@@ -89,18 +84,23 @@ function buildRows(token: string, locale: Locale): ActionRowBuilder<ButtonBuilde
export async function promptDestructiveConfirmation(
interaction: ChatInputCommandInteraction,
context: BotContext,
locale: Locale,
entry: PendingConfirmation
): Promise<void> {
pruneExpired();
const token = randomUUID();
pending.set(token, {
...entry,
moderatorId: interaction.user.id,
guildId: interaction.guildId!,
locale,
expiresAt: Date.now() + TTL_MS
});
await setConfirmation(
context.redis,
NAMESPACE,
token,
{
...entry,
moderatorId: interaction.user.id,
guildId: interaction.guildId!,
locale
} satisfies PendingEntry,
TTL_SECONDS
);
await interaction.reply({
content: confirmationMessage(locale, entry),
@@ -113,8 +113,6 @@ export async function handleModerationConfirmation(
interaction: ButtonInteraction,
context: BotContext
): Promise<void> {
pruneExpired();
const customId = interaction.customId;
const isConfirm = customId.startsWith(CONFIRM_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 entry = pending.get(token);
if (!entry) {
const peeked = await getConfirmation<PendingEntry>(context.redis, NAMESPACE, token);
if (!peeked) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
await interaction.reply({ content: t(locale, 'moderation.confirm.expired'), ephemeral: true });
return;
}
if (interaction.user.id !== entry.moderatorId) {
if (interaction.user.id !== peeked.moderatorId) {
await interaction.reply({
content: t(entry.locale, 'moderation.confirm.unauthorized'),
content: t(peeked.locale, 'moderation.confirm.unauthorized'),
ephemeral: true
});
return;
}
pending.delete(token);
if (isCancel) {
await deleteConfirmation(context.redis, NAMESPACE, token);
await interaction.update({
content: t(entry.locale, 'moderation.confirm.cancelled'),
content: t(peeked.locale, 'moderation.confirm.cancelled'),
components: []
});
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') {
await executeBan(interaction, context, entry.locale, entry.payload);
return;

View File

@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest';
import { parseDuration } from './duration.js';
import {
MAX_TIMEOUT_MS,
parseDuration,
parseTimeoutDuration,
tryParseDuration
} from './duration.js';
describe('parseDuration', () => {
it('parses minutes', () => {
@@ -14,3 +19,27 @@ describe('parseDuration', () => {
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);
});
});

View File

@@ -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 {
const m = input.match(/^(\d+)([smhd])$/i);
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 unit = m[2].toLowerCase();
const unit = m[2]!.toLowerCase();
const factor = unit === 's' ? 1000 : unit === 'm' ? 60000 : unit === 'h' ? 3600000 : 86400000;
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;
}
}

View File

@@ -2,7 +2,8 @@ import { moderationQueue } from '../../queues.js';
import type { BotContext } from '../../types.js';
import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js';
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 = {
guildId: string;
@@ -22,39 +23,69 @@ export async function createCase(context: BotContext, input: CreateCaseInput) {
create: { id: input.guildId }
});
const lastCase = await context.prisma.case.findFirst({
where: { guildId: input.guildId },
orderBy: { caseNumber: 'desc' }
});
const metadata = buildCaseMetadata({
source: input.source ?? 'slash_command',
channelId: input.channelId,
extras: input.metadata
});
return context.prisma.case.create({
data: {
guildId: input.guildId,
caseNumber: (lastCase?.caseNumber ?? 0) + 1,
targetUserId: input.targetUserId,
moderatorId: input.moderatorId,
action: input.action,
reason: input.reason,
metadata: metadata as Prisma.InputJsonValue
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: {
guildId: input.guildId,
caseNumber: (lastCase?.caseNumber ?? 0) + 1,
targetUserId: input.targetUserId,
moderatorId: input.moderatorId,
action: input.action,
reason: input.reason,
metadata: metadata as Prisma.InputJsonValue
}
});
},
{ 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;
}
}
}).then(async (created) => {
const { logModAction } = await import('../logging/events.js');
await logModAction(context, {
guildId: input.guildId,
caseNumber: created.caseNumber,
action: input.action,
targetUserId: input.targetUserId,
moderatorId: input.moderatorId,
reason: input.reason
});
return created;
}
if (!created) {
throw new Error('Failed to create case');
}
const { logModAction } = await import('../logging/events.js');
await logModAction(context, {
guildId: input.guildId,
caseNumber: created.caseNumber,
action: input.action,
targetUserId: input.targetUserId,
moderatorId: input.moderatorId,
reason: input.reason
});
return created;
}
export function tempBanJobId(guildId: string, userId: string): string {
return `tempBan:${guildId}:${userId}`;
}
export async function scheduleTempBanExpire(
@@ -63,10 +94,17 @@ export async function scheduleTempBanExpire(
durationMs: number,
reason?: string
) {
const jobId = tempBanJobId(guildId, userId);
const existing = await moderationQueue.getJob(jobId);
if (existing) {
await existing.remove();
}
await moderationQueue.add(
'tempBanExpire',
{ guildId, userId, reason: reason ?? null },
{
jobId,
delay: durationMs,
removeOnComplete: 1000,
removeOnFail: 500
@@ -78,7 +116,8 @@ export async function applyWarnEscalation(
interaction: ChatInputCommandInteraction,
context: BotContext,
userId: string,
moderatorId: string
moderatorId: string,
locale: Locale
): Promise<string | null> {
const guildId = interaction.guildId;
if (!guildId || !interaction.guild) {
@@ -98,16 +137,25 @@ export async function applyWarnEscalation(
}
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.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)) {
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, {
guildId,
targetUserId: userId,
@@ -116,14 +164,20 @@ export async function applyWarnEscalation(
reason,
channelId: interaction.channelId ?? undefined,
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 (!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 createCase(context, {
@@ -136,11 +190,14 @@ export async function applyWarnEscalation(
source: 'escalation',
metadata: { escalation: true, warningCount }
});
return 'Auto escalation applied: kick.';
return t(locale, 'moderation.escalation.appliedKick');
}
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 createCase(context, {
@@ -153,5 +210,5 @@ export async function applyWarnEscalation(
source: 'escalation',
metadata: { escalation: true, warningCount }
});
return 'Auto escalation applied: ban.';
return t(locale, 'moderation.escalation.appliedBan');
}

View File

@@ -1,13 +1,14 @@
import {
EmbedBuilder,
PermissionFlagsBits,
type GuildTextBasedChannel,
type MessageCreateOptions,
type TextChannel
} from 'discord.js';
import { WelcomeEmbedSchema, t, tf } from '@nexumi/shared';
import { WelcomeEmbedSchema, expandBracketChannelMentions, parseMessageComponentsV2, t, tf } from '@nexumi/shared';
import type { ScheduledMessage } from '@prisma/client';
import { ensureGuild } from '../../guild.js';
import { buildEmbedFromPayload } from '../../lib/embed-payload.js';
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
import { logger } from '../../logger.js';
import { scheduleQueue } from '../../queues.js';
import type { BotContext } from '../../types.js';
@@ -91,23 +92,31 @@ function parseStoredEmbed(raw: unknown) {
export function buildAnnouncementPayload(schedule: {
content: string | null;
embed: unknown;
components?: unknown;
rolePingId: string | null;
id?: string;
}): MessageCreateOptions {
const embedData = parseStoredEmbed(schedule.embed);
const embeds: EmbedBuilder[] = [];
if (embedData) {
const embed = new EmbedBuilder().setColor(embedData.color ?? 0x6366f1);
if (embedData.title) {
embed.setTitle(embedData.title);
const componentsData = parseMessageComponentsV2(schedule.components);
if (componentsData && schedule.id) {
const componentsPayload = buildComponentsV2Payload(componentsData, {
source: 's',
ref: schedule.id,
renderText: expandBracketChannelMentions
});
if (componentsPayload) {
if (schedule.rolePingId) {
// Components V2 cannot mix classic content; role ping is skipped.
}
return componentsPayload;
}
if (embedData.description) {
embed.setDescription(embedData.description);
}
embeds.push(embed);
}
const baseContent = schedule.content?.trim() ?? '';
const embedData = parseStoredEmbed(schedule.embed);
const embed = buildEmbedFromPayload(embedData, {
renderText: expandBracketChannelMentions
});
const baseContent = expandBracketChannelMentions(schedule.content?.trim() ?? '');
const roleMention = schedule.rolePingId ? `<@&${schedule.rolePingId}>` : '';
const content = [roleMention, baseContent].filter(Boolean).join(' ').trim();
@@ -115,8 +124,8 @@ export function buildAnnouncementPayload(schedule: {
if (content) {
payload.content = content;
}
if (embeds.length > 0) {
payload.embeds = embeds;
if (embed) {
payload.embeds = [embed];
}
if (schedule.rolePingId) {
payload.allowedMentions = { roles: [schedule.rolePingId] };
@@ -264,7 +273,11 @@ export async function listScheduledMessages(
.map((schedule) => {
const preview =
schedule.content?.slice(0, 60) ??
(schedule.embed ? t(locale, 'scheduler.list.embedPreview') : '—');
(schedule.components
? t(locale, 'scheduler.list.componentsPreview')
: schedule.embed
? t(locale, 'scheduler.list.embedPreview')
: '—');
const timing = schedule.cron
? tf(locale, 'scheduler.list.cron', { cron: schedule.cron })
: schedule.runAt
@@ -322,7 +335,7 @@ export async function sendScheduledMessage(context: BotContext, scheduleId: stri
try {
const channel = await assertSendableChannel(context, schedule.channelId);
const payload = buildAnnouncementPayload(schedule);
if (!payload.content && !payload.embeds?.length) {
if (!payload.content && !payload.embeds?.length && !payload.components?.length) {
throw new SchedulerError('content_required');
}
await (channel as TextChannel).send(payload);

View File

@@ -1,4 +1,4 @@
import { PermissionFlagsBits } from 'discord.js';
import { PermissionFlagsBits, type InteractionReplyOptions } from 'discord.js';
import { TagResponseTypeSchema, t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
@@ -67,7 +67,7 @@ const tagCommand: SlashCommand = {
interaction.channelId!,
args
);
await interaction.reply(payload);
await interaction.reply(payload as InteractionReplyOptions);
return;
}

View File

@@ -1,12 +1,19 @@
import { EmbedBuilder, type Guild, type GuildMember } from 'discord.js';
import type { Guild, GuildMember, MessageCreateOptions } from 'discord.js';
import {
applyTagPlaceholders,
componentsV2HasContent,
embedHasContent,
parseMessageComponentsV2,
TagResponseTypeSchema,
WelcomeEmbedSchema,
type TagResponseType
type MessageComponentsV2,
type TagResponseType,
type WelcomeEmbed
} from '@nexumi/shared';
import type { Tag } from '@prisma/client';
import { ensureGuild } from '../../guild.js';
import { buildEmbedFromPayload } from '../../lib/embed-payload.js';
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
import type { BotContext } from '../../types.js';
import { assertPremiumLimit, PremiumLimitError } from '../../premium.js';
@@ -50,7 +57,9 @@ export function buildTagPlaceholderVars(
'user.name': member?.displayName ?? '',
'user.tag': member?.user.tag ?? '',
'user.id': member?.id ?? '',
'user.avatar': member?.user.displayAvatarURL({ size: 256 }) ?? '',
server: guild.name,
'server.icon': guild.iconURL({ size: 128 }) ?? '',
args
};
}
@@ -65,10 +74,7 @@ export function parseTagEmbed(raw: unknown) {
return parsed.success ? parsed.data : null;
}
export type TagReplyPayload = {
content?: string;
embeds?: EmbedBuilder[];
};
export type TagReplyPayload = MessageCreateOptions;
export function buildTagReply(
tag: Tag,
@@ -79,16 +85,22 @@ export function buildTagReply(
const vars = buildTagPlaceholderVars(member, guild, args);
const responseType = TagResponseTypeSchema.parse(tag.responseType);
if (responseType === 'COMPONENTS_V2') {
const components = parseMessageComponentsV2(tag.components);
const payload = buildComponentsV2Payload(components, {
source: 't',
ref: tag.id,
renderText: (value) => renderTagContent(value, vars)
});
return payload ?? { content: '—' };
}
if (responseType === 'EMBED') {
const embedData = parseTagEmbed(tag.embed);
const embed = new EmbedBuilder().setColor(embedData?.color ?? 0x6366f1);
if (embedData?.title) {
embed.setTitle(renderTagContent(embedData.title, vars));
}
if (embedData?.description) {
embed.setDescription(renderTagContent(embedData.description, vars));
}
return { embeds: [embed] };
const embed = buildEmbedFromPayload(embedData, {
renderText: (value) => renderTagContent(value, vars)
});
return embed ? { embeds: [embed] } : { content: '—' };
}
const content = renderTagContent(tag.content ?? '', vars);
@@ -132,7 +144,8 @@ export async function createTag(
name: string;
responseType: TagResponseType;
content?: string | null;
embed?: { title?: string; description?: string; color?: number } | null;
embed?: WelcomeEmbed | null;
components?: MessageComponentsV2 | null;
triggerWord?: string | null;
allowedRoleIds?: string[];
allowedChannelIds?: string[];
@@ -150,10 +163,14 @@ export async function createTag(
throw new TagError('content_required');
}
if (params.responseType === 'EMBED' && !params.embed?.title && !params.embed?.description) {
if (params.responseType === 'EMBED' && !embedHasContent(params.embed) && params.embed?.color === undefined) {
throw new TagError('embed_required');
}
if (params.responseType === 'COMPONENTS_V2' && !componentsV2HasContent(params.components)) {
throw new TagError('components_required');
}
const currentCount = await context.prisma.tag.count({ where: { guildId: params.guildId } });
try {
await assertPremiumLimit(context.prisma, params.guildId, 'tags', currentCount);
@@ -171,6 +188,7 @@ export async function createTag(
responseType: params.responseType,
content: params.content ?? null,
embed: params.embed ?? undefined,
components: params.components ?? undefined,
triggerWord: params.triggerWord?.trim() || null,
allowedRoleIds: params.allowedRoleIds ?? [],
allowedChannelIds: params.allowedChannelIds ?? [],
@@ -187,7 +205,8 @@ export async function updateTag(
newName?: string;
responseType?: TagResponseType;
content?: string | null;
embed?: { title?: string; description?: string; color?: number } | null;
embed?: WelcomeEmbed | null;
components?: MessageComponentsV2 | null;
triggerWord?: string | null;
allowedRoleIds?: string[];
allowedChannelIds?: string[];
@@ -201,15 +220,23 @@ export async function updateTag(
const responseType = updates.responseType ?? TagResponseTypeSchema.parse(existing.responseType);
const content = updates.content !== undefined ? updates.content : existing.content;
const embed = updates.embed !== undefined ? updates.embed : parseTagEmbed(existing.embed);
const components =
updates.components !== undefined
? updates.components
: parseMessageComponentsV2(existing.components);
if (responseType === 'TEXT' && !content?.trim()) {
throw new TagError('content_required');
}
if (responseType === 'EMBED' && !embed?.title && !embed?.description) {
if (responseType === 'EMBED' && !embedHasContent(embed) && embed?.color === undefined) {
throw new TagError('embed_required');
}
if (responseType === 'COMPONENTS_V2' && !componentsV2HasContent(components)) {
throw new TagError('components_required');
}
let newName: string | undefined;
if (updates.newName) {
newName = normalizeTagName(updates.newName);
@@ -225,6 +252,7 @@ export async function updateTag(
responseType,
content,
embed: embed ?? undefined,
components: components ?? undefined,
triggerWord:
updates.triggerWord !== undefined ? updates.triggerWord?.trim() || null : existing.triggerWord,
allowedRoleIds: updates.allowedRoleIds ?? existing.allowedRoleIds,

View File

@@ -180,6 +180,40 @@ export const editsnipeCommandData = applyCommandDescription(
export const embedCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('embed'),
'utility.embed.description'
).addSubcommand((s) =>
applyCommandDescription(s.setName('builder'), 'utility.embed.builder.description')
);
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('builder'), 'utility.embed.builder.description')
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('components'), 'utility.embed.components.description')
.addChannelOption((o) =>
applyOptionDescription(
o.setName('channel').setRequired(true),
'utility.embed.components.options.channel'
)
)
.addStringOption((o) =>
applyOptionDescription(
o.setName('text').setRequired(true).setMaxLength(2000),
'utility.embed.components.options.text'
)
)
.addStringOption((o) =>
applyOptionDescription(
o.setName('image_url').setMaxLength(2048),
'utility.embed.components.options.image_url'
)
)
.addStringOption((o) =>
applyOptionDescription(
o.setName('button_label').setMaxLength(80),
'utility.embed.components.options.button_label'
)
)
.addStringOption((o) =>
applyOptionDescription(
o.setName('button_url').setMaxLength(2048),
'utility.embed.components.options.button_url'
)
)
);

View File

@@ -422,7 +422,123 @@ const embedCommand: SlashCommand = {
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageMessages, locale))) {
return;
}
await showEmbedBuilderModal(interaction);
const sub = interaction.options.getSubcommand();
if (sub === 'builder') {
await showEmbedBuilderModal(interaction);
return;
}
if (sub === 'components') {
const { ChannelType } = await import('discord.js');
const channel = interaction.options.getChannel('channel', true);
const text = interaction.options.getString('text', true);
const imageUrl = interaction.options.getString('image_url');
const buttonLabel = interaction.options.getString('button_label');
const buttonUrl = interaction.options.getString('button_url');
if (!text.trim()) {
await interaction.reply({
content: t(locale, 'utility.embed.components.missingText'),
ephemeral: true
});
return;
}
if (
!channel ||
(channel.type !== ChannelType.GuildText &&
channel.type !== ChannelType.GuildAnnouncement &&
channel.type !== ChannelType.PublicThread &&
channel.type !== ChannelType.PrivateThread)
) {
await interaction.reply({
content: t(locale, 'utility.error.channel_not_found'),
ephemeral: true
});
return;
}
const containerChildren: import('@nexumi/shared').ComponentV2Node[] = [
{ type: 'text_display', content: text.trim() }
];
if (imageUrl?.trim()) {
containerChildren.push({
type: 'media_gallery',
items: [{ url: imageUrl.trim() }]
});
}
const components: import('@nexumi/shared').MessageComponentsV2 = {
components: [
{
type: 'container',
components: containerChildren
}
],
actions: {}
};
if (buttonLabel?.trim() && buttonUrl?.trim()) {
components.components.push({
type: 'action_row',
components: [
{
type: 'button',
style: 'Link',
label: buttonLabel.trim(),
url: buttonUrl.trim()
}
]
});
}
const binding = await context.prisma.componentMessageBinding.create({
data: {
guildId: interaction.guildId!,
channelId: channel.id,
payload: components,
createdById: interaction.user.id
}
});
const { buildComponentsV2Payload } = await import('../../lib/components-v2-payload.js');
const payload = buildComponentsV2Payload(components, {
source: 'm',
ref: binding.id
});
if (!payload) {
await interaction.reply({ content: t(locale, 'generic.error'), ephemeral: true });
return;
}
const textChannel = await context.client.channels.fetch(channel.id);
if (!textChannel?.isTextBased() || textChannel.isDMBased()) {
await interaction.reply({
content: t(locale, 'utility.error.channel_not_found'),
ephemeral: true
});
return;
}
const sent = await textChannel.send(payload);
await context.prisma.componentMessageBinding.update({
where: { id: binding.id },
data: { messageId: sent.id }
});
const webui = (await import('../../env.js')).env.WEBUI_URL;
const hint = webui
? tf(locale, 'utility.embed.components.dashboardHint', {
url: `${webui}/dashboard/${interaction.guildId}/messages`
})
: '';
await interaction.reply({
content: [t(locale, 'utility.embed.components.sent'), hint].filter(Boolean).join('\n'),
ephemeral: true
});
}
}
};

View File

@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest';
import { detectAltAccount } from './alt-detection.js';
describe('detectAltAccount', () => {
const basePrior = {
userId: 'user-a',
inviteCode: 'abc123',
ipHash: 'ip-hash-1',
createdAt: new Date(),
flaggedAsAlt: false
};
it('matches identical IP hash within window', () => {
const match = detectAltAccount({
userId: 'user-b',
accountAgeDays: 30,
inviteCode: null,
ipHash: 'ip-hash-1',
priorRecords: [basePrior],
altIpWindowDays: 30,
altInviteWindowHours: 48,
altMaxAccountsPerInvite: 3
});
expect(match).toEqual({ reason: 'ip', matchedUserId: 'user-a' });
});
it('does not match when IP window expired', () => {
const match = detectAltAccount({
userId: 'user-b',
accountAgeDays: 30,
inviteCode: null,
ipHash: 'ip-hash-1',
priorRecords: [
{
...basePrior,
createdAt: new Date(Date.now() - 40 * 24 * 60 * 60 * 1000)
}
],
altIpWindowDays: 30,
altInviteWindowHours: 48,
altMaxAccountsPerInvite: 3
});
expect(match).toBeNull();
});
it('flags invite clusters for young accounts', () => {
const now = Date.now();
const match = detectAltAccount({
userId: 'user-new',
accountAgeDays: 2,
inviteCode: 'raid',
ipHash: null,
priorRecords: [
{ ...basePrior, userId: 'u1', inviteCode: 'raid', createdAt: new Date(now - 60_000) },
{ ...basePrior, userId: 'u2', inviteCode: 'raid', createdAt: new Date(now - 120_000) },
{ ...basePrior, userId: 'u3', inviteCode: 'raid', createdAt: new Date(now - 180_000) }
],
altIpWindowDays: 30,
altInviteWindowHours: 48,
altMaxAccountsPerInvite: 3
});
expect(match?.reason).toBe('invite_cluster');
});
it('ignores invite clusters for older accounts', () => {
const now = Date.now();
const match = detectAltAccount({
userId: 'user-old',
accountAgeDays: 90,
inviteCode: 'raid',
ipHash: null,
priorRecords: [
{ ...basePrior, userId: 'u1', inviteCode: 'raid', createdAt: new Date(now - 60_000) },
{ ...basePrior, userId: 'u2', inviteCode: 'raid', createdAt: new Date(now - 120_000) },
{ ...basePrior, userId: 'u3', inviteCode: 'raid', createdAt: new Date(now - 180_000) }
],
altIpWindowDays: 30,
altInviteWindowHours: 48,
altMaxAccountsPerInvite: 3
});
expect(match).toBeNull();
});
});

View File

@@ -0,0 +1,62 @@
export type AltSignalMatch = {
reason: 'ip' | 'invite_cluster';
matchedUserId: string | null;
};
export type AltDetectionInput = {
userId: string;
accountAgeDays: number;
inviteCode: string | null;
ipHash: string | null;
/** Prior verification records in the same guild (excluding current user). */
priorRecords: Array<{
userId: string;
inviteCode: string | null;
ipHash: string | null;
createdAt: Date;
flaggedAsAlt: boolean;
}>;
altIpWindowDays: number;
altInviteWindowHours: number;
altMaxAccountsPerInvite: number;
/** Only flag invite clusters when the current account is this young or younger. */
youngAccountMaxDays?: number;
};
/**
* Detects likely alt accounts from Discord + captcha signals.
* Does not use browser fingerprinting beyond salted IP / UA hashes from the captcha page.
*/
export function detectAltAccount(input: AltDetectionInput): AltSignalMatch | null {
const youngMax = input.youngAccountMaxDays ?? 7;
const now = Date.now();
if (input.ipHash) {
const ipWindowMs = input.altIpWindowDays * 24 * 60 * 60 * 1000;
const ipMatch = input.priorRecords.find(
(record) =>
record.ipHash === input.ipHash &&
now - record.createdAt.getTime() <= ipWindowMs
);
if (ipMatch) {
return { reason: 'ip', matchedUserId: ipMatch.userId };
}
}
if (input.inviteCode && input.accountAgeDays <= youngMax) {
const inviteWindowMs = input.altInviteWindowHours * 60 * 60 * 1000;
const recentSameInvite = input.priorRecords.filter(
(record) =>
record.inviteCode === input.inviteCode &&
now - record.createdAt.getTime() <= inviteWindowMs
);
if (recentSameInvite.length >= input.altMaxAccountsPerInvite) {
return {
reason: 'invite_cluster',
matchedUserId: recentSameInvite[0]?.userId ?? null
};
}
}
return null;
}

View File

@@ -26,6 +26,18 @@ export const verifyCommandData = applyCommandDescription(
{ name: 'Captcha', value: 'CAPTCHA' }
)
)
.addStringOption((o) =>
applyOptionDescription(
o.setName('captcha_provider'),
'verify.setup.options.captcha_provider'
).addChoices(
{ name: 'Math', value: 'MATH' },
{ name: 'reCAPTCHA v2', value: 'RECAPTCHA_V2' },
{ name: 'reCAPTCHA v3', value: 'RECAPTCHA_V3' },
{ name: 'hCaptcha', value: 'HCAPTCHA' },
{ name: 'Turnstile', value: 'TURNSTILE' }
)
)
.addIntegerOption((o) =>
applyOptionDescription(o.setName('min_account_age_days'), 'verify.setup.options.min_account_age_days')
.setMinValue(0)
@@ -39,6 +51,9 @@ export const verifyCommandData = applyCommandDescription(
{ name: 'None', value: 'NONE' }
)
)
.addBooleanOption((o) =>
applyOptionDescription(o.setName('alt_detection'), 'verify.setup.options.alt_detection')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('panel'), 'verify.panel.description')

View File

@@ -1,21 +1,41 @@
import { PermissionFlagsBits } from 'discord.js';
import {
PermissionFlagsBits,
type GuildBasedChannel,
type GuildTextBasedChannel
} from 'discord.js';
import { t } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js';
import { ephemeral } from '../../interaction-reply.js';
import { verifyCommandData } from './command-definitions.js';
import {
getVerificationConfig,
updateVerificationConfig
} from './service.js';
import { getVerificationConfig, updateVerificationConfig } from './service.js';
import { buildVerificationPanel } from './handlers.js';
function botCanPostPanel(
channel: GuildBasedChannel,
meId: string
): channel is GuildTextBasedChannel {
if (!channel.isTextBased() || channel.isDMBased()) {
return false;
}
const permissions = channel.permissionsFor(meId);
if (!permissions) {
return false;
}
return (
permissions.has(PermissionFlagsBits.ViewChannel) &&
permissions.has(PermissionFlagsBits.SendMessages) &&
permissions.has(PermissionFlagsBits.EmbedLinks)
);
}
const verifyCommand: SlashCommand = {
data: verifyCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() });
return;
}
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
@@ -30,11 +50,20 @@ const verifyCommand: SlashCommand = {
const verifiedRole = interaction.options.getRole('verified_role', true);
const unverifiedRole = interaction.options.getRole('unverified_role');
const mode = (interaction.options.getString('mode') ?? 'BUTTON') as 'BUTTON' | 'CAPTCHA';
const captchaProvider = (interaction.options.getString('captcha_provider') ??
'MATH') as
| 'MATH'
| 'RECAPTCHA_V2'
| 'RECAPTCHA_V3'
| 'HCAPTCHA'
| 'TURNSTILE';
const minAccountAgeDays = interaction.options.getInteger('min_account_age_days') ?? 0;
const failAction = (interaction.options.getString('fail_action') ?? 'KICK') as
| 'KICK'
| 'BAN'
| 'NONE';
const altDetectionEnabled =
interaction.options.getBoolean('alt_detection') ?? false;
await updateVerificationConfig(context.prisma, guildId, {
enabled: true,
@@ -42,17 +71,22 @@ const verifyCommand: SlashCommand = {
verifiedRoleId: verifiedRole.id,
unverifiedRoleId: unverifiedRole?.id ?? null,
mode,
captchaProvider,
minAccountAgeDays,
failAction
failAction,
altDetectionEnabled
});
await interaction.reply({ content: t(locale, 'verification.setupDone'), ephemeral: true });
await interaction.reply({ content: t(locale, 'verification.setupDone'), ...ephemeral() });
return;
}
const config = await getVerificationConfig(context.prisma, guildId);
if (!config.enabled || !config.verifiedRoleId) {
await interaction.reply({ content: t(locale, 'verification.notConfigured'), ephemeral: true });
await interaction.reply({
content: t(locale, 'verification.notConfigured'),
...ephemeral()
});
return;
}
@@ -63,12 +97,16 @@ const verifyCommand: SlashCommand = {
? await interaction.guild!.channels.fetch(config.channelId)
: null;
if (!panelChannel?.isTextBased() || panelChannel.isDMBased()) {
await interaction.reply({ content: t(locale, 'verification.invalidChannel'), ephemeral: true });
const me = interaction.guild!.members.me;
if (!panelChannel || !me || !botCanPostPanel(panelChannel, me.id)) {
await interaction.reply({
content: t(locale, 'verification.panelMissingPermission'),
...ephemeral()
});
return;
}
const panel = buildVerificationPanel(config);
const panel = buildVerificationPanel(config, locale);
const message = await panelChannel.send(panel);
await context.prisma.verificationConfig.update({
where: { guildId },
@@ -77,7 +115,7 @@ const verifyCommand: SlashCommand = {
panelMessageId: message.id
}
});
await interaction.reply({ content: t(locale, 'verification.panelPosted'), ephemeral: true });
await interaction.reply({ content: t(locale, 'verification.panelPosted'), ...ephemeral() });
}
};

View File

@@ -7,7 +7,7 @@ import {
type ButtonInteraction,
type GuildMember
} from 'discord.js';
import { t, tf } from '@nexumi/shared';
import { CaptchaProviderSchema, t, tf, type CaptchaProvider } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import {
@@ -18,8 +18,16 @@ import {
verificationButtonId,
verificationCaptchaButtonId
} from './service.js';
import { detectAltAccount } from './alt-detection.js';
import { env } from '../../env.js';
import { logger } from '../../logger.js';
import { ephemeral } from '../../interaction-reply.js';
export type CompleteVerificationOptions = {
ipHash?: string | null;
userAgentHash?: string | null;
captchaProvider?: string | null;
};
async function applyFailAction(
member: GuildMember,
@@ -39,7 +47,8 @@ async function applyFailAction(
export async function completeVerification(
context: BotContext,
guildId: string,
userId: string
userId: string,
options: CompleteVerificationOptions = {}
): Promise<{ ok: boolean; reason?: string }> {
const config = await getVerificationConfig(context.prisma, guildId);
if (!config.enabled || !config.verifiedRoleId) {
@@ -62,6 +71,88 @@ export async function completeVerification(
return { ok: false, reason: 'account_too_young' };
}
const inviteJoin = await context.prisma.inviteJoin.findUnique({
where: { guildId_userId: { guildId, userId } }
});
let flaggedAsAlt = false;
let matchedUserId: string | null = null;
if (config.altDetectionEnabled) {
const lookbackMs = Math.max(
config.altIpWindowDays * 24 * 60 * 60 * 1000,
config.altInviteWindowHours * 60 * 60 * 1000
);
const since = new Date(Date.now() - lookbackMs);
const priorRecords = await context.prisma.verificationRecord.findMany({
where: {
guildId,
userId: { not: userId },
createdAt: { gte: since }
},
select: {
userId: true,
inviteCode: true,
ipHash: true,
createdAt: true,
flaggedAsAlt: true
}
});
const match = detectAltAccount({
userId,
accountAgeDays: ageDays,
inviteCode: inviteJoin?.code ?? null,
ipHash: options.ipHash ?? null,
priorRecords,
altIpWindowDays: config.altIpWindowDays,
altInviteWindowHours: config.altInviteWindowHours,
altMaxAccountsPerInvite: config.altMaxAccountsPerInvite
});
if (match) {
flaggedAsAlt = true;
matchedUserId = match.matchedUserId;
logger.info(
{ guildId, userId, reason: match.reason, matchedUserId },
'Verification alt-account signal'
);
await context.prisma.verificationRecord.upsert({
where: { guildId_userId: { guildId, userId } },
create: {
guildId,
userId,
inviteCode: inviteJoin?.code ?? null,
inviterId: inviteJoin?.inviterId ?? null,
ipHash: options.ipHash ?? null,
userAgentHash: options.userAgentHash ?? null,
captchaProvider: options.captchaProvider ?? null,
flaggedAsAlt: true,
matchedUserId
},
update: {
inviteCode: inviteJoin?.code ?? null,
inviterId: inviteJoin?.inviterId ?? null,
ipHash: options.ipHash ?? null,
userAgentHash: options.userAgentHash ?? null,
captchaProvider: options.captchaProvider ?? null,
flaggedAsAlt: true,
matchedUserId
}
});
if (config.altBlockOnMatch) {
await applyFailAction(
member,
config.failAction,
`Verification failed: possible alt account (${match.reason})`
);
return { ok: false, reason: 'alt_detected' };
}
}
}
const me = guild.members.me;
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
return { ok: false, reason: 'missing_permissions' };
@@ -82,13 +173,40 @@ export async function completeVerification(
await member.roles.add(config.verifiedRoleId);
}
await context.prisma.verificationRecord.upsert({
where: { guildId_userId: { guildId, userId } },
create: {
guildId,
userId,
inviteCode: inviteJoin?.code ?? null,
inviterId: inviteJoin?.inviterId ?? null,
ipHash: options.ipHash ?? null,
userAgentHash: options.userAgentHash ?? null,
captchaProvider: options.captchaProvider ?? null,
flaggedAsAlt,
matchedUserId
},
update: {
inviteCode: inviteJoin?.code ?? null,
inviterId: inviteJoin?.inviterId ?? null,
ipHash: options.ipHash ?? null,
userAgentHash: options.userAgentHash ?? null,
captchaProvider: options.captchaProvider ?? null,
flaggedAsAlt,
matchedUserId
}
});
return { ok: true };
}
export function buildVerificationPanel(config: Awaited<ReturnType<typeof getVerificationConfig>>) {
export function buildVerificationPanel(
config: Awaited<ReturnType<typeof getVerificationConfig>>,
locale: 'de' | 'en'
) {
const embed = new EmbedBuilder()
.setTitle('Verification')
.setDescription('Click the button below to verify and gain access to the server.')
.setTitle(t(locale, 'verification.panel.title'))
.setDescription(t(locale, 'verification.panel.description'))
.setColor(0x6366f1);
const customId =
@@ -99,7 +217,11 @@ export function buildVerificationPanel(config: Awaited<ReturnType<typeof getVeri
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(customId)
.setLabel(config.mode === 'CAPTCHA' ? 'Verify (Captcha)' : 'Verify')
.setLabel(
config.mode === 'CAPTCHA'
? t(locale, 'verification.panel.buttonCaptcha')
: t(locale, 'verification.panel.button')
)
.setStyle(ButtonStyle.Success)
);
@@ -119,25 +241,29 @@ export async function handleVerificationButton(
const config = await getVerificationConfig(context.prisma, parsed.guildId);
if (!config.enabled) {
await interaction.reply({ content: t(locale, 'verification.disabled'), ephemeral: true });
await interaction.reply({ content: t(locale, 'verification.disabled'), ...ephemeral() });
return;
}
if (parsed.mode === 'captcha') {
const providerParse = CaptchaProviderSchema.safeParse(config.captchaProvider);
const provider: CaptchaProvider = providerParse.success ? providerParse.data : 'MATH';
const challenge = await createCaptchaChallenge(
context.redis,
parsed.guildId,
interaction.user.id
interaction.user.id,
provider
);
const url = `${env.PUBLIC_BASE_URL}/verify/captcha?token=${challenge.token}`;
const base = (env.WEBUI_URL ?? env.PUBLIC_BASE_URL).replace(/\/$/, '');
const url = `${base}/verify/captcha?token=${challenge.token}`;
await interaction.reply({
content: tf(locale, 'verification.captchaLink', { url }),
ephemeral: true
...ephemeral()
});
return;
}
await interaction.deferReply({ ephemeral: true });
await interaction.deferReply(ephemeral());
const result = await completeVerification(context, parsed.guildId, interaction.user.id);
if (result.ok) {
await interaction.editReply({ content: t(locale, 'verification.success') });

View File

@@ -1,11 +1,27 @@
import { randomBytes, randomInt, createHash } from 'node:crypto';
import { createHash, randomBytes, randomInt } from 'node:crypto';
import type { CaptchaProvider, VerificationFailAction, VerificationMode } from '@nexumi/shared';
import type { PrismaClient } from '@prisma/client';
import type { VerificationFailAction, VerificationMode } from '@nexumi/shared';
import { ensureGuild } from '../../guild.js';
import type { Redis } from 'ioredis';
import { ensureGuild } from '../../guild.js';
const CAPTCHA_PREFIX = 'verify:captcha:';
export type VerificationConfigUpdate = {
enabled: boolean;
channelId: string;
verifiedRoleId: string;
unverifiedRoleId?: string | null;
mode: VerificationMode;
captchaProvider?: CaptchaProvider;
minAccountAgeDays: number;
failAction: VerificationFailAction;
altDetectionEnabled?: boolean;
altBlockOnMatch?: boolean;
altIpWindowDays?: number;
altInviteWindowHours?: number;
altMaxAccountsPerInvite?: number;
};
export async function getVerificationConfig(prisma: PrismaClient, guildId: string) {
await ensureGuild(prisma, guildId);
let config = await prisma.verificationConfig.findUnique({ where: { guildId } });
@@ -18,15 +34,7 @@ export async function getVerificationConfig(prisma: PrismaClient, guildId: strin
export async function updateVerificationConfig(
prisma: PrismaClient,
guildId: string,
data: {
enabled: boolean;
channelId: string;
verifiedRoleId: string;
unverifiedRoleId?: string | null;
mode: VerificationMode;
minAccountAgeDays: number;
failAction: VerificationFailAction;
}
data: VerificationConfigUpdate
) {
await ensureGuild(prisma, guildId);
return prisma.verificationConfig.upsert({
@@ -65,27 +73,32 @@ export type CaptchaChallenge = {
token: string;
guildId: string;
userId: string;
question: string;
answerHash: string;
provider: CaptchaProvider;
question?: string;
answerHash?: string;
};
export async function createCaptchaChallenge(
redis: Redis,
guildId: string,
userId: string
userId: string,
provider: CaptchaProvider = 'MATH'
): Promise<CaptchaChallenge> {
const a = randomInt(2, 12);
const b = randomInt(2, 12);
const answer = String(a + b);
const token = randomBytes(24).toString('hex');
const answerHash = createHash('sha256').update(answer).digest('hex');
const challenge: CaptchaChallenge = {
token,
guildId,
userId,
question: `${a} + ${b}`,
answerHash
provider
};
if (provider === 'MATH') {
const a = randomInt(2, 12);
const b = randomInt(2, 12);
challenge.question = `${a} + ${b}`;
challenge.answerHash = createHash('sha256').update(String(a + b)).digest('hex');
}
await redis.set(`${CAPTCHA_PREFIX}${token}`, JSON.stringify(challenge), 'EX', 600);
return challenge;
}
@@ -98,7 +111,11 @@ export async function getCaptchaChallenge(
if (!raw) {
return null;
}
return JSON.parse(raw) as CaptchaChallenge;
const parsed = JSON.parse(raw) as CaptchaChallenge;
if (!parsed.provider) {
parsed.provider = 'MATH';
}
return parsed;
}
export async function deleteCaptchaChallenge(redis: Redis, token: string): Promise<void> {
@@ -113,3 +130,7 @@ export function accountAgeDays(userCreatedAt: Date): number {
const ms = Date.now() - userCreatedAt.getTime();
return Math.floor(ms / (1000 * 60 * 60 * 24));
}
export function hashClientSignal(salt: string, value: string): string {
return createHash('sha256').update(`${salt}:${value}`).digest('hex');
}

View File

@@ -1,4 +1,4 @@
import { PermissionFlagsBits } from 'discord.js';
import { MessageFlags, PermissionFlagsBits, type InteractionReplyOptions } from 'discord.js';
import { t } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
@@ -7,6 +7,26 @@ import { welcomeCommandData } from './command-definitions.js';
import { getWelcomeConfig, resolveWelcomePayload } from './service.js';
import { buildWelcomeMessage } from './renderer.js';
function toInteractionReply(
message: Awaited<ReturnType<typeof buildWelcomeMessage>>,
ephemeral: boolean
): InteractionReplyOptions {
const flags =
typeof message.flags === 'number'
? ephemeral
? message.flags | MessageFlags.Ephemeral
: message.flags
: undefined;
return {
content: message.content,
embeds: message.embeds,
files: message.files,
components: message.components,
...(flags !== undefined ? { flags } : ephemeral ? { ephemeral: true } : {})
};
}
const welcomeCommand: SlashCommand = {
data: welcomeCommandData,
async execute(interaction, context) {
@@ -32,7 +52,7 @@ const welcomeCommand: SlashCommand = {
if (sub === 'preview') {
const message = await buildWelcomeMessage(payload, guildMember, interaction.guild!);
await interaction.reply({ ...message, ephemeral: true });
await interaction.reply(toInteractionReply(message, true));
return;
}
@@ -48,7 +68,9 @@ const welcomeCommand: SlashCommand = {
}
const message = await buildWelcomeMessage(payload, guildMember, interaction.guild!);
await channel.send(message);
if ('send' in channel) {
await channel.send(message);
}
await interaction.reply({ content: t(locale, 'welcome.testSent'), ephemeral: true });
}
};

View File

@@ -1,6 +1,6 @@
import { PermissionFlagsBits, type GuildMember } from 'discord.js';
import type { BotContext } from '../../types.js';
import { getWelcomeConfig, resolveWelcomePayload } from './service.js';
import { getWelcomeConfig, resolveWelcomePayload, resolveLeavePayload, renderWelcomeText } from './service.js';
import { sendLeaveMessage, sendWelcomeMessage } from './renderer.js';
import { logger } from '../../logger.js';
@@ -41,7 +41,7 @@ export async function handleMemberWelcome(context: BotContext, member: GuildMemb
if (config.welcomeDmEnabled && config.welcomeDmContent && !member.user.bot) {
await member
.send({ content: config.welcomeDmContent.replace('{user}', `<@${member.id}>`) })
.send({ content: renderWelcomeText(config.welcomeDmContent, member, member.guild) })
.catch(() => undefined);
}
}
@@ -55,8 +55,7 @@ export async function handleMemberLeave(context: BotContext, member: GuildMember
if (!channel?.isTextBased() || channel.isDMBased()) {
return;
}
const content = config.leaveContent ?? '{user.tag} left {server}.';
await sendLeaveMessage(channel, content, member, member.guild);
await sendLeaveMessage(channel, resolveLeavePayload(config), member, member.guild);
}
export function registerWelcomeEvents(context: BotContext): void {

View File

@@ -1,34 +1,40 @@
import {
AttachmentBuilder,
EmbedBuilder,
type Guild,
type GuildMember,
type MessageCreateOptions,
type SendableChannels,
type TextBasedChannel
} from 'discord.js';
import { createCanvas, loadImage } from '@napi-rs/canvas';
import type { WelcomePayload } from './service.js';
import { buildEmbedFromPayload } from '../../lib/embed-payload.js';
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
import type { LeavePayload, WelcomePayload } from './service.js';
import { renderWelcomeText } from './service.js';
export async function buildWelcomeMessage(
payload: WelcomePayload,
member: GuildMember,
guild: Guild
): Promise<{ content?: string; embeds?: EmbedBuilder[]; files?: AttachmentBuilder[] }> {
): Promise<MessageCreateOptions> {
if (payload.type === 'COMPONENTS_V2') {
const componentsPayload = buildComponentsV2Payload(payload.components, {
source: 'w',
ref: guild.id,
renderText: (value) => renderWelcomeText(value, member, guild)
});
if (componentsPayload) {
return componentsPayload;
}
return { content: renderWelcomeText('{user}', member, guild) };
}
if (payload.type === 'EMBED') {
const embed = new EmbedBuilder();
const data = payload.embed;
if (data?.title) {
embed.setTitle(renderWelcomeText(data.title, member, guild));
}
if (data?.description) {
embed.setDescription(renderWelcomeText(data.description, member, guild));
}
if (data?.color !== undefined) {
embed.setColor(data.color);
}
embed.setThumbnail(member.user.displayAvatarURL({ size: 256 }));
return { embeds: [embed] };
const embed = buildEmbedFromPayload(payload.embed, {
renderText: (value) => renderWelcomeText(value, member, guild),
defaultThumbnailUrl: member.user.displayAvatarURL({ size: 256 })
});
return embed ? { embeds: [embed] } : { content: renderWelcomeText('{user}', member, guild) };
}
if (payload.type === 'IMAGE') {
@@ -95,13 +101,47 @@ export async function sendWelcomeMessage(
}
}
export async function buildLeaveMessage(
payload: LeavePayload,
member: GuildMember,
guild: Guild
): Promise<MessageCreateOptions> {
if (payload.type === 'COMPONENTS_V2') {
const componentsPayload = buildComponentsV2Payload(payload.components, {
source: 'l',
ref: guild.id,
renderText: (value) => renderWelcomeText(value, member, guild)
});
if (componentsPayload) {
return componentsPayload;
}
}
if (payload.type === 'EMBED') {
const embed = buildEmbedFromPayload(payload.embed, {
renderText: (value) => renderWelcomeText(value, member, guild),
defaultThumbnailUrl: member.user.displayAvatarURL({ size: 256 })
});
if (embed) {
return { embeds: [embed] };
}
}
const content = payload.content ?? '{user.tag} left {server}.';
return { content: renderWelcomeText(content, member, guild) };
}
export async function sendLeaveMessage(
channel: TextBasedChannel,
content: string,
payload: LeavePayload | string,
member: GuildMember,
guild: Guild
): Promise<void> {
if ('send' in channel) {
await (channel as SendableChannels).send({ content: renderWelcomeText(content, member, guild) });
const message =
typeof payload === 'string'
? { content: renderWelcomeText(payload, member, guild) }
: await buildLeaveMessage(payload, member, guild);
await (channel as SendableChannels).send(message);
}
}

View File

@@ -1,6 +1,6 @@
import type { PrismaClient } from '@prisma/client';
import type { WelcomeEmbed, WelcomeMessageType } from '@nexumi/shared';
import { applyWelcomePlaceholders } from '@nexumi/shared';
import { applyWelcomePlaceholders, parseMessageComponentsV2 } from '@nexumi/shared';
import type { Guild, GuildMember } from 'discord.js';
import { ensureGuild } from '../../guild.js';
@@ -19,8 +19,10 @@ export function buildPlaceholderVars(member: GuildMember, guild: Guild) {
'user.name': member.displayName,
'user.tag': member.user.tag,
'user.id': member.id,
'user.avatar': member.user.displayAvatarURL({ size: 256 }),
server: guild.name,
memberCount: guild.memberCount
memberCount: guild.memberCount,
'server.icon': guild.iconURL({ size: 128 }) ?? ''
};
}
@@ -39,6 +41,7 @@ export type WelcomePayload = {
type: WelcomeMessageType;
content?: string | null;
embed?: WelcomeEmbed | null;
components?: import('@nexumi/shared').MessageComponentsV2 | null;
imageTitle?: string | null;
imageSubtitle?: string | null;
};
@@ -50,7 +53,26 @@ export function resolveWelcomePayload(
type: config.welcomeType as WelcomeMessageType,
content: config.welcomeContent,
embed: parseWelcomeEmbed(config.welcomeEmbed),
components: parseMessageComponentsV2(config.welcomeComponents),
imageTitle: config.imageTitle,
imageSubtitle: config.imageSubtitle
};
}
export type LeavePayload = {
type: import('@nexumi/shared').LeaveMessageType;
content?: string | null;
embed?: WelcomeEmbed | null;
components?: import('@nexumi/shared').MessageComponentsV2 | null;
};
export function resolveLeavePayload(
config: Awaited<ReturnType<typeof getWelcomeConfig>>
): LeavePayload {
return {
type: (config.leaveType as LeavePayload['type']) || 'TEXT',
content: config.leaveContent,
embed: parseWelcomeEmbed(config.leaveEmbed),
components: parseMessageComponentsV2(config.leaveComponents)
};
}

View File

@@ -23,6 +23,8 @@ export const feedsQueueName = 'feeds';
export const feedsQueue = new Queue(feedsQueueName, { connection: redis });
export const scheduleQueueName = 'schedules';
export const scheduleQueue = new Queue(scheduleQueueName, { connection: redis });
export const messagesQueueName = 'messages';
export const messagesQueue = new Queue(messagesQueueName, { connection: redis });
export const guildBackupQueueName = 'guild-backups';
export const guildBackupQueue = new Queue(guildBackupQueueName, { connection: redis });
export const suggestionsQueueName = 'suggestions';

36
apps/bot/src/sentry.ts Normal file
View 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);
});
}

View 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;
}

View File

@@ -4,7 +4,8 @@ import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'standalone',
transpilePackages: ['@nexumi/shared'],
outputFileTracingRoot: path.join(__dirname, '../..')
outputFileTracingRoot: path.join(__dirname, '../..'),
serverExternalPackages: ['@prisma/client', 'ioredis', 'bullmq']
};
export default nextConfig;

View File

@@ -15,8 +15,10 @@
"@nexumi/shared": "workspace:^",
"@prisma/client": "^5.22.0",
"@radix-ui/react-avatar": "^1.2.3",
"@radix-ui/react-dialog": "^1.1.20",
"@radix-ui/react-dropdown-menu": "^2.1.21",
"@radix-ui/react-label": "^2.1.12",
"@radix-ui/react-popover": "^1.1.20",
"@radix-ui/react-select": "^2.3.4",
"@radix-ui/react-separator": "^1.1.12",
"@radix-ui/react-slot": "^1.3.0",
@@ -24,6 +26,7 @@
"bullmq": "^5.34.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dotenv": "^16.4.7",
"ioredis": "^5.4.2",
"lucide-react": "^1.25.0",

View File

@@ -1,7 +1,9 @@
import { CaseListQuerySchema } from '@nexumi/shared';
import { CaseListQuerySchema, CaseUpdateDashboardSchema } from '@nexumi/shared';
import { type NextRequest, NextResponse } from 'next/server';
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
import { listCases } from '@/lib/module-configs/cases';
import { writeDashboardAudit } from '@/lib/audit';
import { listCases, softDeleteCase, updateCaseReason } from '@/lib/module-configs/cases';
import { prisma } from '@/lib/prisma';
interface RouteParams {
params: Promise<{ guildId: string }>;
@@ -11,15 +13,63 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
await requireGuildAccess(guildId);
const searchParams = request.nextUrl.searchParams;
const query = CaseListQuerySchema.parse({
search: searchParams.get('search') ?? undefined,
action: searchParams.get('action') ?? undefined,
limit: searchParams.get('limit') ?? undefined
});
const query = CaseListQuerySchema.parse(Object.fromEntries(request.nextUrl.searchParams));
const cases = await listCases(guildId, query);
return NextResponse.json({ cases });
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function PATCH(request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
const session = await requireGuildAccess(guildId);
const body = (await request.json()) as { id?: string; reason?: string };
if (!body.id) {
return NextResponse.json({ error: 'Missing case id' }, { status: 400 });
}
const patch = CaseUpdateDashboardSchema.parse({ reason: body.reason });
const updated = await updateCaseReason(guildId, body.id, patch);
if (!updated) {
return NextResponse.json({ error: 'Case not found' }, { status: 404 });
}
await writeDashboardAudit(prisma, {
guildId,
actorUserId: session.user.id,
action: 'case.update',
path: `/dashboard/${guildId}/moderation`,
before: { id: body.id },
after: updated
});
return NextResponse.json({ case: updated });
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function DELETE(request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
const session = await requireGuildAccess(guildId);
const id = request.nextUrl.searchParams.get('id');
if (!id) {
return NextResponse.json({ error: 'Missing case id' }, { status: 400 });
}
const ok = await softDeleteCase(guildId, id);
if (!ok) {
return NextResponse.json({ error: 'Case not found' }, { status: 404 });
}
await writeDashboardAudit(prisma, {
guildId,
actorUserId: session.user.id,
action: 'case.delete',
path: `/dashboard/${guildId}/moderation`,
before: { id },
after: { deleted: true }
});
return NextResponse.json({ ok: true });
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -0,0 +1,18 @@
import { type NextRequest, NextResponse } from 'next/server';
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
import { listGuildChannelsForDashboard } from '@/lib/discord-guild-resources';
interface RouteParams {
params: Promise<{ guildId: string }>;
}
export async function GET(_request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
await requireGuildAccess(guildId);
const channels = await listGuildChannelsForDashboard(guildId);
return NextResponse.json(channels);
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -0,0 +1,46 @@
import { CommandGlobalRulesPatchSchema } from '@nexumi/shared';
import { type NextRequest, NextResponse } from 'next/server';
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
import { writeDashboardAudit } from '@/lib/audit';
import { getCommandGlobalRules, updateCommandGlobalRules } from '@/lib/module-configs/command-global-rules';
import { prisma } from '@/lib/prisma';
interface RouteParams {
params: Promise<{ guildId: string }>;
}
export async function GET(_request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
await requireGuildAccess(guildId);
const rules = await getCommandGlobalRules(guildId);
return NextResponse.json(rules);
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function PATCH(request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
const session = await requireGuildAccess(guildId);
const body = await request.json();
const patch = CommandGlobalRulesPatchSchema.parse(body);
const before = await getCommandGlobalRules(guildId);
const after = await updateCommandGlobalRules(guildId, patch);
await writeDashboardAudit(prisma, {
guildId,
actorUserId: session.user.id,
action: 'commands.global.update',
path: `/dashboard/${guildId}/commands`,
before,
after
});
return NextResponse.json(after);
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -0,0 +1,41 @@
import { DashboardMessageSendSchema } from '@nexumi/shared';
import { type NextRequest, NextResponse } from 'next/server';
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
import { writeDashboardAudit } from '@/lib/audit';
import { MessageSendTimeoutError, sendDashboardMessageFromWebui } from '@/lib/module-configs/messages';
import { prisma } from '@/lib/prisma';
interface RouteParams {
params: Promise<{ guildId: string }>;
}
export async function POST(request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
const session = await requireGuildAccess(guildId);
const body = await request.json();
const input = DashboardMessageSendSchema.parse(body);
const result = await sendDashboardMessageFromWebui(guildId, session.user.id, input);
await writeDashboardAudit(prisma, {
guildId,
actorUserId: session.user.id,
action: 'messages.send',
path: `/dashboard/${guildId}/messages`,
after: {
channelId: result.channelId,
mode: input.mode,
messageId: result.messageId,
bindingId: result.bindingId
}
});
return NextResponse.json(result, { status: 201 });
} catch (error) {
if (error instanceof MessageSendTimeoutError) {
return NextResponse.json({ error: error.message }, { status: 504 });
}
return toApiErrorResponse(error);
}
}

View File

@@ -0,0 +1,18 @@
import { type NextRequest, NextResponse } from 'next/server';
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
import { listGuildRolesForDashboard } from '@/lib/discord-guild-resources';
interface RouteParams {
params: Promise<{ guildId: string }>;
}
export async function GET(_request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
await requireGuildAccess(guildId);
const roles = await listGuildRolesForDashboard(guildId);
return NextResponse.json(roles);
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -13,8 +13,8 @@ export async function GET(_request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
await requireGuildAccess(guildId);
const config = await getVerificationDashboard(guildId);
return NextResponse.json(config);
const payload = await getVerificationDashboard(guildId);
return NextResponse.json(payload);
} catch (error) {
return toApiErrorResponse(error);
}
@@ -28,15 +28,19 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
const patch = VerificationConfigDashboardPatchSchema.parse(body);
const before = await getVerificationDashboard(guildId);
const after = await updateVerificationDashboard(guildId, patch);
const afterConfig = await updateVerificationDashboard(guildId, patch);
const after = {
config: afterConfig,
availableCaptchaProviders: before.availableCaptchaProviders
};
await writeDashboardAudit(prisma, {
guildId,
actorUserId: session.user.id,
action: 'verification.update',
path: `/dashboard/${guildId}/verification`,
before,
after
before: before.config,
after: afterConfig
});
return NextResponse.json(after);

View File

@@ -1,7 +1,9 @@
import { WarningListQuerySchema } from '@nexumi/shared';
import { type NextRequest, NextResponse } from 'next/server';
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
import { listWarnings } from '@/lib/module-configs/warnings';
import { writeDashboardAudit } from '@/lib/audit';
import { deleteWarning, listWarnings } from '@/lib/module-configs/warnings';
import { prisma } from '@/lib/prisma';
interface RouteParams {
params: Promise<{ guildId: string }>;
@@ -11,13 +13,36 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
await requireGuildAccess(guildId);
const searchParams = request.nextUrl.searchParams;
const query = WarningListQuerySchema.parse({
userId: searchParams.get('userId') ?? undefined
});
const query = WarningListQuerySchema.parse(Object.fromEntries(request.nextUrl.searchParams));
const warnings = await listWarnings(guildId, query);
return NextResponse.json({ warnings });
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function DELETE(request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
const session = await requireGuildAccess(guildId);
const id = request.nextUrl.searchParams.get('id');
if (!id) {
return NextResponse.json({ error: 'Missing warning id' }, { status: 400 });
}
const ok = await deleteWarning(guildId, id);
if (!ok) {
return NextResponse.json({ error: 'Warning not found' }, { status: 404 });
}
await writeDashboardAudit(prisma, {
guildId,
actorUserId: session.user.id,
action: 'warning.delete',
path: `/dashboard/${guildId}/moderation`,
before: { id },
after: { deleted: true }
});
return NextResponse.json({ ok: true });
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -1,5 +1,8 @@
import { Suspense } from 'react';
import { CommandsManager } from '@/components/modules/commands-manager';
import { listGuildChannelsForDashboard } from '@/lib/discord-guild-resources';
import { getLocale, t } from '@/lib/i18n';
import { getCommandGlobalRules } from '@/lib/module-configs/command-global-rules';
import { listCommandOverridesDashboard } from '@/lib/module-configs/commands';
interface CommandsPageProps {
@@ -8,7 +11,12 @@ interface CommandsPageProps {
export default async function CommandsPage({ params }: CommandsPageProps) {
const { guildId } = await params;
const [locale, commands] = await Promise.all([getLocale(), listCommandOverridesDashboard(guildId)]);
const [locale, commands, globalRules, channels] = await Promise.all([
getLocale(),
listCommandOverridesDashboard(guildId),
getCommandGlobalRules(guildId),
listGuildChannelsForDashboard(guildId).catch(() => [])
]);
return (
<div className="space-y-6">
@@ -16,7 +24,14 @@ export default async function CommandsPage({ params }: CommandsPageProps) {
<h1 className="text-2xl font-semibold">{t(locale, 'modulePages.commands.title')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'modulePages.commands.description')}</p>
</div>
<CommandsManager guildId={guildId} initialCommands={commands} />
<Suspense fallback={null}>
<CommandsManager
guildId={guildId}
initialCommands={commands}
initialGlobalRules={globalRules}
channels={channels}
/>
</Suspense>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { Skeleton } from '@/components/ui/skeleton';
export default function MessagesLoading() {
return (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-80" />
</div>
<Skeleton className="h-96 rounded-lg" />
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { MessagesManager } from '@/components/modules/messages-manager';
import { getLocale, t } from '@/lib/i18n';
interface MessagesPageProps {
params: Promise<{ guildId: string }>;
}
export default async function MessagesPage({ params }: MessagesPageProps) {
const { guildId } = await params;
const locale = await getLocale();
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">{t(locale, 'modules.messages.label')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'modules.messages.description')}</p>
</div>
<MessagesManager guildId={guildId} />
</div>
);
}

View File

@@ -1,9 +1,11 @@
import { CaseListQuerySchema } from '@nexumi/shared';
import { CaseListQuerySchema, WarningListQuerySchema } from '@nexumi/shared';
import { CasesTable } from '@/components/modules/cases-table';
import { ModerationForm } from '@/components/modules/moderation-form';
import { WarningsTable } from '@/components/modules/warnings-table';
import { getLocale, t } from '@/lib/i18n';
import { listCases } from '@/lib/module-configs/cases';
import { getModerationDashboard } from '@/lib/module-configs/moderation';
import { listWarnings } from '@/lib/module-configs/warnings';
interface ModerationPageProps {
params: Promise<{ guildId: string }>;
@@ -11,10 +13,11 @@ interface ModerationPageProps {
export default async function ModerationPage({ params }: ModerationPageProps) {
const { guildId } = await params;
const [locale, moderation, cases] = await Promise.all([
const [locale, moderation, cases, warnings] = await Promise.all([
getLocale(),
getModerationDashboard(guildId),
listCases(guildId, CaseListQuerySchema.parse({}))
listCases(guildId, CaseListQuerySchema.parse({})),
listWarnings(guildId, WarningListQuerySchema.parse({}))
]);
return (
@@ -24,6 +27,7 @@ export default async function ModerationPage({ params }: ModerationPageProps) {
<p className="text-sm text-muted-foreground">{t(locale, 'modules.moderation.description')}</p>
</div>
<ModerationForm guildId={guildId} initialValue={moderation} />
<WarningsTable guildId={guildId} initialWarnings={warnings} />
<CasesTable guildId={guildId} initialCases={cases} />
</div>
);

View File

@@ -1,7 +1,9 @@
import { DASHBOARD_MODULES } from '@nexumi/shared';
import { Activity, MessageSquare, Mic, Users } from 'lucide-react';
import Link from 'next/link';
import { getActivitySummary } from '@/lib/activity';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { listRecentDashboardAudit } from '@/lib/audit';
import { getLocale, t } from '@/lib/i18n';
import { getModuleStatuses } from '@/lib/modules';
@@ -17,6 +19,10 @@ function formatDate(iso: string, locale: string): string {
});
}
function moduleHref(moduleId: string): string {
return DASHBOARD_MODULES.find((module) => module.id === moduleId)?.href ?? moduleId;
}
export default async function GuildOverviewPage({ params }: OverviewPageProps) {
const { guildId } = await params;
const locale = await getLocale();
@@ -26,55 +32,108 @@ export default async function GuildOverviewPage({ params }: OverviewPageProps) {
getActivitySummary(guildId)
]);
const enabledCount = modules.filter((module) => module.enabled).length;
const numberLocale = locale === 'de' ? 'de-DE' : 'en-US';
return (
<div className="space-y-8">
<h1 className="text-2xl font-semibold">{t(locale, 'dashboard.overview.title')}</h1>
<div className="space-y-10">
<div className="space-y-2">
<h1 className="text-3xl font-semibold tracking-tight">{t(locale, 'dashboard.overview.title')}</h1>
<p className="max-w-2xl text-sm text-muted-foreground">
{t(locale, 'dashboard.overview.activityHint')}
</p>
</div>
<section className="space-y-4">
<h2 className="text-lg font-medium">{t(locale, 'dashboard.overview.activityTitle')}</h2>
<div className="grid gap-3 sm:grid-cols-3">
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">{t(locale, 'dashboard.overview.activityMessages')}</p>
<p className="text-2xl font-semibold">{activitySummary.messageCount.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}</p>
<div className="flex items-center gap-2">
<Activity className="size-4 text-primary" />
<h2 className="text-lg font-medium">{t(locale, 'dashboard.overview.activityTitle')}</h2>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<Card className="border-border/80 bg-card/80">
<CardContent className="flex items-start gap-3 p-5">
<div className="rounded-md bg-primary/10 p-2 text-primary">
<MessageSquare className="size-4" />
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t(locale, 'dashboard.overview.activityMessages')}
</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">
{activitySummary.messageCount.toLocaleString(numberLocale)}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">{t(locale, 'dashboard.overview.activityVoiceMinutes')}</p>
<p className="text-2xl font-semibold">{activitySummary.voiceMinutes.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}</p>
<Card className="border-border/80 bg-card/80">
<CardContent className="flex items-start gap-3 p-5">
<div className="rounded-md bg-primary/10 p-2 text-primary">
<Mic className="size-4" />
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t(locale, 'dashboard.overview.activityVoiceMinutes')}
</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">
{activitySummary.voiceMinutes.toLocaleString(numberLocale)}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">{t(locale, 'dashboard.overview.activityActiveUsers')}</p>
<p className="text-2xl font-semibold">{activitySummary.activeUserCount.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}</p>
<Card className="border-border/80 bg-card/80">
<CardContent className="flex items-start gap-3 p-5">
<div className="rounded-md bg-primary/10 p-2 text-primary">
<Users className="size-4" />
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t(locale, 'dashboard.overview.activityActiveUsers')}
</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">
{activitySummary.activeUserCount.toLocaleString(numberLocale)}
</p>
</div>
</CardContent>
</Card>
</div>
<p className="text-xs text-muted-foreground">{t(locale, 'dashboard.overview.activityHint')}</p>
</section>
<section className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium">{t(locale, 'dashboard.overview.moduleStatusTitle')}</h2>
<Link href={`/dashboard/${guildId}/modules`} className="text-sm text-primary hover:underline">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-lg font-medium">{t(locale, 'dashboard.overview.moduleStatusTitle')}</h2>
{modules.length > 0 ? (
<p className="text-sm text-muted-foreground">
{enabledCount}/{modules.length} {t(locale, 'common.enabled').toLowerCase()}
</p>
) : null}
</div>
<Link
href={`/dashboard/${guildId}/modules`}
className="text-sm font-medium text-primary hover:underline"
>
{t(locale, 'dashboard.overview.viewAllModules')}
</Link>
</div>
{modules.length === 0 ? (
<p className="text-sm text-muted-foreground">{t(locale, 'dashboard.overview.moduleStatusEmpty')}</p>
<Card className="border-dashed">
<CardContent className="p-8 text-center text-sm text-muted-foreground">
{t(locale, 'dashboard.overview.moduleStatusEmpty')}
</CardContent>
</Card>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{modules.map((module) => (
<Card key={module.id}>
<CardContent className="flex items-center justify-between p-4">
<span className="font-medium">{t(locale, `modules.${module.id}.label`)}</span>
<Badge variant={module.enabled ? 'success' : 'secondary'}>
{t(locale, module.enabled ? 'common.enabled' : 'common.disabled')}
</Badge>
</CardContent>
</Card>
<Link key={module.id} href={`/dashboard/${guildId}/${moduleHref(module.id)}`}>
<Card className="h-full border-border/80 transition-colors hover:border-primary/40 hover:bg-accent/30">
<CardContent className="flex items-center justify-between gap-3 p-4">
<span className="font-medium">{t(locale, `modules.${module.id}.label`)}</span>
<Badge variant={module.enabled ? 'success' : 'secondary'}>
{t(locale, module.enabled ? 'common.enabled' : 'common.disabled')}
</Badge>
</CardContent>
</Card>
</Link>
))}
</div>
)}
@@ -82,22 +141,23 @@ export default async function GuildOverviewPage({ params }: OverviewPageProps) {
<section className="space-y-4">
<h2 className="text-lg font-medium">{t(locale, 'dashboard.overview.recentAuditTitle')}</h2>
<Card>
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">
{t(locale, 'dashboard.overview.recentAuditTitle')}
</CardTitle>
<Card className="border-border/80">
<CardHeader className="pb-2">
<CardTitle className="text-base">{t(locale, 'dashboard.overview.recentAuditTitle')}</CardTitle>
<CardDescription>{t(locale, 'dashboard.overview.auditPath')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<CardContent>
{auditEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">{t(locale, 'dashboard.overview.auditEmpty')}</p>
<p className="py-6 text-center text-sm text-muted-foreground">
{t(locale, 'dashboard.overview.auditEmpty')}
</p>
) : (
<ul className="divide-y divide-border">
{auditEntries.map((entry) => (
<li key={entry.id} className="flex items-center justify-between gap-4 py-2 text-sm">
<li key={entry.id} className="flex items-center justify-between gap-4 py-3 text-sm">
<div className="min-w-0">
<p className="truncate font-medium">{entry.action}</p>
{entry.path && <p className="truncate text-xs text-muted-foreground">{entry.path}</p>}
{entry.path ? <p className="truncate text-xs text-muted-foreground">{entry.path}</p> : null}
</div>
<span className="whitespace-nowrap text-xs text-muted-foreground">
{formatDate(entry.createdAt, locale)}

View File

@@ -8,7 +8,7 @@ interface VerificationPageProps {
export default async function VerificationPage({ params }: VerificationPageProps) {
const { guildId } = await params;
const [locale, config] = await Promise.all([getLocale(), getVerificationDashboard(guildId)]);
const [locale, payload] = await Promise.all([getLocale(), getVerificationDashboard(guildId)]);
return (
<div className="space-y-6">
@@ -16,7 +16,11 @@ export default async function VerificationPage({ params }: VerificationPageProps
<h1 className="text-2xl font-semibold">{t(locale, 'modules.verification.label')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'modules.verification.description')}</p>
</div>
<VerificationForm guildId={guildId} initialValue={config} />
<VerificationForm
guildId={guildId}
initialValue={payload.config}
availableCaptchaProviders={payload.availableCaptchaProviders}
/>
</div>
);
}

View File

@@ -1,33 +1,13 @@
import type { DashboardGuild } from '@nexumi/shared';
import Link from 'next/link';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { DashboardGuildGrid } from '@/components/dashboard/dashboard-guild-grid';
import { requireAuthOrRedirect } from '@/lib/auth';
import { env } from '@/lib/env';
import { getManageableGuilds } from '@/lib/guilds';
import { getLocale, t } from '@/lib/i18n';
function guildIconUrl(guild: Pick<DashboardGuild, 'id' | 'icon'>): string | undefined {
return guild.icon ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=64` : undefined;
}
function initials(name: string): string {
return (
name
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join('') || '?'
);
}
function buildInviteUrl(guildId: string): string {
const url = new URL('https://discord.com/api/oauth2/authorize');
url.searchParams.set('client_id', env.BOT_CLIENT_ID);
url.searchParams.set('permissions', '8');
url.searchParams.set('permissions', '4786708802825463');
url.searchParams.set('scope', 'bot applications.commands');
url.searchParams.set('guild_id', guildId);
return url.toString();
@@ -38,6 +18,11 @@ export default async function DashboardGuildListPage() {
const guilds = await getManageableGuilds(session);
const locale = await getLocale();
const inviteUrls: Record<string, string> = {};
for (const guild of guilds) {
inviteUrls[guild.id] = buildInviteUrl(guild.id);
}
return (
<main className="flex min-h-screen justify-center px-6 py-10">
<div className="w-full max-w-[1200px] space-y-6">
@@ -46,47 +31,16 @@ export default async function DashboardGuildListPage() {
<p className="text-sm text-muted-foreground">{t(locale, 'dashboard.guildList.subtitle')}</p>
</div>
{guilds.length === 0 ? (
<Card>
<CardContent className="py-10 text-center text-sm text-muted-foreground">
{t(locale, 'dashboard.guildList.empty')}
</CardContent>
</Card>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{guilds.map((guild) => (
<Card key={guild.id}>
<CardContent className="flex flex-col gap-4 p-5">
<div className="flex items-center gap-3">
<Avatar className="size-10">
<AvatarImage src={guildIconUrl(guild)} alt={guild.name} />
<AvatarFallback>{initials(guild.name)}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate font-medium">{guild.name}</p>
{!guild.botPresent && (
<Badge variant="outline" className="mt-1">
{t(locale, 'dashboard.guildList.botMissing')}
</Badge>
)}
</div>
</div>
{guild.botPresent ? (
<Button asChild>
<Link href={`/dashboard/${guild.id}`}>{t(locale, 'dashboard.guildList.manage')}</Link>
</Button>
) : (
<Button asChild variant="outline">
<a href={buildInviteUrl(guild.id)} target="_blank" rel="noreferrer">
{t(locale, 'dashboard.guildList.invite')}
</a>
</Button>
)}
</CardContent>
</Card>
))}
</div>
)}
<DashboardGuildGrid
guilds={guilds}
inviteUrls={inviteUrls}
labels={{
empty: t(locale, 'dashboard.guildList.empty'),
botMissing: t(locale, 'dashboard.guildList.botMissing'),
manage: t(locale, 'dashboard.guildList.manage'),
invite: t(locale, 'dashboard.guildList.invite')
}}
/>
</div>
</main>
);

View File

@@ -81,4 +81,11 @@ body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans), ui-sans-serif, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
/* Subtle page depth without full-bleed gradients (SPEC). */
.dark body {
background-image: radial-gradient(ellipse 80% 50% at 50% -20%, rgba(99, 102, 241, 0.08), transparent);
background-attachment: fixed;
}

View File

@@ -1,46 +1,13 @@
import { LegalDocument } from '@/components/site/legal-document';
import { LegalShell } from '@/components/site/legal-shell';
import { env } from '@/lib/env';
import { getLocale, t } from '@/lib/i18n';
export default async function ImpressumPage() {
const locale = await getLocale();
const configured = Boolean(env.LEGAL_OPERATOR_NAME && env.LEGAL_OPERATOR_ADDRESS && env.LEGAL_OPERATOR_EMAIL);
return (
<LegalShell title={t(locale, 'legal.impressum.title')}>
<p className="text-sm text-muted-foreground">{t(locale, 'legal.impressum.intro')}</p>
{configured ? (
<dl className="space-y-3 text-sm">
<div>
<dt className="font-medium">{t(locale, 'legal.impressum.name')}</dt>
<dd className="text-muted-foreground">{env.LEGAL_OPERATOR_NAME}</dd>
</div>
<div>
<dt className="font-medium">{t(locale, 'legal.impressum.address')}</dt>
<dd className="whitespace-pre-wrap text-muted-foreground">{env.LEGAL_OPERATOR_ADDRESS}</dd>
</div>
<div>
<dt className="font-medium">{t(locale, 'legal.impressum.email')}</dt>
<dd className="text-muted-foreground">
<a className="text-primary hover:underline" href={`mailto:${env.LEGAL_OPERATOR_EMAIL}`}>
{env.LEGAL_OPERATOR_EMAIL}
</a>
</dd>
</div>
{env.LEGAL_OPERATOR_PHONE ? (
<div>
<dt className="font-medium">{t(locale, 'legal.impressum.phone')}</dt>
<dd className="text-muted-foreground">{env.LEGAL_OPERATOR_PHONE}</dd>
</div>
) : null}
</dl>
) : (
<p className="rounded-md border border-border bg-muted/40 px-4 py-3 text-sm">
{t(locale, 'legal.impressum.notConfigured')}
</p>
)}
<p className="text-sm text-muted-foreground">{t(locale, 'legal.impressum.p1')}</p>
<p className="text-sm text-muted-foreground">{t(locale, 'legal.impressum.p2')}</p>
<LegalDocument locale={locale} page="impressum" />
</LegalShell>
);
}

View File

@@ -1,9 +1,9 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import type { ReactNode } from 'react';
import { Toaster } from 'sonner';
import { LocaleProvider } from '@/components/locale-provider';
import { ThemeProvider } from '@/components/layout/theme-provider';
import { Toaster } from '@/components/ui/toaster';
import { getLocale, MESSAGES } from '@/lib/i18n';
import './globals.css';
@@ -11,7 +11,7 @@ const inter = Inter({ subsets: ['latin'], variable: '--font-inter', display: 'sw
export const metadata: Metadata = {
title: 'Nexumi — Discord Bot',
description: 'Self-hosted Discord bot with dashboard, moderation, and community modules'
description: 'Discord bot with dashboard, moderation, and community modules'
};
export default async function RootLayout({ children }: { children: ReactNode }) {
@@ -23,7 +23,7 @@ export default async function RootLayout({ children }: { children: ReactNode })
<ThemeProvider>
<LocaleProvider locale={locale} messages={MESSAGES[locale]}>
{children}
<Toaster richColors position="top-right" />
<Toaster />
</LocaleProvider>
</ThemeProvider>
</body>

View File

@@ -1,52 +1,12 @@
import { redirect } from 'next/navigation';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { getLocale, t } from '@/lib/i18n';
import { getSession } from '@/lib/session';
interface LoginPageProps {
searchParams: Promise<{ error?: string }>;
}
const KNOWN_ERRORS = ['access_denied', 'invalid_request', 'invalid_state', 'oauth_failed'] as const;
/** Alias for `/` so OAuth error redirects to `/login?error=…` keep working. */
export default async function LoginPage({ searchParams }: LoginPageProps) {
const session = await getSession();
if (session) {
redirect('/dashboard');
}
const { error } = await searchParams;
const locale = await getLocale();
const errorMessage =
error && (KNOWN_ERRORS as readonly string[]).includes(error)
? t(locale, `login.errors.${error}`)
: undefined;
return (
<main className="flex min-h-screen items-center justify-center px-4">
<Card className="w-full max-w-sm">
<CardHeader className="items-center text-center">
<div className="mb-2 flex size-12 items-center justify-center rounded-xl bg-primary text-lg font-semibold text-primary-foreground">
N
</div>
<CardTitle className="text-xl">{t(locale, 'login.title')}</CardTitle>
<CardDescription>{t(locale, 'login.subtitle')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{errorMessage && (
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMessage}
</p>
)}
<a
href="/api/auth/login"
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-md bg-primary text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
{t(locale, 'login.cta')}
</a>
<p className="text-center text-xs text-muted-foreground">{t(locale, 'login.footer')}</p>
</CardContent>
</Card>
</main>
);
const target = error ? `/?error=${encodeURIComponent(error)}` : '/';
redirect(target);
}

View File

@@ -1,111 +1,17 @@
import { DASHBOARD_MODULES, type DashboardModuleGroup } from '@nexumi/shared';
import Image from 'next/image';
import Link from 'next/link';
import { SiteShell } from '@/components/site/site-shell';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { buildBotInviteUrl, env } from '@/lib/env';
import { getLocale, t } from '@/lib/i18n';
import { getPublicStats } from '@/lib/public-stats';
import { redirect } from 'next/navigation';
import { LoginCard } from '@/components/auth/login-card';
import { getSession } from '@/lib/session';
const GROUP_ORDER: DashboardModuleGroup[] = [
'moderation',
'engagement',
'community',
'utility',
'integrations'
];
export default async function LandingPage() {
const [locale, stats, session] = await Promise.all([getLocale(), getPublicStats(), getSession()]);
const inviteUrl = buildBotInviteUrl();
const isLoggedIn = Boolean(session);
return (
<SiteShell>
<section className="border-b border-border">
<div className="mx-auto flex w-full max-w-[1200px] flex-col gap-8 px-6 py-16 sm:py-20">
<div className="flex items-center gap-3">
<Image src="/nexumi-logo.svg" alt="" width={48} height={48} priority />
<h1 className="text-4xl font-semibold tracking-tight sm:text-5xl">Nexumi</h1>
</div>
<p className="max-w-2xl text-lg text-muted-foreground">{t(locale, 'landing.hero.subtitle')}</p>
<div className="flex flex-wrap gap-3">
<Button asChild size="lg">
<a href={inviteUrl} target="_blank" rel="noreferrer">
{t(locale, 'landing.hero.invite')}
</a>
</Button>
<Button asChild size="lg" variant="secondary">
<Link href={isLoggedIn ? '/dashboard' : '/login'}>
{isLoggedIn ? t(locale, 'landing.hero.dashboard') : t(locale, 'landing.hero.login')}
</Link>
</Button>
{env.SUPPORT_SERVER_URL ? (
<Button asChild size="lg" variant="outline">
<a href={env.SUPPORT_SERVER_URL} target="_blank" rel="noreferrer">
{t(locale, 'landing.hero.support')}
</a>
</Button>
) : null}
</div>
</div>
</section>
<section className="border-b border-border">
<div className="mx-auto grid w-full max-w-[1200px] gap-4 px-6 py-12 sm:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t(locale, 'landing.stats.guilds')}
</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-semibold">{stats.guildCount.toLocaleString(locale)}</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t(locale, 'landing.stats.users')}
</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-semibold">
{stats.approximateUserCount.toLocaleString(locale)}
</CardContent>
</Card>
</div>
</section>
<section>
<div className="mx-auto w-full max-w-[1200px] space-y-8 px-6 py-12">
<div>
<h2 className="text-2xl font-semibold">{t(locale, 'landing.features.title')}</h2>
<p className="mt-2 text-muted-foreground">{t(locale, 'landing.features.subtitle')}</p>
</div>
{GROUP_ORDER.map((group) => {
const modules = DASHBOARD_MODULES.filter((module) => module.group === group);
return (
<div key={group} className="space-y-3">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
{t(locale, `moduleGroups.${group}`)}
</h3>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{modules.map((module) => (
<Card key={module.id}>
<CardHeader className="pb-2">
<CardTitle className="text-base">{t(locale, `modules.${module.id}.label`)}</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
{t(locale, `modules.${module.id}.description`)}
</CardContent>
</Card>
))}
</div>
</div>
);
})}
</div>
</section>
</SiteShell>
);
interface HomePageProps {
searchParams: Promise<{ error?: string }>;
}
export default async function HomePage({ searchParams }: HomePageProps) {
const session = await getSession();
if (session) {
redirect('/dashboard');
}
const { error } = await searchParams;
return <LoginCard error={error} />;
}

View File

@@ -1,3 +1,4 @@
import { LegalDocument } from '@/components/site/legal-document';
import { LegalShell } from '@/components/site/legal-shell';
import { getLocale, t } from '@/lib/i18n';
@@ -6,12 +7,7 @@ export default async function PrivacyPage() {
return (
<LegalShell title={t(locale, 'legal.privacy.title')}>
<p className="text-sm text-amber-600 dark:text-amber-400">{t(locale, 'legal.scaffoldNotice')}</p>
{['p1', 'p2', 'p3', 'p4', 'p5'].map((key) => (
<p key={key} className="text-sm text-muted-foreground">
{t(locale, `legal.privacy.${key}`)}
</p>
))}
<LegalDocument locale={locale} page="privacy" />
</LegalShell>
);
}

View File

@@ -1,3 +1,4 @@
import { LegalDocument } from '@/components/site/legal-document';
import { LegalShell } from '@/components/site/legal-shell';
import { getLocale, t } from '@/lib/i18n';
@@ -6,12 +7,7 @@ export default async function TermsPage() {
return (
<LegalShell title={t(locale, 'legal.terms.title')}>
<p className="text-sm text-amber-600 dark:text-amber-400">{t(locale, 'legal.scaffoldNotice')}</p>
{['p1', 'p2', 'p3', 'p4'].map((key) => (
<p key={key} className="text-sm text-muted-foreground">
{t(locale, `legal.terms.${key}`)}
</p>
))}
<LegalDocument locale={locale} page="terms" />
</LegalShell>
);
}

View File

@@ -0,0 +1,243 @@
import { NextResponse } from 'next/server';
import type { CaptchaProvider } from '@nexumi/shared';
import {
deleteCaptchaChallenge,
getCaptchaChallenge,
getCaptchaPublicConfig,
getClientIp,
hashCaptchaAnswer,
hashClientSignal,
verifyProviderToken
} from '@/lib/captcha';
import { getVerificationQueue } from '@/lib/queues';
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
const PAGE_STYLES = `
body { font-family: system-ui, sans-serif; background:#0b1220; color:#f8fafc; display:flex; justify-content:center; align-items:center; min-height:100vh; margin:0; }
.card { background:#111827; padding:2rem; border-radius:12px; width:min(440px, 92vw); border:1px solid #1f2937; }
h1 { margin:0 0 0.5rem; font-size:1.35rem; }
p { margin:0 0 1rem; color:#cbd5e1; line-height:1.45; }
.error { color:#f87171; margin-bottom:0.75rem; }
input, button { width:100%; padding:0.75rem; margin-top:0.75rem; border-radius:8px; border:1px solid #374151; background:#0b1220; color:#f8fafc; box-sizing:border-box; }
button { background:#4f46e5; border:none; cursor:pointer; font-weight:600; }
.widget { display:flex; justify-content:center; margin:1rem 0; min-height:78px; }
`;
function renderShell(body: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Nexumi Verification</title>
<style>${PAGE_STYLES}</style>
</head>
<body>${body}</body>
</html>`;
}
function renderMathPage(token: string, question: string, error?: string): string {
const errorBlock = error ? `<p class="error">${escapeHtml(error)}</p>` : '';
return renderShell(`
<form class="card" method="POST" action="/verify/captcha">
<h1>Nexumi Verification</h1>
<p>Solve: <strong>${escapeHtml(question)} = ?</strong></p>
${errorBlock}
<input type="hidden" name="token" value="${escapeHtml(token)}" />
<input type="hidden" name="provider" value="MATH" />
<input type="number" name="answer" required autofocus />
<button type="submit">Verify</button>
</form>`);
}
function renderProviderPage(
token: string,
provider: CaptchaProvider,
siteKey: string,
error?: string
): string {
const errorBlock = error ? `<p class="error">${escapeHtml(error)}</p>` : '';
const labels: Record<string, string> = {
RECAPTCHA_V2: 'Complete the Google reCAPTCHA below.',
RECAPTCHA_V3: 'Checking with Google reCAPTCHA…',
HCAPTCHA: 'Complete the hCaptcha below.',
TURNSTILE: 'Complete the Cloudflare Turnstile below.'
};
if (provider === 'RECAPTCHA_V3') {
return renderShell(`
<form class="card" id="verify-form" method="POST" action="/verify/captcha">
<h1>Nexumi Verification</h1>
<p>${escapeHtml(labels.RECAPTCHA_V3)}</p>
${errorBlock}
<input type="hidden" name="token" value="${escapeHtml(token)}" />
<input type="hidden" name="provider" value="RECAPTCHA_V3" />
<input type="hidden" name="captcha_response" id="captcha_response" />
<button type="submit" id="submit-btn" disabled>Verifying…</button>
</form>
<script src="https://www.google.com/recaptcha/api.js?render=${escapeHtml(siteKey)}"></script>
<script>
grecaptcha.ready(function () {
grecaptcha.execute('${escapeHtml(siteKey)}', { action: 'verify' }).then(function (token) {
document.getElementById('captcha_response').value = token;
document.getElementById('verify-form').submit();
});
});
</script>`);
}
let widget = '';
let scripts = '';
if (provider === 'RECAPTCHA_V2') {
widget = `<div class="g-recaptcha" data-sitekey="${escapeHtml(siteKey)}"></div>`;
scripts = `<script src="https://www.google.com/recaptcha/api.js" async defer></script>`;
} else if (provider === 'HCAPTCHA') {
widget = `<div class="h-captcha" data-sitekey="${escapeHtml(siteKey)}"></div>`;
scripts = `<script src="https://js.hcaptcha.com/1/api.js" async defer></script>`;
} else if (provider === 'TURNSTILE') {
widget = `<div class="cf-turnstile" data-sitekey="${escapeHtml(siteKey)}"></div>`;
scripts = `<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>`;
}
return renderShell(`
<form class="card" method="POST" action="/verify/captcha">
<h1>Nexumi Verification</h1>
<p>${escapeHtml(labels[provider] ?? 'Complete the captcha below.')}</p>
${errorBlock}
<input type="hidden" name="token" value="${escapeHtml(token)}" />
<input type="hidden" name="provider" value="${escapeHtml(provider)}" />
<div class="widget">${widget}</div>
<button type="submit">Verify</button>
</form>
${scripts}`);
}
function renderMisconfigured(provider: string): string {
return renderShell(`
<div class="card">
<h1>Captcha unavailable</h1>
<p>Provider <strong>${escapeHtml(provider)}</strong> is not configured on this server. Ask an admin to set the keys in <code>.env</code>, or switch the guild to Math captcha.</p>
</div>`);
}
function renderDonePage(): string {
return renderShell(`
<div class="card" style="text-align:center">
<h1>Verified</h1>
<p>You can return to Discord.</p>
</div>`);
}
function html(body: string, status = 200): NextResponse {
return new NextResponse(body, {
status,
headers: { 'Content-Type': 'text/html; charset=utf-8' }
});
}
function extractProviderResponse(form: FormData, provider: CaptchaProvider): string {
const direct = String(form.get('captcha_response') ?? '').trim();
if (direct) {
return direct;
}
if (provider === 'RECAPTCHA_V2' || provider === 'RECAPTCHA_V3') {
return String(form.get('g-recaptcha-response') ?? '').trim();
}
if (provider === 'HCAPTCHA') {
return String(form.get('h-captcha-response') ?? '').trim();
}
if (provider === 'TURNSTILE') {
return String(form.get('cf-turnstile-response') ?? '').trim();
}
return '';
}
function renderChallengePage(
token: string,
provider: CaptchaProvider,
question: string | undefined,
error?: string
): NextResponse {
if (provider === 'MATH') {
return html(renderMathPage(token, question ?? '?', error));
}
const publicConfig = getCaptchaPublicConfig(provider);
if (!publicConfig.siteKey) {
return html(renderMisconfigured(provider), 503);
}
return html(renderProviderPage(token, provider, publicConfig.siteKey, error));
}
export async function GET(request: Request): Promise<NextResponse> {
const token = new URL(request.url).searchParams.get('token');
if (!token) {
return new NextResponse('Missing token', { status: 400 });
}
const challenge = await getCaptchaChallenge(token);
if (!challenge) {
return new NextResponse('Captcha expired', { status: 404 });
}
return renderChallengePage(token, challenge.provider, challenge.question);
}
export async function POST(request: Request): Promise<NextResponse> {
const form = await request.formData();
const token = String(form.get('token') ?? '');
if (!token) {
return new NextResponse('Missing fields', { status: 400 });
}
const challenge = await getCaptchaChallenge(token);
if (!challenge) {
return new NextResponse('Captcha expired', { status: 404 });
}
const provider = challenge.provider;
const clientIp = getClientIp(request);
const userAgent = request.headers.get('user-agent') ?? '';
if (provider === 'MATH') {
const answer = String(form.get('answer') ?? '');
if (!answer || !challenge.answerHash || hashCaptchaAnswer(answer) !== challenge.answerHash) {
return renderChallengePage(token, provider, challenge.question, 'Wrong answer. Try again.');
}
} else {
const responseToken = extractProviderResponse(form, provider);
const verified = await verifyProviderToken(provider, responseToken, clientIp);
if (!verified.ok) {
const message =
verified.error === 'score_too_low'
? 'reCAPTCHA score too low. Try again.'
: verified.error === 'provider_not_configured'
? 'Captcha provider is not configured.'
: 'Captcha failed. Try again.';
return renderChallengePage(token, provider, challenge.question, message);
}
}
await deleteCaptchaChallenge(token);
const ipHash = clientIp ? hashClientSignal(clientIp) : null;
const userAgentHash = userAgent ? hashClientSignal(userAgent) : null;
await getVerificationQueue().add(
'verificationComplete',
{
guildId: challenge.guildId,
userId: challenge.userId,
ipHash,
userAgentHash,
captchaProvider: provider
},
{ removeOnComplete: 1000, removeOnFail: 500 }
);
return html(renderDonePage());
}

View File

@@ -0,0 +1,117 @@
/**
* Archived marketing landing page (Phase 8).
* Replaced by the login screen at `/` for the dashboard.nexumi.de deploy.
* A separate public landing will live on nexumi.de later.
* This file is not an App Router page and is not served.
*/
import { DASHBOARD_MODULES, type DashboardModuleGroup } from '@nexumi/shared';
import Image from 'next/image';
import Link from 'next/link';
import { SiteShell } from '@/components/site/site-shell';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { buildBotInviteUrl, env } from '@/lib/env';
import { getLocale, t } from '@/lib/i18n';
import { getPublicStats } from '@/lib/public-stats';
import { getSession } from '@/lib/session';
const GROUP_ORDER: DashboardModuleGroup[] = [
'moderation',
'engagement',
'community',
'utility',
'integrations'
];
export default async function ArchivedLandingPage() {
const [locale, stats, session] = await Promise.all([getLocale(), getPublicStats(), getSession()]);
const inviteUrl = buildBotInviteUrl();
const isLoggedIn = Boolean(session);
return (
<SiteShell>
<section className="border-b border-border">
<div className="mx-auto flex w-full max-w-[1200px] flex-col gap-8 px-6 py-16 sm:py-20">
<div className="flex items-center gap-3">
<Image src="/nexumi-logo.svg" alt="" width={48} height={48} priority />
<h1 className="text-4xl font-semibold tracking-tight sm:text-5xl">Nexumi</h1>
</div>
<p className="max-w-2xl text-lg text-muted-foreground">{t(locale, 'landing.hero.subtitle')}</p>
<div className="flex flex-wrap gap-3">
<Button asChild size="lg">
<a href={inviteUrl} target="_blank" rel="noreferrer">
{t(locale, 'landing.hero.invite')}
</a>
</Button>
<Button asChild size="lg" variant="secondary">
<Link href={isLoggedIn ? '/dashboard' : '/login'}>
{isLoggedIn ? t(locale, 'landing.hero.dashboard') : t(locale, 'landing.hero.login')}
</Link>
</Button>
{env.SUPPORT_SERVER_URL ? (
<Button asChild size="lg" variant="outline">
<a href={env.SUPPORT_SERVER_URL} target="_blank" rel="noreferrer">
{t(locale, 'landing.hero.support')}
</a>
</Button>
) : null}
</div>
</div>
</section>
<section className="border-b border-border">
<div className="mx-auto grid w-full max-w-[1200px] gap-4 px-6 py-12 sm:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t(locale, 'landing.stats.guilds')}
</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-semibold">{stats.guildCount.toLocaleString(locale)}</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t(locale, 'landing.stats.users')}
</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-semibold">
{stats.approximateUserCount.toLocaleString(locale)}
</CardContent>
</Card>
</div>
</section>
<section>
<div className="mx-auto w-full max-w-[1200px] space-y-8 px-6 py-12">
<div>
<h2 className="text-2xl font-semibold">{t(locale, 'landing.features.title')}</h2>
<p className="mt-2 text-muted-foreground">{t(locale, 'landing.features.subtitle')}</p>
</div>
{GROUP_ORDER.map((group) => {
const modules = DASHBOARD_MODULES.filter((module) => module.group === group);
return (
<div key={group} className="space-y-3">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
{t(locale, `moduleGroups.${group}`)}
</h3>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{modules.map((module) => (
<Card key={module.id}>
<CardHeader className="pb-2">
<CardTitle className="text-base">{t(locale, `modules.${module.id}.label`)}</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
{t(locale, `modules.${module.id}.description`)}
</CardContent>
</Card>
))}
</div>
</div>
);
})}
</div>
</section>
</SiteShell>
);
}

View File

@@ -0,0 +1,40 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { getLocale, t } from '@/lib/i18n';
const KNOWN_ERRORS = ['access_denied', 'invalid_request', 'invalid_state', 'oauth_failed'] as const;
export async function LoginCard({ error }: { error?: string }) {
const locale = await getLocale();
const errorMessage =
error && (KNOWN_ERRORS as readonly string[]).includes(error)
? t(locale, `login.errors.${error}`)
: undefined;
return (
<main className="flex min-h-screen items-center justify-center px-4">
<Card className="w-full max-w-sm">
<CardHeader className="items-center text-center">
<div className="mb-2 flex size-12 items-center justify-center rounded-xl bg-primary text-lg font-semibold text-primary-foreground">
N
</div>
<CardTitle className="text-xl">{t(locale, 'login.title')}</CardTitle>
<CardDescription>{t(locale, 'login.subtitle')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{errorMessage && (
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMessage}
</p>
)}
<a
href="/api/auth/login"
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-md bg-primary text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
{t(locale, 'login.cta')}
</a>
<p className="text-center text-xs text-muted-foreground">{t(locale, 'login.footer')}</p>
</CardContent>
</Card>
</main>
);
}

View File

@@ -0,0 +1,86 @@
'use client';
import type { DashboardGuild } from '@nexumi/shared';
import Link from 'next/link';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
function guildIconUrl(guild: Pick<DashboardGuild, 'id' | 'icon'>): string | undefined {
return guild.icon
? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=64`
: undefined;
}
function initials(name: string): string {
return (
name
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join('') || '?'
);
}
export function DashboardGuildGrid({
guilds,
inviteUrls,
labels
}: {
guilds: DashboardGuild[];
inviteUrls: Record<string, string>;
labels: {
empty: string;
botMissing: string;
manage: string;
invite: string;
};
}) {
if (guilds.length === 0) {
return (
<Card>
<CardContent className="py-10 text-center text-sm text-muted-foreground">
{labels.empty}
</CardContent>
</Card>
);
}
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{guilds.map((guild) => (
<Card key={guild.id}>
<CardContent className="flex flex-col gap-4 p-5">
<div className="flex items-center gap-3">
<Avatar className="size-10">
<AvatarImage src={guildIconUrl(guild)} alt={guild.name} />
<AvatarFallback>{initials(guild.name)}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate font-medium">{guild.name}</p>
{!guild.botPresent && (
<Badge variant="outline" className="mt-1">
{labels.botMissing}
</Badge>
)}
</div>
</div>
{guild.botPresent ? (
<Button asChild>
<Link href={`/dashboard/${guild.id}`}>{labels.manage}</Link>
</Button>
) : (
<Button asChild variant="outline">
<a href={inviteUrls[guild.id]} target="_blank" rel="noreferrer">
{labels.invite}
</a>
</Button>
)}
</CardContent>
</Card>
))}
</div>
);
}

View File

@@ -0,0 +1,176 @@
'use client';
import { Search, Terminal, Settings2, LayoutGrid } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslations } from '@/components/locale-provider';
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/command';
import {
OWNER_NAV_ENTRIES,
buildGuildSearchHref,
buildGuildSearchIndex,
type SearchIndexEntry,
type SearchItemKind
} from '@/lib/dashboard-search-index';
function kindIcon(kind: SearchItemKind) {
switch (kind) {
case 'command':
return Terminal;
case 'setting':
return Settings2;
default:
return LayoutGrid;
}
}
interface DashboardCommandPaletteProps {
guildId?: string;
mode?: 'guild' | 'owner';
}
export function DashboardCommandPalette({ guildId, mode = 'guild' }: DashboardCommandPaletteProps) {
const t = useTranslations();
const router = useRouter();
const [open, setOpen] = useState(false);
const entries = useMemo(() => {
if (mode === 'owner') {
return OWNER_NAV_ENTRIES;
}
return buildGuildSearchIndex();
}, [mode]);
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
event.preventDefault();
setOpen((prev) => !prev);
}
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, []);
const resolveLabel = useCallback(
(entry: SearchIndexEntry) => {
if (entry.labelOverride) {
return entry.labelOverride;
}
if (entry.labelKey) {
return t(entry.labelKey);
}
return entry.id;
},
[t]
);
function navigateTo(entry: SearchIndexEntry) {
setOpen(false);
if (mode === 'owner') {
router.push(entry.href);
return;
}
if (!guildId) {
return;
}
const href = buildGuildSearchHref(guildId, entry);
router.push(href);
if (entry.hash) {
window.setTimeout(() => {
document.getElementById(entry.hash!)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 250);
}
}
const pages = entries.filter((e) => e.kind === 'page');
const commands = entries.filter((e) => e.kind === 'command');
const settings = entries.filter((e) => e.kind === 'setting');
const isMac =
typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="inline-flex h-9 w-full max-w-xs items-center gap-2 rounded-md border border-input bg-background/60 px-3 text-sm text-muted-foreground shadow-sm transition-colors hover:bg-accent/50 hover:text-foreground"
>
<Search className="size-4 shrink-0" />
<span className="flex-1 truncate text-left">{t('search.placeholder')}</span>
<kbd className="pointer-events-none hidden h-5 select-none items-center gap-1 rounded border border-border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground sm:inline-flex">
{isMac ? '⌘' : 'Ctrl'}K
</kbd>
</button>
<CommandDialog open={open} onOpenChange={setOpen} title={t('search.title')}>
<CommandInput placeholder={t('search.placeholder')} />
<CommandList>
<CommandEmpty>{t('search.empty')}</CommandEmpty>
{pages.length > 0 ? (
<CommandGroup heading={t('search.groups.pages')}>
{pages.map((entry) => {
const Icon = kindIcon(entry.kind);
const label = resolveLabel(entry);
return (
<CommandItem
key={entry.id}
value={`${label} ${entry.keywords?.join(' ') ?? ''} ${entry.labelKey ?? ''}`}
onSelect={() => navigateTo(entry)}
>
<Icon className="size-4 text-muted-foreground" />
<span>{label}</span>
</CommandItem>
);
})}
</CommandGroup>
) : null}
{commands.length > 0 ? (
<CommandGroup heading={t('search.groups.commands')}>
{commands.map((entry) => {
const Icon = kindIcon(entry.kind);
const label = resolveLabel(entry);
return (
<CommandItem
key={entry.id}
value={`${label} ${entry.keywords?.join(' ') ?? ''}`}
onSelect={() => navigateTo(entry)}
>
<Icon className="size-4 text-muted-foreground" />
<span>{label}</span>
</CommandItem>
);
})}
</CommandGroup>
) : null}
{settings.length > 0 ? (
<CommandGroup heading={t('search.groups.settings')}>
{settings.map((entry) => {
const Icon = kindIcon(entry.kind);
const label = resolveLabel(entry);
return (
<CommandItem
key={entry.id}
value={`${label} ${entry.keywords?.join(' ') ?? ''} ${entry.labelKey ?? ''}`}
onSelect={() => navigateTo(entry)}
>
<Icon className="size-4 text-muted-foreground" />
<span>{label}</span>
</CommandItem>
);
})}
</CommandGroup>
) : null}
</CommandList>
</CommandDialog>
</>
);
}

View File

@@ -1,8 +1,16 @@
'use client';
import type { DashboardGuild, SessionUser } from '@nexumi/shared';
import type { ReactNode } from 'react';
import { ServerSwitcher } from './server-switcher';
import { Sidebar } from './sidebar';
import { UserMenu } from './user-menu';
import { Menu } from 'lucide-react';
import { useState, type ReactNode } from 'react';
import { DashboardCommandPalette } from '@/components/layout/dashboard-command-palette';
import { HashScroll } from '@/components/layout/field-anchor';
import { ServerSwitcher } from '@/components/layout/server-switcher';
import { Sidebar, SidebarNav } from '@/components/layout/sidebar';
import { UserMenu } from '@/components/layout/user-menu';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import { useTranslations } from '@/components/locale-provider';
interface DashboardShellProps {
guildId: string;
@@ -21,15 +29,36 @@ export function DashboardShell({
showOwnerLink = false,
children
}: DashboardShellProps) {
const t = useTranslations();
const [mobileOpen, setMobileOpen] = useState(false);
return (
<div className="flex min-h-screen">
<div className="flex min-h-screen bg-background">
<HashScroll />
<Sidebar guildId={guildId} />
<div className="flex flex-1 flex-col">
<header className="flex h-14 items-center justify-between border-b border-border px-6">
<div className="flex min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-40 flex h-14 items-center gap-3 border-b border-border bg-background/80 px-4 backdrop-blur-md supports-[backdrop-filter]:bg-background/70 sm:px-6">
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="lg:hidden" aria-label={t('search.openNav')}>
<Menu className="size-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="p-0">
<SheetTitle className="sr-only">{t('nav.dashboard')}</SheetTitle>
<SidebarNav guildId={guildId} onNavigate={() => setMobileOpen(false)} />
</SheetContent>
</Sheet>
<ServerSwitcher currentGuild={currentGuild} otherGuilds={otherGuilds} />
<UserMenu user={user} showOwnerLink={showOwnerLink} />
<div className="ml-auto flex min-w-0 items-center gap-3">
<div className="min-w-0 flex-1 sm:flex-initial">
<DashboardCommandPalette guildId={guildId} />
</div>
<UserMenu user={user} showOwnerLink={showOwnerLink} />
</div>
</header>
<main className="flex-1 px-6 py-8">
<main className="flex-1 px-4 py-6 sm:px-6 sm:py-8">
<div className="mx-auto w-full max-w-[1200px]">{children}</div>
</main>
</div>

View File

@@ -0,0 +1,47 @@
'use client';
import { useEffect, type ReactNode } from 'react';
import { cn } from '@/lib/utils';
/** Stable scroll target for command-palette deep links (`#field-…`). */
export function FieldAnchor({
id,
children,
className
}: {
id: string;
children: ReactNode;
className?: string;
}) {
return (
<div id={id} className={cn('scroll-mt-24', className)}>
{children}
</div>
);
}
/** Scrolls to `location.hash` after navigation from the command palette. */
export function HashScroll() {
useEffect(() => {
function scrollToHash() {
const hash = window.location.hash.replace(/^#/, '');
if (!hash) {
return;
}
const el = document.getElementById(hash);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
scrollToHash();
window.addEventListener('hashchange', scrollToHash);
const timer = window.setTimeout(scrollToHash, 120);
return () => {
window.removeEventListener('hashchange', scrollToHash);
window.clearTimeout(timer);
};
}, []);
return null;
}

View File

@@ -1,7 +1,36 @@
'use client';
import { DASHBOARD_MODULES, type DashboardModuleGroup } from '@nexumi/shared';
import { LayoutGrid, Settings, ShieldCheck, SlidersHorizontal, Terminal } from 'lucide-react';
import { DASHBOARD_MODULES, type DashboardModuleGroup, type DashboardModuleId } from '@nexumi/shared';
import {
Archive,
BarChart3,
Cake,
CalendarClock,
Coins,
Gamepad2,
Gift,
Hash,
LayoutGrid,
MessageSquare,
MessageSquareQuote,
MessagesSquare,
PartyPopper,
Radio,
Rss,
Settings,
Shield,
ShieldAlert,
ShieldCheck,
SlidersHorizontal,
Sparkles,
Star,
Terminal,
Ticket,
UserCheck,
Volume2,
type LucideIcon
} from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useTranslations } from '@/components/locale-provider';
@@ -15,19 +44,56 @@ const MODULE_GROUP_ORDER: DashboardModuleGroup[] = [
'integrations'
];
interface SidebarProps {
const MODULE_ICONS: Record<DashboardModuleId, LucideIcon> = {
moderation: Shield,
automod: ShieldAlert,
logging: Hash,
welcome: PartyPopper,
verification: UserCheck,
leveling: Sparkles,
economy: Coins,
fun: Gamepad2,
giveaways: Gift,
tickets: Ticket,
selfroles: UserCheck,
tags: MessageSquareQuote,
starboard: Star,
suggestions: MessagesSquare,
birthdays: Cake,
tempvoice: Volume2,
stats: BarChart3,
feeds: Rss,
scheduler: CalendarClock,
guildbackup: Archive,
messages: MessageSquare
};
interface SidebarNavProps {
guildId: string;
onNavigate?: () => void;
className?: string;
}
function NavLink({ href, active, children }: { href: string; active: boolean; children: React.ReactNode }) {
function NavLink({
href,
active,
onNavigate,
children
}: {
href: string;
active: boolean;
onNavigate?: () => void;
children: React.ReactNode;
}) {
return (
<Link
href={href}
onClick={onNavigate}
className={cn(
'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
'flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm font-medium transition-colors',
active
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground'
? 'border-l-2 border-primary bg-primary/10 pl-2 text-foreground'
: 'border-l-2 border-transparent text-muted-foreground hover:bg-accent/60 hover:text-foreground'
)}
>
{children}
@@ -35,7 +101,7 @@ function NavLink({ href, active, children }: { href: string; active: boolean; ch
);
}
export function Sidebar({ guildId }: SidebarProps) {
export function SidebarNav({ guildId, onNavigate, className }: SidebarNavProps) {
const pathname = usePathname();
const t = useTranslations();
const base = `/dashboard/${guildId}`;
@@ -46,39 +112,50 @@ export function Sidebar({ guildId }: SidebarProps) {
}));
return (
<nav className="flex h-full w-60 flex-col gap-6 overflow-y-auto border-r border-border px-3 py-4">
<div className="flex flex-col gap-1">
<NavLink href={base} active={pathname === base}>
<LayoutGrid className="size-4" />
<nav className={cn('flex h-full flex-col gap-5 overflow-y-auto px-3 py-4', className)}>
<Link
href={base}
onClick={onNavigate}
className="mb-1 flex items-center gap-2.5 rounded-md px-2.5 py-1.5 transition-opacity hover:opacity-90"
>
<Image src="/nexumi-logo.svg" alt="" width={28} height={28} className="size-7" />
<span className="text-base font-semibold tracking-tight">Nexumi</span>
</Link>
<div className="flex flex-col gap-0.5">
<NavLink href={base} active={pathname === base} onNavigate={onNavigate}>
<LayoutGrid className="size-4 shrink-0" />
{t('nav.overview')}
</NavLink>
<NavLink href={`${base}/settings`} active={pathname === `${base}/settings`}>
<Settings className="size-4" />
<NavLink href={`${base}/settings`} active={pathname === `${base}/settings`} onNavigate={onNavigate}>
<Settings className="size-4 shrink-0" />
{t('nav.settings')}
</NavLink>
<NavLink href={`${base}/modules`} active={pathname === `${base}/modules`}>
<SlidersHorizontal className="size-4" />
<NavLink href={`${base}/modules`} active={pathname === `${base}/modules`} onNavigate={onNavigate}>
<SlidersHorizontal className="size-4 shrink-0" />
{t('nav.modules')}
</NavLink>
<NavLink href={`${base}/access`} active={pathname === `${base}/access`}>
<ShieldCheck className="size-4" />
<NavLink href={`${base}/access`} active={pathname === `${base}/access`} onNavigate={onNavigate}>
<ShieldCheck className="size-4 shrink-0" />
{t('nav.access')}
</NavLink>
<NavLink href={`${base}/commands`} active={pathname === `${base}/commands`}>
<Terminal className="size-4" />
<NavLink href={`${base}/commands`} active={pathname === `${base}/commands`} onNavigate={onNavigate}>
<Terminal className="size-4 shrink-0" />
{t('nav.commands')}
</NavLink>
</div>
{groups.map(({ group, modules }) => (
<div key={group} className="flex flex-col gap-1">
<p className="px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<div key={group} className="flex flex-col gap-0.5">
<p className="mb-1 px-2.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/80">
{t(`moduleGroups.${group}`)}
</p>
{modules.map((module) => {
const href = `${base}/${module.href}`;
const Icon = MODULE_ICONS[module.id] ?? Radio;
return (
<NavLink key={module.id} href={href} active={pathname === href}>
<NavLink key={module.id} href={href} active={pathname === href} onNavigate={onNavigate}>
<Icon className="size-4 shrink-0" />
{t(`modules.${module.id}.label`)}
</NavLink>
);
@@ -88,3 +165,12 @@ export function Sidebar({ guildId }: SidebarProps) {
</nav>
);
}
/** Desktop sidebar chrome. */
export function Sidebar({ guildId }: { guildId: string }) {
return (
<aside className="hidden h-screen w-60 shrink-0 border-r border-border bg-card/40 lg:sticky lg:top-0 lg:block">
<SidebarNav guildId={guildId} />
</aside>
);
}

View File

@@ -1,20 +1,48 @@
'use client';
import type { AutomodConfigDashboard } from '@nexumi/shared';
import type {
AutoModAction,
AutoModRuleType,
AutomodConfigDashboard,
AutomodRuleDashboard
} from '@nexumi/shared';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { DiscordChannelMultiSelect } from '@/components/ui/discord-channel-select';
import { DiscordRoleMultiSelect } from '@/components/ui/discord-role-select';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { StringListEditor } from '@/components/ui/string-list-editor';
import { Switch } from '@/components/ui/switch';
import type { SettingsSaveResult } from '@/components/settings/settings-form';
import { SettingsForm } from '@/components/settings/settings-form';
const ACTIONS: AutoModAction[] = ['DELETE', 'WARN', 'TIMEOUT', 'KICK', 'BAN'];
async function saveAutomod(guildId: string, value: AutomodConfigDashboard): Promise<SettingsSaveResult> {
try {
const cleaned: AutomodConfigDashboard = {
...value,
rules: value.rules.map((rule) => ({
...rule,
durationMs: rule.action === 'TIMEOUT' ? rule.durationMs ?? 60_000 : null,
threshold: {
...rule.threshold,
linkWhitelist: (rule.threshold.linkWhitelist ?? []).map((s) => s.trim()).filter(Boolean),
linkBlacklist: (rule.threshold.linkBlacklist ?? []).map((s) => s.trim()).filter(Boolean)
},
wordList: {
words: (rule.wordList.words ?? []).map((s) => s.trim()).filter(Boolean),
regexPatterns: (rule.wordList.regexPatterns ?? []).map((s) => s.trim()).filter(Boolean)
}
}))
};
const response = await fetch(`/api/guilds/${guildId}/automod`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(value)
body: JSON.stringify(cleaned)
});
if (!response.ok) {
const body = (await response.json().catch(() => null)) as { error?: string } | null;
@@ -26,6 +54,281 @@ async function saveAutomod(guildId: string, value: AutomodConfigDashboard): Prom
}
}
function updateRule(
rules: AutomodRuleDashboard[],
ruleType: AutoModRuleType,
patch: Partial<AutomodRuleDashboard>
): AutomodRuleDashboard[] {
return rules.map((rule) => (rule.ruleType === ruleType ? { ...rule, ...patch } : rule));
}
function RuleCard({
guildId,
rule,
onChange
}: {
guildId: string;
rule: AutomodRuleDashboard;
onChange: (patch: Partial<AutomodRuleDashboard>) => void;
}) {
const t = useTranslations();
const type = rule.ruleType;
return (
<FieldAnchor id={`field-automod-rule-${type}`}>
<Card>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle className="text-base">
{t(`modulePages.automod.rules.${type}.title`)}
</CardTitle>
<CardDescription>{t(`modulePages.automod.rules.${type}.description`)}</CardDescription>
</div>
<Switch
checked={rule.enabled}
onCheckedChange={(enabled) => onChange({ enabled })}
aria-label={t(`modulePages.automod.rules.${type}.title`)}
/>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('modulePages.automod.ruleAction')}</Label>
<Select
value={rule.action}
onValueChange={(action) => onChange({ action: action as AutoModAction })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACTIONS.map((action) => (
<SelectItem key={action} value={action}>
{t(`modulePages.automod.actions.${action}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{rule.action === 'TIMEOUT' ? (
<div className="space-y-2">
<Label>{t('modulePages.automod.timeoutMinutes')}</Label>
<Input
type="number"
min={1}
max={40320}
value={Math.max(1, Math.round((rule.durationMs ?? 60_000) / 60_000))}
onChange={(event) =>
onChange({
durationMs: Math.max(1, Number(event.target.value) || 1) * 60_000
})
}
/>
</div>
) : null}
</div>
{type === 'SPAM' || type === 'DUPLICATE' ? (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>
{type === 'SPAM'
? t('modulePages.automod.thresholds.maxMessages')
: t('modulePages.automod.thresholds.duplicateCount')}
</Label>
<Input
type="number"
min={1}
value={
type === 'SPAM'
? (rule.threshold.maxMessages ?? 5)
: (rule.threshold.duplicateCount ?? 3)
}
onChange={(event) =>
onChange({
threshold: {
...rule.threshold,
...(type === 'SPAM'
? { maxMessages: Number(event.target.value) || 1 }
: { duplicateCount: Number(event.target.value) || 1 })
}
})
}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.automod.thresholds.windowSeconds')}</Label>
<Input
type="number"
min={1}
value={rule.threshold.windowSeconds ?? (type === 'SPAM' ? 5 : 30)}
onChange={(event) =>
onChange({
threshold: {
...rule.threshold,
windowSeconds: Number(event.target.value) || 1
}
})
}
/>
</div>
</div>
) : null}
{type === 'MASS_MENTION' ? (
<div className="space-y-2">
<Label>{t('modulePages.automod.thresholds.maxMentions')}</Label>
<Input
type="number"
min={1}
className="w-40"
value={rule.threshold.maxMentions ?? 5}
onChange={(event) =>
onChange({
threshold: { ...rule.threshold, maxMentions: Number(event.target.value) || 1 }
})
}
/>
</div>
) : null}
{type === 'CAPS' ? (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('modulePages.automod.thresholds.capsPercent')}</Label>
<Input
type="number"
min={0}
max={100}
value={rule.threshold.capsPercent ?? 70}
onChange={(event) =>
onChange({
threshold: {
...rule.threshold,
capsPercent: Number(event.target.value) || 0
}
})
}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.automod.thresholds.minLength')}</Label>
<Input
type="number"
min={0}
value={rule.threshold.minLength ?? 10}
onChange={(event) =>
onChange({
threshold: {
...rule.threshold,
minLength: Number(event.target.value) || 0
}
})
}
/>
</div>
</div>
) : null}
{type === 'EMOJI_SPAM' ? (
<div className="space-y-2">
<Label>{t('modulePages.automod.thresholds.maxEmojis')}</Label>
<Input
type="number"
min={1}
className="w-40"
value={rule.threshold.maxEmojis ?? 10}
onChange={(event) =>
onChange({
threshold: { ...rule.threshold, maxEmojis: Number(event.target.value) || 1 }
})
}
/>
</div>
) : null}
{type === 'EXTERNAL_LINK' ? (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('modulePages.automod.linkWhitelist')}</Label>
<StringListEditor
value={rule.threshold.linkWhitelist ?? []}
onChange={(linkWhitelist) =>
onChange({ threshold: { ...rule.threshold, linkWhitelist } })
}
placeholder="discord.com"
addLabel={t('modulePages.automod.addHost')}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.automod.linkBlacklist')}</Label>
<StringListEditor
value={rule.threshold.linkBlacklist ?? []}
onChange={(linkBlacklist) =>
onChange({ threshold: { ...rule.threshold, linkBlacklist } })
}
placeholder="example.com"
addLabel={t('modulePages.automod.addHost')}
/>
</div>
</div>
) : null}
{type === 'WORD_FILTER' ? (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('modulePages.automod.wordList')}</Label>
<StringListEditor
value={rule.wordList.words}
onChange={(words) => onChange({ wordList: { ...rule.wordList, words } })}
placeholder={t('modulePages.automod.wordPlaceholder')}
addLabel={t('modulePages.automod.addWord')}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.automod.regexList')}</Label>
<StringListEditor
value={rule.wordList.regexPatterns}
onChange={(regexPatterns) =>
onChange({ wordList: { ...rule.wordList, regexPatterns } })
}
placeholder="\\bspam\\b"
addLabel={t('modulePages.automod.addRegex')}
/>
</div>
</div>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('modulePages.automod.exceptionRoles')}</Label>
<DiscordRoleMultiSelect
guildId={guildId}
value={rule.exceptions.roleIds}
onChange={(roleIds) =>
onChange({ exceptions: { ...rule.exceptions, roleIds } })
}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.automod.exceptionChannels')}</Label>
<DiscordChannelMultiSelect
guildId={guildId}
value={rule.exceptions.channelIds}
onChange={(channelIds) =>
onChange({ exceptions: { ...rule.exceptions, channelIds } })
}
/>
</div>
</div>
</CardContent>
</Card>
</FieldAnchor>
);
}
interface AutomodFormProps {
guildId: string;
initialValue: AutomodConfigDashboard;
@@ -35,7 +338,10 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
const t = useTranslations();
return (
<SettingsForm<AutomodConfigDashboard> initialValue={initialValue} onSave={(value) => saveAutomod(guildId, value)}>
<SettingsForm<AutomodConfigDashboard>
initialValue={initialValue}
onSave={(value) => saveAutomod(guildId, value)}
>
{({ value, setValue }) => (
<div className="space-y-6">
<Card>
@@ -44,120 +350,197 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
<CardDescription>{t('modulePages.automod.description')}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-0.5">
<Label>{t('modulePages.automod.enabledLabel')}</Label>
<p className="text-sm text-muted-foreground">{t('modulePages.automod.enabledHint')}</p>
<FieldAnchor id="field-automod-enabled">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-0.5">
<Label>{t('modulePages.automod.enabledLabel')}</Label>
<p className="text-sm text-muted-foreground">{t('modulePages.automod.enabledHint')}</p>
</div>
<Switch
checked={value.enabled}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
/>
</div>
<Switch
checked={value.enabled}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
/>
</div>
</FieldAnchor>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('modulePages.automod.antiRaidTitle')}</CardTitle>
<CardDescription>{t('modulePages.automod.antiRaidDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.automod.antiRaidEnabled')}</Label>
<Switch
checked={value.antiRaidEnabled}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, antiRaidEnabled: checked }))}
<FieldAnchor id="field-automod-rules">
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold">{t('modulePages.automod.rulesTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('modulePages.automod.rulesDescription')}</p>
</div>
{value.rules.map((rule) => (
<RuleCard
key={rule.ruleType}
guildId={guildId}
rule={rule}
onChange={(patch) =>
setValue((prev) => ({
...prev,
rules: updateRule(prev.rules, rule.ruleType, patch)
}))
}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('modulePages.automod.antiRaidJoinThreshold')}</Label>
<Input
type="number"
min={1}
value={value.antiRaidJoinThreshold}
onChange={(event) =>
setValue((prev) => ({ ...prev, antiRaidJoinThreshold: Number(event.target.value) || 1 }))
}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.automod.antiRaidWindowSeconds')}</Label>
<Input
type="number"
min={1}
value={value.antiRaidWindowSeconds}
onChange={(event) =>
setValue((prev) => ({ ...prev, antiRaidWindowSeconds: Number(event.target.value) || 1 }))
}
/>
</div>
</div>
<p className="text-sm text-muted-foreground">{t('modulePages.automod.antiRaidActionHint')}</p>
</CardContent>
</Card>
))}
</div>
</FieldAnchor>
<Card>
<CardHeader>
<CardTitle>{t('modulePages.automod.antiNukeTitle')}</CardTitle>
<CardDescription>{t('modulePages.automod.antiNukeDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.automod.antiNukeEnabled')}</Label>
<Switch
checked={value.antiNukeEnabled}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, antiNukeEnabled: checked }))}
/>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label>{t('modulePages.automod.antiNukeBanThreshold')}</Label>
<Input
type="number"
min={1}
value={value.antiNukeBanThreshold}
onChange={(event) =>
setValue((prev) => ({ ...prev, antiNukeBanThreshold: Number(event.target.value) || 1 }))
<FieldAnchor id="field-anti-raid">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.automod.antiRaidTitle')}</CardTitle>
<CardDescription>{t('modulePages.automod.antiRaidDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.automod.antiRaidEnabled')}</Label>
<Switch
checked={value.antiRaidEnabled}
onCheckedChange={(checked) =>
setValue((prev) => ({ ...prev, antiRaidEnabled: checked }))
}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.automod.antiNukeChannelThreshold')}</Label>
<Input
type="number"
min={1}
value={value.antiNukeChannelThreshold}
onChange={(event) =>
setValue((prev) => ({ ...prev, antiNukeChannelThreshold: Number(event.target.value) || 1 }))
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label>{t('modulePages.automod.antiRaidJoinThreshold')}</Label>
<Input
type="number"
min={1}
value={value.antiRaidJoinThreshold}
onChange={(event) =>
setValue((prev) => ({
...prev,
antiRaidJoinThreshold: Number(event.target.value) || 1
}))
}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.automod.antiRaidWindowSeconds')}</Label>
<Input
type="number"
min={1}
value={value.antiRaidWindowSeconds}
onChange={(event) =>
setValue((prev) => ({
...prev,
antiRaidWindowSeconds: Number(event.target.value) || 1
}))
}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.automod.antiRaidAction')}</Label>
<Select
value={value.antiRaidAction}
onValueChange={(antiRaidAction) =>
setValue((prev) => ({
...prev,
antiRaidAction: antiRaidAction as AutomodConfigDashboard['antiRaidAction']
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="LOCKDOWN">
{t('modulePages.automod.antiRaidActions.LOCKDOWN')}
</SelectItem>
<SelectItem value="VERIFY">
{t('modulePages.automod.antiRaidActions.VERIFY')}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<p className="text-sm text-muted-foreground">{t('modulePages.automod.antiRaidActionHint')}</p>
</CardContent>
</Card>
</FieldAnchor>
<FieldAnchor id="field-anti-nuke">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.automod.antiNukeTitle')}</CardTitle>
<CardDescription>{t('modulePages.automod.antiNukeDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.automod.antiNukeEnabled')}</Label>
<Switch
checked={value.antiNukeEnabled}
onCheckedChange={(checked) =>
setValue((prev) => ({ ...prev, antiNukeEnabled: checked }))
}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.automod.antiNukeWindowSeconds')}</Label>
<Input
type="number"
min={1}
value={value.antiNukeWindowSeconds}
onChange={(event) =>
setValue((prev) => ({ ...prev, antiNukeWindowSeconds: Number(event.target.value) || 1 }))
}
/>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label>{t('modulePages.automod.antiNukeBanThreshold')}</Label>
<Input
type="number"
min={1}
value={value.antiNukeBanThreshold}
onChange={(event) =>
setValue((prev) => ({
...prev,
antiNukeBanThreshold: Number(event.target.value) || 1
}))
}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.automod.antiNukeChannelThreshold')}</Label>
<Input
type="number"
min={1}
value={value.antiNukeChannelThreshold}
onChange={(event) =>
setValue((prev) => ({
...prev,
antiNukeChannelThreshold: Number(event.target.value) || 1
}))
}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.automod.antiNukeWindowSeconds')}</Label>
<Input
type="number"
min={1}
value={value.antiNukeWindowSeconds}
onChange={(event) =>
setValue((prev) => ({
...prev,
antiNukeWindowSeconds: Number(event.target.value) || 1
}))
}
/>
</div>
</div>
</div>
<div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-0.5">
<Label>{t('modulePages.automod.lockdownActive')}</Label>
<p className="text-sm text-muted-foreground">{t('modulePages.automod.lockdownActiveHint')}</p>
</div>
<Switch
checked={value.lockdownActive}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, lockdownActive: checked }))}
/>
</div>
</CardContent>
</Card>
<FieldAnchor id="field-lockdown">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-0.5">
<Label>{t('modulePages.automod.lockdownActive')}</Label>
<p className="text-sm text-muted-foreground">
{t('modulePages.automod.lockdownActiveHint')}
</p>
</div>
<Switch
checked={value.lockdownActive}
onCheckedChange={(checked) =>
setValue((prev) => ({ ...prev, lockdownActive: checked }))
}
/>
</div>
</FieldAnchor>
</CardContent>
</Card>
</FieldAnchor>
</div>
)}
</SettingsForm>

View File

@@ -2,10 +2,14 @@
import type { BirthdayConfigDashboard } from '@nexumi/shared';
import { useId } from 'react';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { DiscordChannelSelect } from '@/components/ui/discord-channel-select';
import { DiscordRoleSelect } from '@/components/ui/discord-role-select';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { PlaceholderHelp } from '@/components/ui/placeholder-help';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import type { SettingsSaveResult } from '@/components/settings/settings-form';
@@ -35,8 +39,6 @@ interface BirthdaysFormProps {
export function BirthdaysForm({ guildId, initialValue }: BirthdaysFormProps) {
const t = useTranslations();
const channelId = useId();
const roleId = useId();
const timezoneId = useId();
return (
@@ -48,28 +50,47 @@ export function BirthdaysForm({ guildId, initialValue }: BirthdaysFormProps) {
<CardDescription>{t('modulePages.birthdays.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FieldAnchor id="field-birthdays-enabled">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.birthdays.enabledLabel')}</Label>
<Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} />
</div>
</FieldAnchor>
<div className="grid gap-4 sm:grid-cols-3">
<FieldAnchor id="field-birthdays-channel">
<div className="space-y-2">
<Label htmlFor={channelId}>{t('modulePages.birthdays.channelId')}</Label>
<Input id={channelId} value={value.channelId} onChange={(event) => setValue((prev) => ({ ...prev, channelId: event.target.value.trim() }))} placeholder="123456789012345678" />
<Label>{t('modulePages.birthdays.channelId')}</Label>
<DiscordChannelSelect
guildId={guildId}
value={value.channelId}
onChange={(channelId) => setValue((prev) => ({ ...prev, channelId }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-birthdays-role">
<div className="space-y-2">
<Label htmlFor={roleId}>{t('modulePages.birthdays.roleId')}</Label>
<Input id={roleId} value={value.roleId} onChange={(event) => setValue((prev) => ({ ...prev, roleId: event.target.value.trim() }))} placeholder="123456789012345678" />
<Label>{t('modulePages.birthdays.roleId')}</Label>
<DiscordRoleSelect
guildId={guildId}
value={value.roleId}
onChange={(roleId) => setValue((prev) => ({ ...prev, roleId }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-birthdays-timezone">
<div className="space-y-2">
<Label htmlFor={timezoneId}>{t('modulePages.birthdays.timezone')}</Label>
<Input id={timezoneId} value={value.timezone} onChange={(event) => setValue((prev) => ({ ...prev, timezone: event.target.value }))} placeholder="Europe/Berlin" />
</div>
</FieldAnchor>
</div>
<FieldAnchor id="field-birthdays-message">
<div className="space-y-2">
<Label>{t('modulePages.birthdays.message')}</Label>
<Textarea value={value.message ?? ''} onChange={(event) => setValue((prev) => ({ ...prev, message: event.target.value || null }))} placeholder={t('modulePages.birthdays.messagePlaceholder')} />
<PlaceholderHelp preset="birthdays" />
</div>
</FieldAnchor>
</CardContent>
</Card>
)}

View File

@@ -1,8 +1,9 @@
'use client';
import type { CaseDashboard } from '@nexumi/shared';
import { Search } from 'lucide-react';
import { Pencil, Search, Trash2 } from 'lucide-react';
import { useCallback, useState } from 'react';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -25,6 +26,8 @@ export function CasesTable({ guildId, initialCases }: CasesTableProps) {
const [action, setAction] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [editReason, setEditReason] = useState('');
const fetchCases = useCallback(async () => {
setLoading(true);
@@ -47,84 +50,158 @@ export function CasesTable({ guildId, initialCases }: CasesTableProps) {
}
}, [action, guildId, search, t]);
async function saveReason(caseId: string) {
setError(null);
const response = await fetch(`/api/guilds/${guildId}/cases`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: caseId, reason: editReason })
});
if (!response.ok) {
setError(t('common.saveError'));
return;
}
const body = (await response.json()) as { case: CaseDashboard };
setCases((prev) => prev.map((entry) => (entry.id === caseId ? body.case : entry)));
setEditingId(null);
}
async function removeCase(caseId: string) {
if (!window.confirm(t('modulePages.moderation.confirmDeleteCase'))) {
return;
}
setError(null);
const response = await fetch(`/api/guilds/${guildId}/cases?id=${encodeURIComponent(caseId)}`, {
method: 'DELETE'
});
if (!response.ok) {
setError(t('common.saveError'));
return;
}
setCases((prev) => prev.filter((entry) => entry.id !== caseId));
}
return (
<Card>
<CardHeader>
<CardTitle>{t('modulePages.moderation.casesTitle')}</CardTitle>
<CardDescription>{t('modulePages.moderation.casesDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<form
className="flex flex-wrap items-end gap-3"
onSubmit={(event) => {
event.preventDefault();
void fetchCases();
}}
>
<div className="flex-1 space-y-2">
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t('modulePages.moderation.searchPlaceholder')}
/>
</div>
<div className="space-y-2">
<Input
value={action}
onChange={(event) => setAction(event.target.value)}
placeholder={t('modulePages.moderation.actionPlaceholder')}
className="w-40"
/>
</div>
<Button type="submit" variant="outline" disabled={loading}>
<Search className="size-4" />
{t('common.confirm')}
</Button>
</form>
<FieldAnchor id="field-mod-cases">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.moderation.casesTitle')}</CardTitle>
<CardDescription>{t('modulePages.moderation.casesDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<form
className="flex flex-wrap items-end gap-3"
onSubmit={(event) => {
event.preventDefault();
void fetchCases();
}}
>
<div className="flex-1 space-y-2">
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t('modulePages.moderation.searchPlaceholder')}
/>
</div>
<div className="space-y-2">
<Input
value={action}
onChange={(event) => setAction(event.target.value)}
placeholder={t('modulePages.moderation.actionPlaceholder')}
className="w-40"
/>
</div>
<Button type="submit" variant="outline" disabled={loading}>
<Search className="size-4" />
{t('common.confirm')}
</Button>
</form>
{error && <p className="text-sm text-destructive">{error}</p>}
{error && <p className="text-sm text-destructive">{error}</p>}
{loading ? (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className="h-12 rounded-md" />
))}
</div>
) : cases.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('modulePages.moderation.casesEmpty')}</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-xs uppercase text-muted-foreground">
<th className="py-2 pr-4">#</th>
<th className="py-2 pr-4">{t('modulePages.moderation.action')}</th>
<th className="py-2 pr-4">{t('modulePages.moderation.target')}</th>
<th className="py-2 pr-4">{t('modulePages.moderation.moderator')}</th>
<th className="py-2 pr-4">{t('modulePages.moderation.reason')}</th>
<th className="py-2 pr-4">{t('modulePages.moderation.createdAt')}</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{cases.map((entry) => (
<tr key={entry.id}>
<td className="py-2 pr-4 font-mono text-xs">#{entry.caseNumber}</td>
<td className="py-2 pr-4">{entry.action}</td>
<td className="py-2 pr-4 font-mono text-xs">{entry.targetUserId}</td>
<td className="py-2 pr-4 font-mono text-xs">{entry.moderatorId}</td>
<td className="max-w-64 truncate py-2 pr-4">
{entry.reason ?? t('modulePages.moderation.noReason')}
</td>
<td className="whitespace-nowrap py-2 pr-4 text-xs text-muted-foreground">
{formatDate(entry.createdAt)}
</td>
{loading ? (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className="h-12 rounded-md" />
))}
</div>
) : cases.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('modulePages.moderation.casesEmpty')}</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-xs uppercase text-muted-foreground">
<th className="py-2 pr-4">#</th>
<th className="py-2 pr-4">{t('modulePages.moderation.action')}</th>
<th className="py-2 pr-4">{t('modulePages.moderation.target')}</th>
<th className="py-2 pr-4">{t('modulePages.moderation.moderator')}</th>
<th className="py-2 pr-4">{t('modulePages.moderation.reason')}</th>
<th className="py-2 pr-4">{t('modulePages.moderation.createdAt')}</th>
<th className="py-2 pr-4" />
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</thead>
<tbody className="divide-y divide-border">
{cases.map((entry) => (
<tr key={entry.id}>
<td className="py-2 pr-4 font-mono text-xs">#{entry.caseNumber}</td>
<td className="py-2 pr-4">{entry.action}</td>
<td className="py-2 pr-4 font-mono text-xs">{entry.targetUserId}</td>
<td className="py-2 pr-4 font-mono text-xs">{entry.moderatorId}</td>
<td className="max-w-64 py-2 pr-4">
{editingId === entry.id ? (
<div className="flex items-center gap-2">
<Input
value={editReason}
onChange={(event) => setEditReason(event.target.value)}
aria-label={t('modulePages.moderation.editReason')}
/>
<Button type="button" size="sm" onClick={() => void saveReason(entry.id)}>
{t('modulePages.moderation.saveReason')}
</Button>
</div>
) : (
<span className="truncate block">
{entry.reason ?? t('modulePages.moderation.noReason')}
</span>
)}
</td>
<td className="whitespace-nowrap py-2 pr-4 text-xs text-muted-foreground">
{formatDate(entry.createdAt)}
</td>
<td className="py-2 pr-4">
<div className="flex gap-1">
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t('modulePages.moderation.editReason')}
onClick={() => {
setEditingId(entry.id);
setEditReason(entry.reason ?? '');
}}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t('modulePages.moderation.deleteCase')}
onClick={() => void removeCase(entry.id)}
>
<Trash2 className="size-4" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</FieldAnchor>
);
}

View File

@@ -1,26 +1,22 @@
'use client';
import type { CommandOverrideDashboard } from '@nexumi/shared';
import type {
CommandGlobalRules,
CommandOverrideDashboard,
DiscordChannelOption
} from '@nexumi/shared';
import { RotateCcw } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { DiscordResourceMultiSelect } from '@/components/ui/discord-resource-multi-select';
import { DiscordRoleMultiSelect } from '@/components/ui/discord-role-select';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
function toCsv(ids: string[]): string {
return ids.join(', ');
}
function fromCsv(text: string): string[] {
return text
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
interface LocalOverride extends CommandOverrideDashboard {
saving?: boolean;
}
@@ -28,12 +24,35 @@ interface LocalOverride extends CommandOverrideDashboard {
interface CommandsManagerProps {
guildId: string;
initialCommands: CommandOverrideDashboard[];
initialGlobalRules: CommandGlobalRules;
channels: DiscordChannelOption[];
}
export function CommandsManager({ guildId, initialCommands }: CommandsManagerProps) {
export function CommandsManager({
guildId,
initialCommands,
initialGlobalRules,
channels
}: CommandsManagerProps) {
const t = useTranslations();
const searchParams = useSearchParams();
const [commands, setCommands] = useState<LocalOverride[]>(initialCommands);
const [search, setSearch] = useState('');
const [globalRules, setGlobalRules] = useState(initialGlobalRules);
const [globalSaving, setGlobalSaving] = useState(false);
const [globalDirty, setGlobalDirty] = useState(false);
const channelOptions = useMemo(
() => channels.map((channel) => ({ id: channel.id, name: channel.name, prefix: '#' })),
[channels]
);
useEffect(() => {
const q = searchParams.get('q');
if (q) {
setSearch(q);
}
}, [searchParams]);
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
@@ -49,6 +68,37 @@ export function CommandsManager({ guildId, initialCommands }: CommandsManagerPro
);
}
function patchGlobal(patch: Partial<CommandGlobalRules>) {
setGlobalRules((prev) => ({ ...prev, ...patch }));
setGlobalDirty(true);
}
async function handleSaveGlobal() {
setGlobalSaving(true);
try {
const response = await fetch(`/api/guilds/${guildId}/commands/globals`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(globalRules)
});
const body = (await response.json().catch(() => null)) as (CommandGlobalRules & { error?: string }) | null;
if (!response.ok || !body) {
toast.error(body?.error ?? t('common.saveError'));
return;
}
setGlobalRules({
allowedChannelIds: body.allowedChannelIds,
deniedChannelIds: body.deniedChannelIds
});
setGlobalDirty(false);
toast.success(t('common.saveSuccess'));
} catch {
toast.error(t('common.saveError'));
} finally {
setGlobalSaving(false);
}
}
async function handleSave(entry: LocalOverride) {
patchLocal(entry.commandName, { saving: true });
try {
@@ -64,7 +114,9 @@ export function CommandsManager({ guildId, initialCommands }: CommandsManagerPro
cooldownSeconds: entry.cooldownSeconds
})
});
const body = (await response.json().catch(() => null)) as (CommandOverrideDashboard & { error?: string }) | null;
const body = (await response.json().catch(() => null)) as
| (CommandOverrideDashboard & { error?: string })
| null;
if (!response.ok || !body) {
toast.error(body?.error ?? t('common.saveError'));
return;
@@ -81,7 +133,9 @@ export function CommandsManager({ guildId, initialCommands }: CommandsManagerPro
async function handleReset(entry: LocalOverride) {
patchLocal(entry.commandName, { saving: true });
try {
const response = await fetch(`/api/guilds/${guildId}/commands/${entry.commandName}`, { method: 'DELETE' });
const response = await fetch(`/api/guilds/${guildId}/commands/${entry.commandName}`, {
method: 'DELETE'
});
const body = (await response.json().catch(() => null)) as CommandOverrideDashboard | null;
if (!response.ok || !body) {
toast.error(t('common.saveError'));
@@ -97,102 +151,168 @@ export function CommandsManager({ guildId, initialCommands }: CommandsManagerPro
}
return (
<Card>
<CardHeader>
<CardTitle>{t('modulePages.commands.title')}</CardTitle>
<CardDescription>{t('modulePages.commands.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t('modulePages.commands.searchPlaceholder')}
className="max-w-sm"
/>
<div className="space-y-3">
{filtered.length === 0 && (
<p className="text-sm text-muted-foreground">{t('modulePages.commands.empty')}</p>
)}
{filtered.map((entry) => (
<div key={entry.commandName} className="space-y-3 rounded-md border border-border p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="font-mono text-sm font-medium">/{entry.commandName}</p>
<div className="flex items-center gap-2">
<Switch
checked={entry.enabled}
disabled={entry.saving}
onCheckedChange={(enabled) => patchLocal(entry.commandName, { enabled })}
/>
<span className="text-xs text-muted-foreground">
{entry.enabled ? t('common.enabled') : t('common.disabled')}
</span>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{t('modulePages.commands.cooldownSeconds')}</p>
<Input
type="number"
min={0}
value={entry.cooldownSeconds ?? ''}
onChange={(event) =>
patchLocal(entry.commandName, {
cooldownSeconds: event.target.value === '' ? null : Number(event.target.value)
})
}
placeholder={t('common.optional')}
/>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{t('modulePages.commands.allowedRoleIds')}</p>
<Input
value={toCsv(entry.allowedRoleIds)}
onChange={(event) => patchLocal(entry.commandName, { allowedRoleIds: fromCsv(event.target.value) })}
placeholder={t('modulePages.commands.idsPlaceholder')}
/>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{t('modulePages.commands.deniedRoleIds')}</p>
<Input
value={toCsv(entry.deniedRoleIds)}
onChange={(event) => patchLocal(entry.commandName, { deniedRoleIds: fromCsv(event.target.value) })}
placeholder={t('modulePages.commands.idsPlaceholder')}
/>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{t('modulePages.commands.allowedChannelIds')}</p>
<Input
value={toCsv(entry.allowedChannelIds)}
onChange={(event) =>
patchLocal(entry.commandName, { allowedChannelIds: fromCsv(event.target.value) })
}
placeholder={t('modulePages.commands.idsPlaceholder')}
/>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{t('modulePages.commands.deniedChannelIds')}</p>
<Input
value={toCsv(entry.deniedChannelIds)}
onChange={(event) =>
patchLocal(entry.commandName, { deniedChannelIds: fromCsv(event.target.value) })
}
placeholder={t('modulePages.commands.idsPlaceholder')}
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" size="sm" disabled={entry.saving} onClick={() => handleReset(entry)}>
<RotateCcw className="size-4" />
{t('modulePages.commands.reset')}
</Button>
<Button type="button" variant="outline" size="sm" disabled={entry.saving} onClick={() => handleSave(entry)}>
{entry.saving ? t('common.saving') : t('common.save')}
</Button>
</div>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.commands.globalTitle')}</CardTitle>
<CardDescription>{t('modulePages.commands.globalDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div id="field-commands-global-allow" className="scroll-mt-24 space-y-2">
<p className="text-sm font-medium">{t('modulePages.commands.globalAllowedChannels')}</p>
<p className="text-xs text-muted-foreground">{t('modulePages.commands.globalAllowedHint')}</p>
<DiscordResourceMultiSelect
options={channelOptions}
value={globalRules.allowedChannelIds}
onChange={(allowedChannelIds) => patchGlobal({ allowedChannelIds })}
placeholder={t('modulePages.commands.channelSearchPlaceholder')}
disabled={globalSaving}
/>
</div>
))}
</div>
</CardContent>
</Card>
</div>
<div className="space-y-2">
<div id="field-commands-global-deny" className="scroll-mt-24 space-y-2">
<p className="text-sm font-medium">{t('modulePages.commands.globalDeniedChannels')}</p>
<p className="text-xs text-muted-foreground">{t('modulePages.commands.globalDeniedHint')}</p>
<DiscordResourceMultiSelect
options={channelOptions}
value={globalRules.deniedChannelIds}
onChange={(deniedChannelIds) => patchGlobal({ deniedChannelIds })}
placeholder={t('modulePages.commands.channelSearchPlaceholder')}
disabled={globalSaving}
/>
</div>
</div>
<div className="flex justify-end">
<Button
type="button"
disabled={!globalDirty || globalSaving}
onClick={() => void handleSaveGlobal()}
>
{globalSaving ? t('common.saving') : t('common.save')}
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('modulePages.commands.title')}</CardTitle>
<CardDescription>{t('modulePages.commands.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t('modulePages.commands.searchPlaceholder')}
className="max-w-sm"
/>
<div className="space-y-3">
{filtered.length === 0 && (
<p className="text-sm text-muted-foreground">{t('modulePages.commands.empty')}</p>
)}
{filtered.map((entry) => (
<div key={entry.commandName} className="space-y-3 rounded-md border border-border p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="font-mono text-sm font-medium">/{entry.commandName}</p>
<div className="flex items-center gap-2">
<Switch
checked={entry.enabled}
disabled={entry.saving}
onCheckedChange={(enabled) => patchLocal(entry.commandName, { enabled })}
/>
<span className="text-xs text-muted-foreground">
{entry.enabled ? t('common.enabled') : t('common.disabled')}
</span>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{t('modulePages.commands.cooldownSeconds')}</p>
<Input
type="number"
min={0}
value={entry.cooldownSeconds ?? ''}
onChange={(event) =>
patchLocal(entry.commandName, {
cooldownSeconds: event.target.value === '' ? null : Number(event.target.value)
})
}
placeholder={t('common.optional')}
/>
</div>
<div className="space-y-1 sm:col-span-2">
<p className="text-xs text-muted-foreground">{t('modulePages.commands.allowedRoleIds')}</p>
<DiscordRoleMultiSelect
guildId={guildId}
value={entry.allowedRoleIds}
onChange={(allowedRoleIds) => patchLocal(entry.commandName, { allowedRoleIds })}
placeholder={t('modulePages.commands.roleSearchPlaceholder')}
disabled={entry.saving}
/>
</div>
<div className="space-y-1 sm:col-span-2">
<p className="text-xs text-muted-foreground">{t('modulePages.commands.deniedRoleIds')}</p>
<DiscordRoleMultiSelect
guildId={guildId}
value={entry.deniedRoleIds}
onChange={(deniedRoleIds) => patchLocal(entry.commandName, { deniedRoleIds })}
placeholder={t('modulePages.commands.roleSearchPlaceholder')}
disabled={entry.saving}
/>
</div>
<div className="space-y-1 sm:col-span-2">
<p className="text-xs text-muted-foreground">{t('modulePages.commands.allowedChannelIds')}</p>
<DiscordResourceMultiSelect
options={channelOptions}
value={entry.allowedChannelIds}
onChange={(allowedChannelIds) =>
patchLocal(entry.commandName, { allowedChannelIds })
}
placeholder={t('modulePages.commands.channelSearchPlaceholder')}
disabled={entry.saving}
/>
</div>
<div className="space-y-1 sm:col-span-2">
<p className="text-xs text-muted-foreground">{t('modulePages.commands.deniedChannelIds')}</p>
<DiscordResourceMultiSelect
options={channelOptions}
value={entry.deniedChannelIds}
onChange={(deniedChannelIds) =>
patchLocal(entry.commandName, { deniedChannelIds })
}
placeholder={t('modulePages.commands.channelSearchPlaceholder')}
disabled={entry.saving}
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
disabled={entry.saving}
onClick={() => void handleReset(entry)}
>
<RotateCcw className="size-4" />
{t('modulePages.commands.reset')}
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={entry.saving}
onClick={() => void handleSave(entry)}
>
{entry.saving ? t('common.saving') : t('common.save')}
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,6 +1,7 @@
'use client';
import type { EconomyConfigDashboard } from '@nexumi/shared';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
@@ -44,6 +45,7 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
<CardDescription>{t('modulePages.economy.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FieldAnchor id="field-economy-enabled">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.economy.enabledLabel')}</Label>
<Switch
@@ -51,7 +53,9 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
/>
</div>
</FieldAnchor>
<div className="grid gap-4 sm:grid-cols-2">
<FieldAnchor id="field-economy-currency-name">
<div className="space-y-2">
<Label>{t('modulePages.economy.currencyName')}</Label>
<Input
@@ -59,6 +63,8 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
onChange={(event) => setValue((prev) => ({ ...prev, currencyName: event.target.value }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-economy-currency-symbol">
<div className="space-y-2">
<Label>{t('modulePages.economy.currencySymbol')}</Label>
<Input
@@ -66,6 +72,7 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
onChange={(event) => setValue((prev) => ({ ...prev, currencySymbol: event.target.value }))}
/>
</div>
</FieldAnchor>
</div>
</CardContent>
</Card>
@@ -75,6 +82,7 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
<CardTitle>{t('modulePages.economy.amountsTitle')}</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<FieldAnchor id="field-economy-daily">
<div className="space-y-2">
<Label>{t('modulePages.economy.dailyAmount')}</Label>
<Input
@@ -84,6 +92,8 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
onChange={(event) => setValue((prev) => ({ ...prev, dailyAmount: Number(event.target.value) || 0 }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-economy-weekly">
<div className="space-y-2">
<Label>{t('modulePages.economy.weeklyAmount')}</Label>
<Input
@@ -93,6 +103,8 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
onChange={(event) => setValue((prev) => ({ ...prev, weeklyAmount: Number(event.target.value) || 0 }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-economy-work-min">
<div className="space-y-2">
<Label>{t('modulePages.economy.workMin')}</Label>
<Input
@@ -102,6 +114,8 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
onChange={(event) => setValue((prev) => ({ ...prev, workMin: Number(event.target.value) || 0 }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-economy-work-max">
<div className="space-y-2">
<Label>{t('modulePages.economy.workMax')}</Label>
<Input
@@ -111,6 +125,8 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
onChange={(event) => setValue((prev) => ({ ...prev, workMax: Number(event.target.value) || 0 }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-economy-work-cooldown">
<div className="space-y-2">
<Label>{t('modulePages.economy.workCooldownSeconds')}</Label>
<Input
@@ -122,6 +138,8 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-economy-starting">
<div className="space-y-2">
<Label>{t('modulePages.economy.startingBalance')}</Label>
<Input
@@ -133,6 +151,7 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
}
/>
</div>
</FieldAnchor>
</CardContent>
</Card>
</div>

Some files were not shown because too many files have changed in this diff Show More