Compare commits
31 Commits
08af99ed52
...
2f01335e61
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f01335e61 | |||
|
|
f2406fb6d7 | ||
|
|
bd1b55fff7 | ||
|
|
fba597a6f2 | ||
|
|
3af989a83b | ||
|
|
04e9ffad28 | ||
|
|
e24f99ad38 | ||
|
|
4b6736444c | ||
|
|
629e89f380 | ||
|
|
c14c2ddaa2 | ||
|
|
ffaa1e26fd | ||
|
|
4e135bcf43 | ||
|
|
95eed45ec4 | ||
|
|
e7a3162494 | ||
|
|
8cfb149a18 | ||
|
|
55a4f7bf09 | ||
|
|
6a223892dd | ||
|
|
b7cf505ca4 | ||
|
|
7888ccb7e6 | ||
|
|
042f68e777 | ||
|
|
ed2ea23e87 | ||
|
|
cf6d071047 | ||
|
|
d31660f813 | ||
|
|
0fb8195236 | ||
|
|
dd93b78c94 | ||
|
|
bb57b842b9 | ||
|
|
75ac91eecc | ||
|
|
f1d74f922d | ||
|
|
bda5c116fe | ||
|
|
8c02b95934 | ||
|
|
496a8239a5 |
42
.env.example
42
.env.example
@@ -1,9 +1,13 @@
|
|||||||
NODE_ENV=development
|
NODE_ENV=production
|
||||||
# Shared by apps/bot (gateway connection) and apps/webui (narrow REST calls +
|
# Shared by apps/bot (gateway connection) and apps/webui (narrow REST calls +
|
||||||
# BullMQ job enqueueing for guild backups, giveaways and the scheduler).
|
# BullMQ job enqueueing for guild backups, giveaways and the scheduler).
|
||||||
BOT_TOKEN=
|
BOT_TOKEN=
|
||||||
BOT_CLIENT_ID=
|
BOT_CLIENT_ID=
|
||||||
BOT_CLIENT_SECRET=
|
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=
|
BOT_GUILD_ID=
|
||||||
DATABASE_URL=postgresql://nexumi:nexumi@postgres:5432/nexumi
|
DATABASE_URL=postgresql://nexumi:nexumi@postgres:5432/nexumi
|
||||||
REDIS_URL=redis://redis:6379
|
REDIS_URL=redis://redis:6379
|
||||||
@@ -15,12 +19,15 @@ BACKUP_RETENTION_DAYS=14
|
|||||||
BACKUP_CRON=0 3 * * *
|
BACKUP_CRON=0 3 * * *
|
||||||
BACKUP_DIR=/backups
|
BACKUP_DIR=/backups
|
||||||
HEALTH_PORT=8080
|
HEALTH_PORT=8080
|
||||||
|
# Internal bot health/metrics base (Docker healthcheck). Captcha links use WEBUI_URL.
|
||||||
PUBLIC_BASE_URL=http://localhost:8080
|
PUBLIC_BASE_URL=http://localhost:8080
|
||||||
TWITCH_CLIENT_ID=
|
TWITCH_CLIENT_ID=
|
||||||
TWITCH_CLIENT_SECRET=
|
TWITCH_CLIENT_SECRET=
|
||||||
|
|
||||||
# WebUI (apps/webui)
|
# WebUI (apps/webui) – public URL behind Traefik (also used for captcha verification links)
|
||||||
WEBUI_URL=http://10.111.0.65:3000
|
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`.
|
# Must be at least 32 characters long. Generate e.g. with `openssl rand -hex 32`.
|
||||||
SESSION_SECRET=
|
SESSION_SECRET=
|
||||||
# Comma-separated Discord user IDs treated as global bot owners (owner panel, Phase 7 Batch C).
|
# 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)
|
# Public site / bot links (Phase 8)
|
||||||
SUPPORT_SERVER_URL=
|
SUPPORT_SERVER_URL=
|
||||||
LEGAL_OPERATOR_NAME=
|
LEGAL_OPERATOR_NAME=HexaHost Inh. Samuel Müller
|
||||||
LEGAL_OPERATOR_ADDRESS=
|
LEGAL_OPERATOR_ADDRESS=Richard-Miller-Straße 1, 94051 Hauzenberg, Deutschland
|
||||||
LEGAL_OPERATOR_EMAIL=
|
LEGAL_OPERATOR_EMAIL=info@hexahost.de
|
||||||
LEGAL_OPERATOR_PHONE=
|
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
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"css.lint.unknownAtRules": "ignore"
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Nexumi
|
# 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
|
## Phase 1 scope
|
||||||
|
|
||||||
|
|||||||
@@ -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 $$;
|
||||||
@@ -9,13 +9,15 @@
|
|||||||
"lint": "eslint src --ext .ts",
|
"lint": "eslint src --ext .ts",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate --schema=prisma/schema.prisma",
|
||||||
"prisma:migrate": "prisma migrate deploy"
|
"prisma:migrate": "prisma migrate deploy --schema=prisma/schema.prisma",
|
||||||
|
"profile:banner": "tsx scripts/set-bot-banner.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nexumi/shared": "workspace:*",
|
|
||||||
"@napi-rs/canvas": "^0.1.65",
|
"@napi-rs/canvas": "^0.1.65",
|
||||||
|
"@nexumi/shared": "workspace:*",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
|
"@sentry/node": "^10.67.0",
|
||||||
"bullmq": "^5.34.0",
|
"bullmq": "^5.34.0",
|
||||||
"discord.js": "^14.17.3",
|
"discord.js": "^14.17.3",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
|
|||||||
@@ -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[];
|
||||||
@@ -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 $$;
|
||||||
@@ -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 $$;
|
||||||
@@ -20,6 +20,7 @@ model Guild {
|
|||||||
logChannels LogChannel[]
|
logChannels LogChannel[]
|
||||||
welcomeConfig WelcomeConfig?
|
welcomeConfig WelcomeConfig?
|
||||||
verificationConfig VerificationConfig?
|
verificationConfig VerificationConfig?
|
||||||
|
verificationRecords VerificationRecord[]
|
||||||
levelingConfig LevelingConfig?
|
levelingConfig LevelingConfig?
|
||||||
memberLevels MemberLevel[]
|
memberLevels MemberLevel[]
|
||||||
levelRewards LevelReward[]
|
levelRewards LevelReward[]
|
||||||
@@ -51,6 +52,7 @@ model Guild {
|
|||||||
socialFeeds SocialFeed[]
|
socialFeeds SocialFeed[]
|
||||||
scheduledMessages ScheduledMessage[]
|
scheduledMessages ScheduledMessage[]
|
||||||
guildBackups GuildBackup[]
|
guildBackups GuildBackup[]
|
||||||
|
componentMessageBindings ComponentMessageBinding[]
|
||||||
dashboardAccessRules DashboardAccessRule[]
|
dashboardAccessRules DashboardAccessRule[]
|
||||||
dashboardAuditLogs DashboardAuditLog[]
|
dashboardAuditLogs DashboardAuditLog[]
|
||||||
commandOverrides CommandOverride[]
|
commandOverrides CommandOverride[]
|
||||||
@@ -64,6 +66,10 @@ model GuildSettings {
|
|||||||
moderationEnabled Boolean @default(true)
|
moderationEnabled Boolean @default(true)
|
||||||
/// Snipe/editsnipe storage and commands (SPEC: default off for privacy).
|
/// Snipe/editsnipe storage and commands (SPEC: default off for privacy).
|
||||||
snipeEnabled Boolean @default(false)
|
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)
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,8 +247,11 @@ model WelcomeConfig {
|
|||||||
welcomeType String @default("TEXT")
|
welcomeType String @default("TEXT")
|
||||||
welcomeContent String?
|
welcomeContent String?
|
||||||
welcomeEmbed Json?
|
welcomeEmbed Json?
|
||||||
|
welcomeComponents Json?
|
||||||
|
leaveType String @default("TEXT")
|
||||||
leaveContent String?
|
leaveContent String?
|
||||||
leaveEmbed Json?
|
leaveEmbed Json?
|
||||||
|
leaveComponents Json?
|
||||||
welcomeDmEnabled Boolean @default(false)
|
welcomeDmEnabled Boolean @default(false)
|
||||||
welcomeDmContent String?
|
welcomeDmContent String?
|
||||||
userAutoroleIds String[] @default([])
|
userAutoroleIds String[] @default([])
|
||||||
@@ -262,8 +271,15 @@ model VerificationConfig {
|
|||||||
verifiedRoleId String?
|
verifiedRoleId String?
|
||||||
unverifiedRoleId String?
|
unverifiedRoleId String?
|
||||||
mode String @default("BUTTON")
|
mode String @default("BUTTON")
|
||||||
|
/// MATH | RECAPTCHA_V2 | RECAPTCHA_V3 | HCAPTCHA | TURNSTILE
|
||||||
|
captchaProvider String @default("MATH")
|
||||||
minAccountAgeDays Int @default(0)
|
minAccountAgeDays Int @default(0)
|
||||||
failAction String @default("KICK")
|
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?
|
panelChannelId String?
|
||||||
panelMessageId String?
|
panelMessageId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -271,6 +287,26 @@ model VerificationConfig {
|
|||||||
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
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 {
|
model LevelingConfig {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
guildId String @unique
|
guildId String @unique
|
||||||
@@ -551,6 +587,7 @@ model Tag {
|
|||||||
name String
|
name String
|
||||||
content String?
|
content String?
|
||||||
embed Json?
|
embed Json?
|
||||||
|
components Json?
|
||||||
responseType String @default("TEXT")
|
responseType String @default("TEXT")
|
||||||
triggerWord String?
|
triggerWord String?
|
||||||
allowedRoleIds String[] @default([])
|
allowedRoleIds String[] @default([])
|
||||||
@@ -770,6 +807,7 @@ model ScheduledMessage {
|
|||||||
creatorId String
|
creatorId String
|
||||||
content String?
|
content String?
|
||||||
embed Json?
|
embed Json?
|
||||||
|
components Json?
|
||||||
rolePingId String?
|
rolePingId String?
|
||||||
cron String?
|
cron String?
|
||||||
runAt DateTime?
|
runAt DateTime?
|
||||||
@@ -782,6 +820,20 @@ model ScheduledMessage {
|
|||||||
@@index([guildId, enabled])
|
@@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 {
|
model GuildBackup {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
guildId String
|
guildId String
|
||||||
@@ -854,7 +906,7 @@ model FeatureFlag {
|
|||||||
model BotPresenceConfig {
|
model BotPresenceConfig {
|
||||||
id String @id @default("singleton")
|
id String @id @default("singleton")
|
||||||
status String @default("online")
|
status String @default("online")
|
||||||
activityType String @default("Playing")
|
activityType String @default("Watching")
|
||||||
activityText String @default("nexumi.de")
|
activityText String @default("nexumi.de")
|
||||||
rotatingMessages Json?
|
rotatingMessages Json?
|
||||||
maintenanceMode Boolean @default(false)
|
maintenanceMode Boolean @default(false)
|
||||||
|
|||||||
60
apps/bot/scripts/set-bot-banner.ts
Normal file
60
apps/bot/scripts/set-bot-banner.ts
Normal 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);
|
||||||
38
apps/bot/src/command-gate-rules.ts
Normal file
38
apps/bot/src/command-gate-rules.ts
Normal 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;
|
||||||
|
}
|
||||||
32
apps/bot/src/command-gates.test.ts
Normal file
32
apps/bot/src/command-gates.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
92
apps/bot/src/command-gates.ts
Normal file
92
apps/bot/src/command-gates.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -4,11 +4,20 @@ import {
|
|||||||
type ChatInputCommandInteraction,
|
type ChatInputCommandInteraction,
|
||||||
type MessageContextMenuCommandInteraction
|
type MessageContextMenuCommandInteraction
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
import { t } from '@nexumi/shared';
|
import { t, tf, type Locale } from '@nexumi/shared';
|
||||||
import { env } from './env.js';
|
import { env } from './env.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { getGuildLocale } from './i18n.js';
|
import { getGuildLocale } from './i18n.js';
|
||||||
|
import { evaluateCommandGate, type CommandGateReason } from './command-gates.js';
|
||||||
|
import {
|
||||||
|
isGuildBlacklisted,
|
||||||
|
isModuleEnabled,
|
||||||
|
isUserBlacklisted,
|
||||||
|
moduleForCommand
|
||||||
|
} from './module-gates.js';
|
||||||
import { isMaintenanceMode } from './presence.js';
|
import { isMaintenanceMode } from './presence.js';
|
||||||
|
import { recordCommandMetric } from './metrics.js';
|
||||||
|
import { captureException } from './sentry.js';
|
||||||
import { moderationCommands } from './modules/moderation/commands.js';
|
import { moderationCommands } from './modules/moderation/commands.js';
|
||||||
import { automodCommands } from './modules/automod/commands.js';
|
import { automodCommands } from './modules/automod/commands.js';
|
||||||
import { welcomeCommands } from './modules/welcome/commands.js';
|
import { welcomeCommands } from './modules/welcome/commands.js';
|
||||||
@@ -60,43 +69,126 @@ const commands: SlashCommand[] = [
|
|||||||
const map = new Map(commands.map((c) => [c.data.name, c]));
|
const map = new Map(commands.map((c) => [c.data.name, c]));
|
||||||
const contextMenuMap = new Map(utilityContextMenus.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() {
|
export async function registerCommands() {
|
||||||
const rest = new REST({ version: '10' }).setToken(env.BOT_TOKEN);
|
const rest = new REST({ version: '10' }).setToken(env.BOT_TOKEN);
|
||||||
const body = [
|
const body = buildCommandBody();
|
||||||
...commands.map((c) => c.data.toJSON()),
|
|
||||||
...utilityContextMenus.map((c) => c.data.toJSON())
|
|
||||||
];
|
|
||||||
|
|
||||||
// Guild commands sync instantly; global can take up to ~1h.
|
// Guild commands sync instantly; global can take up to ~1h.
|
||||||
if (env.BOT_GUILD_ID) {
|
if (env.BOT_GUILD_ID) {
|
||||||
await rest.put(Routes.applicationGuildCommands(env.BOT_CLIENT_ID, env.BOT_GUILD_ID), {
|
await rest.put(Routes.applicationGuildCommands(env.BOT_CLIENT_ID, env.BOT_GUILD_ID), {
|
||||||
body
|
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(
|
logger.info(
|
||||||
{
|
{
|
||||||
scope: 'guild',
|
scope: 'guild',
|
||||||
guildId: env.BOT_GUILD_ID,
|
guildId: env.BOT_GUILD_ID,
|
||||||
count: commands.length,
|
count: body.length,
|
||||||
contextMenus: utilityContextMenus.length
|
clearedGlobal: true
|
||||||
},
|
},
|
||||||
'Registered guild application commands'
|
'Registered guild application commands and cleared global commands'
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await rest.put(Routes.applicationCommands(env.BOT_CLIENT_ID), { body });
|
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(
|
logger.info(
|
||||||
{
|
{
|
||||||
scope: 'global',
|
scope: 'global',
|
||||||
count: commands.length,
|
count: body.length,
|
||||||
contextMenus: utilityContextMenus.length
|
clearedGuilds
|
||||||
},
|
},
|
||||||
'Registered global application commands'
|
'Registered global application commands and cleared guild-scoped leftovers'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function routeCommand(interaction: ChatInputCommandInteraction, context: BotContext) {
|
export async function routeCommand(interaction: ChatInputCommandInteraction, context: BotContext) {
|
||||||
|
const started = Date.now();
|
||||||
|
let ok = true;
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (await isUserBlacklisted(context.prisma, interaction.user.id)) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'generic.userBlacklisted'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.guildId && (await isGuildBlacklisted(context.prisma, interaction.guildId))) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'generic.guildBlacklisted'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const maintenance = await isMaintenanceMode(context);
|
const maintenance = await isMaintenanceMode(context);
|
||||||
const ownerIds = env.OWNER_USER_IDS ?? [];
|
const ownerIds = env.OWNER_USER_IDS ?? [];
|
||||||
if (maintenance.enabled && !ownerIds.includes(interaction.user.id)) {
|
if (maintenance.enabled && !ownerIds.includes(interaction.user.id)) {
|
||||||
@@ -112,15 +204,73 @@ export async function routeCommand(interaction: ChatInputCommandInteraction, con
|
|||||||
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const moduleId = moduleForCommand(interaction.commandName);
|
||||||
|
if (moduleId && interaction.guildId) {
|
||||||
|
const enabled = await isModuleEnabled(
|
||||||
|
context.prisma,
|
||||||
|
context.redis,
|
||||||
|
interaction.guildId,
|
||||||
|
moduleId
|
||||||
|
);
|
||||||
|
if (!enabled) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'generic.moduleDisabled'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gate = await evaluateCommandGate(interaction, context);
|
||||||
|
if (!gate.ok) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: gateMessage(locale, gate.reason, gate.retryAfterSeconds),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await command.execute(interaction, context);
|
await command.execute(interaction, context);
|
||||||
|
} catch (error) {
|
||||||
|
ok = false;
|
||||||
|
captureException(error, {
|
||||||
|
command: interaction.commandName,
|
||||||
|
guildId: interaction.guildId,
|
||||||
|
userId: interaction.user.id
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await recordCommandMetric(context.redis, {
|
||||||
|
ok,
|
||||||
|
durationMs: Date.now() - started
|
||||||
|
}).catch(() => undefined);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function routeContextMenu(
|
export async function routeContextMenu(
|
||||||
interaction: MessageContextMenuCommandInteraction,
|
interaction: MessageContextMenuCommandInteraction,
|
||||||
context: BotContext
|
context: BotContext
|
||||||
) {
|
) {
|
||||||
const command = contextMenuMap.get(interaction.commandName);
|
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
|
||||||
|
if (await isUserBlacklisted(context.prisma, interaction.user.id)) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'generic.userBlacklisted'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.guildId && (await isGuildBlacklisted(context.prisma, interaction.guildId))) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'generic.guildBlacklisted'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = contextMenuMap.get(interaction.commandName);
|
||||||
if (!command) {
|
if (!command) {
|
||||||
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });
|
||||||
return;
|
return;
|
||||||
|
|||||||
52
apps/bot/src/confirmation-store.test.ts
Normal file
52
apps/bot/src/confirmation-store.test.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { Redis } from 'ioredis';
|
||||||
|
import {
|
||||||
|
deleteConfirmation,
|
||||||
|
getConfirmation,
|
||||||
|
setConfirmation,
|
||||||
|
takeConfirmation
|
||||||
|
} from './confirmation-store.js';
|
||||||
|
|
||||||
|
function createMemoryRedis(): Redis {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
return {
|
||||||
|
async set(key: string, value: string) {
|
||||||
|
store.set(key, value);
|
||||||
|
return 'OK';
|
||||||
|
},
|
||||||
|
async get(key: string) {
|
||||||
|
return store.get(key) ?? null;
|
||||||
|
},
|
||||||
|
async del(key: string) {
|
||||||
|
store.delete(key);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
} as unknown as Redis;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('confirmation-store', () => {
|
||||||
|
it('stores and retrieves pending confirmations', async () => {
|
||||||
|
const redis = createMemoryRedis();
|
||||||
|
await setConfirmation(redis, 'moderation', 'tok-1', { action: 'ban', userId: '1' }, 60);
|
||||||
|
await expect(getConfirmation(redis, 'moderation', 'tok-1')).resolves.toEqual({
|
||||||
|
action: 'ban',
|
||||||
|
userId: '1'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takeConfirmation deletes the entry', async () => {
|
||||||
|
const redis = createMemoryRedis();
|
||||||
|
await setConfirmation(redis, 'moderation', 'tok-2', { action: 'purge' }, 60);
|
||||||
|
await expect(takeConfirmation(redis, 'moderation', 'tok-2')).resolves.toEqual({
|
||||||
|
action: 'purge'
|
||||||
|
});
|
||||||
|
await expect(getConfirmation(redis, 'moderation', 'tok-2')).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteConfirmation removes pending state', async () => {
|
||||||
|
const redis = createMemoryRedis();
|
||||||
|
await setConfirmation(redis, 'guildbackup', 'tok-3', { action: 'delete' }, 60);
|
||||||
|
await deleteConfirmation(redis, 'guildbackup', 'tok-3');
|
||||||
|
await expect(getConfirmation(redis, 'guildbackup', 'tok-3')).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
57
apps/bot/src/confirmation-store.ts
Normal file
57
apps/bot/src/confirmation-store.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import type { Redis } from 'ioredis';
|
||||||
|
|
||||||
|
function key(namespace: string, token: string): string {
|
||||||
|
return `confirm:${namespace}:${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setConfirmation<T extends object>(
|
||||||
|
redis: Redis,
|
||||||
|
namespace: string,
|
||||||
|
token: string,
|
||||||
|
entry: T,
|
||||||
|
ttlSeconds: number
|
||||||
|
): Promise<void> {
|
||||||
|
await redis.set(key(namespace, token), JSON.stringify(entry), 'EX', ttlSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConfirmation<T>(
|
||||||
|
redis: Redis,
|
||||||
|
namespace: string,
|
||||||
|
token: string
|
||||||
|
): Promise<T | null> {
|
||||||
|
const raw = await redis.get(key(namespace, token));
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteConfirmation(
|
||||||
|
redis: Redis,
|
||||||
|
namespace: string,
|
||||||
|
token: string
|
||||||
|
): Promise<void> {
|
||||||
|
await redis.del(key(namespace, token));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function takeConfirmation<T>(
|
||||||
|
redis: Redis,
|
||||||
|
namespace: string,
|
||||||
|
token: string
|
||||||
|
): Promise<T | null> {
|
||||||
|
const k = key(namespace, token);
|
||||||
|
const raw = await redis.get(k);
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await redis.del(k);
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ const EnvSchema = z.object({
|
|||||||
BACKUP_CRON: z.string().default('0 3 * * *'),
|
BACKUP_CRON: z.string().default('0 3 * * *'),
|
||||||
BACKUP_DIR: z.string().default('/backups'),
|
BACKUP_DIR: z.string().default('/backups'),
|
||||||
METRICS_TOKEN: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
|
METRICS_TOKEN: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
|
||||||
|
SENTRY_DSN: z.preprocess(emptyToUndefined, z.string().url().optional()),
|
||||||
HEALTH_PORT: z.coerce.number().int().positive().default(8080),
|
HEALTH_PORT: z.coerce.number().int().positive().default(8080),
|
||||||
PUBLIC_BASE_URL: z.string().url().default('http://localhost:8080'),
|
PUBLIC_BASE_URL: z.string().url().default('http://localhost:8080'),
|
||||||
TWITCH_CLIENT_ID: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
|
TWITCH_CLIENT_ID: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
|
||||||
@@ -38,7 +39,9 @@ const EnvSchema = z.object({
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
WEBUI_URL: z.preprocess(emptyToUndefined, z.string().url().optional()),
|
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);
|
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 {
|
export function buildBotInviteUrl(clientId = env.BOT_CLIENT_ID): string {
|
||||||
const url = new URL('https://discord.com/api/oauth2/authorize');
|
const url = new URL('https://discord.com/api/oauth2/authorize');
|
||||||
url.searchParams.set('client_id', clientId);
|
url.searchParams.set('client_id', clientId);
|
||||||
url.searchParams.set('permissions', '8');
|
url.searchParams.set('permissions', '4786708802825463');
|
||||||
url.searchParams.set('scope', 'bot applications.commands');
|
url.searchParams.set('scope', 'bot applications.commands');
|
||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|||||||
44
apps/bot/src/harden.test.ts
Normal file
44
apps/bot/src/harden.test.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { formatPrometheus, type MetricsSnapshot } from './metrics.js';
|
||||||
|
|
||||||
|
describe('confirmation custom_id matchers', () => {
|
||||||
|
it('detects moderation confirm/cancel prefixes', () => {
|
||||||
|
const isMod = (id: string) => id.startsWith('mod:confirm:') || id.startsWith('mod:cancel:');
|
||||||
|
expect(isMod('mod:confirm:abc')).toBe(true);
|
||||||
|
expect(isMod('mod:cancel:abc')).toBe(true);
|
||||||
|
expect(isMod('other')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects guildbackup confirm/cancel prefixes', () => {
|
||||||
|
const isBackup = (id: string) =>
|
||||||
|
id.startsWith('gbackup:confirm:delete:') ||
|
||||||
|
id.startsWith('gbackup:confirm:restore:') ||
|
||||||
|
id.startsWith('gbackup:cancel:');
|
||||||
|
expect(isBackup('gbackup:confirm:delete:abc')).toBe(true);
|
||||||
|
expect(isBackup('gbackup:confirm:restore:abc')).toBe(true);
|
||||||
|
expect(isBackup('gbackup:cancel:abc')).toBe(true);
|
||||||
|
expect(isBackup('mod:confirm:abc')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatPrometheus', () => {
|
||||||
|
it('emits core counters and shard gauges', () => {
|
||||||
|
const snapshot: MetricsSnapshot = {
|
||||||
|
up: 1,
|
||||||
|
commandsTotal: 10,
|
||||||
|
commandsErrors: 2,
|
||||||
|
commandDurationSumMs: 500,
|
||||||
|
commandDurationCount: 10,
|
||||||
|
eventsTotal: 100,
|
||||||
|
shards: [{ id: 0, ping: 42, guilds: 5, updatedAt: Date.now() }],
|
||||||
|
queueWaiting: 3
|
||||||
|
};
|
||||||
|
const body = formatPrometheus(snapshot);
|
||||||
|
expect(body).toContain('nexumi_up 1');
|
||||||
|
expect(body).toContain('nexumi_commands_total 10');
|
||||||
|
expect(body).toContain('nexumi_commands_errors_total 2');
|
||||||
|
expect(body).toContain('nexumi_guilds{shard="0"} 5');
|
||||||
|
expect(body).toContain('nexumi_shard_ping_ms{shard="0"} 42');
|
||||||
|
expect(body).toContain('nexumi_queue_waiting 3');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,17 @@ import {
|
|||||||
getCaptchaChallenge,
|
getCaptchaChallenge,
|
||||||
hashCaptchaAnswer
|
hashCaptchaAnswer
|
||||||
} from './modules/verification/service.js';
|
} from './modules/verification/service.js';
|
||||||
import { verificationQueue } from './queues.js';
|
import {
|
||||||
|
automodQueue,
|
||||||
|
backupQueue,
|
||||||
|
birthdayQueue,
|
||||||
|
feedsQueue,
|
||||||
|
presenceQueue,
|
||||||
|
retentionQueue,
|
||||||
|
ticketQueue,
|
||||||
|
verificationQueue
|
||||||
|
} from './queues.js';
|
||||||
|
import { collectMetricsSnapshot, formatPrometheus } from './metrics.js';
|
||||||
|
|
||||||
function readBody(req: IncomingMessage): Promise<string> {
|
function readBody(req: IncomingMessage): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -61,9 +71,17 @@ async function handleCaptchaGet(url: URL, res: ServerResponse): Promise<void> {
|
|||||||
res.end('Captcha expired');
|
res.end('Captcha expired');
|
||||||
return;
|
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.statusCode = 200;
|
||||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
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> {
|
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');
|
res.end('Captcha expired');
|
||||||
return;
|
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.statusCode = 200;
|
||||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
await deleteCaptchaChallenge(redis, token);
|
await deleteCaptchaChallenge(redis, token);
|
||||||
await verificationQueue.add(
|
await verificationQueue.add(
|
||||||
'verificationComplete',
|
'verificationComplete',
|
||||||
{ guildId: challenge.guildId, userId: challenge.userId },
|
{
|
||||||
|
guildId: challenge.guildId,
|
||||||
|
userId: challenge.userId,
|
||||||
|
captchaProvider: challenge.provider
|
||||||
|
},
|
||||||
{ removeOnComplete: 1000, removeOnFail: 500 }
|
{ removeOnComplete: 1000, removeOnFail: 500 }
|
||||||
);
|
);
|
||||||
res.statusCode = 200;
|
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>');
|
res.end('<html><body style="font-family:sans-serif;background:#111827;color:#f9fafb;display:flex;justify-content:center;align-items:center;min-height:100vh"><div><h1>Verified</h1><p>You can return to Discord.</p></div></body></html>');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function approximateQueueWaiting(): Promise<number> {
|
||||||
|
const counts = await Promise.all([
|
||||||
|
automodQueue.getWaitingCount(),
|
||||||
|
backupQueue.getWaitingCount(),
|
||||||
|
birthdayQueue.getWaitingCount(),
|
||||||
|
feedsQueue.getWaitingCount(),
|
||||||
|
presenceQueue.getWaitingCount(),
|
||||||
|
retentionQueue.getWaitingCount(),
|
||||||
|
ticketQueue.getWaitingCount()
|
||||||
|
]);
|
||||||
|
return counts.reduce((sum, n) => sum + n, 0);
|
||||||
|
}
|
||||||
|
|
||||||
export function startHealthServer(): void {
|
export function startHealthServer(): void {
|
||||||
const server = createServer(async (req, res) => {
|
const server = createServer(async (req, res) => {
|
||||||
if (!req.url) {
|
if (!req.url) {
|
||||||
@@ -121,8 +168,16 @@ export function startHealthServer(): void {
|
|||||||
res.end('Unauthorized');
|
res.end('Unauthorized');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const snapshot = await collectMetricsSnapshot(redis);
|
||||||
|
snapshot.queueWaiting = await approximateQueueWaiting();
|
||||||
res.statusCode = 200;
|
res.statusCode = 200;
|
||||||
res.end('# Prometheus metrics scaffold\nnexumi_up 1\n');
|
res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
|
||||||
|
res.end(formatPrometheus(snapshot));
|
||||||
|
} catch {
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.end('metrics error');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
141
apps/bot/src/hierarchy.ts
Normal file
141
apps/bot/src/hierarchy.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import {
|
||||||
|
type ChatInputCommandInteraction,
|
||||||
|
type GuildMember,
|
||||||
|
type Interaction
|
||||||
|
} from 'discord.js';
|
||||||
|
import type { Locale } from '@nexumi/shared';
|
||||||
|
import { t } from '@nexumi/shared';
|
||||||
|
|
||||||
|
export type HierarchyAction = 'ban' | 'kick' | 'timeout' | 'nick';
|
||||||
|
|
||||||
|
export type HierarchyOk = { ok: true; member: GuildMember | null };
|
||||||
|
export type HierarchyDenied = { ok: false };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that the moderator and bot can act on the target member.
|
||||||
|
* Replies ephemerally on denial. For bans, `member` may be null if the user
|
||||||
|
* already left the guild (ban-by-id is still allowed).
|
||||||
|
*/
|
||||||
|
export async function assertModerableMember(
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
targetUserId: string,
|
||||||
|
locale: Locale,
|
||||||
|
action: HierarchyAction
|
||||||
|
): Promise<HierarchyOk | HierarchyDenied> {
|
||||||
|
const guild = interaction.guild;
|
||||||
|
if (!guild) {
|
||||||
|
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetUserId === interaction.user.id) {
|
||||||
|
await interaction.reply({ content: t(locale, 'moderation.hierarchy.self'), ephemeral: true });
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetUserId === guild.ownerId) {
|
||||||
|
await interaction.reply({ content: t(locale, 'moderation.hierarchy.owner'), ephemeral: true });
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
let member: GuildMember | null = null;
|
||||||
|
try {
|
||||||
|
member = await guild.members.fetch(targetUserId);
|
||||||
|
} catch {
|
||||||
|
if (action === 'ban') {
|
||||||
|
return { ok: true, member: null };
|
||||||
|
}
|
||||||
|
await interaction.reply({ content: t(locale, 'moderation.hierarchy.notMember'), ephemeral: true });
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const moderator =
|
||||||
|
interaction.member && 'roles' in interaction.member
|
||||||
|
? (interaction.member as GuildMember)
|
||||||
|
: await guild.members.fetch(interaction.user.id);
|
||||||
|
|
||||||
|
if (guild.ownerId !== moderator.id) {
|
||||||
|
if (member.roles.highest.position >= moderator.roles.highest.position) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'moderation.hierarchy.moderator'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const botOk =
|
||||||
|
action === 'ban'
|
||||||
|
? member.bannable
|
||||||
|
: action === 'kick'
|
||||||
|
? member.kickable
|
||||||
|
: action === 'timeout'
|
||||||
|
? member.moderatable
|
||||||
|
: member.manageable;
|
||||||
|
|
||||||
|
if (!botOk) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'moderation.hierarchy.bot'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, member };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replyHierarchyForButton(interaction: Interaction, locale: Locale, key: string): Promise<void> {
|
||||||
|
if (!interaction.isRepliable()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const content = t(locale, key);
|
||||||
|
if (interaction.isButton()) {
|
||||||
|
await interaction.update({ content, components: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (interaction.replied || interaction.deferred) {
|
||||||
|
await interaction.followUp({ content, ephemeral: true });
|
||||||
|
} else {
|
||||||
|
await interaction.reply({ content, ephemeral: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertBannableForConfirm(
|
||||||
|
interaction: Interaction,
|
||||||
|
locale: Locale,
|
||||||
|
targetUserId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const guild = interaction.guild;
|
||||||
|
if (!guild) {
|
||||||
|
await replyHierarchyForButton(interaction, locale, 'generic.guildOnly');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (targetUserId === interaction.user.id) {
|
||||||
|
await replyHierarchyForButton(interaction, locale, 'moderation.hierarchy.self');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (targetUserId === guild.ownerId) {
|
||||||
|
await replyHierarchyForButton(interaction, locale, 'moderation.hierarchy.owner');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const member = await guild.members.fetch(targetUserId);
|
||||||
|
if (!member.bannable) {
|
||||||
|
await replyHierarchyForButton(interaction, locale, 'moderation.hierarchy.bot');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const moderator = await guild.members.fetch(interaction.user.id);
|
||||||
|
if (
|
||||||
|
guild.ownerId !== moderator.id &&
|
||||||
|
member.roles.highest.position >= moderator.roles.highest.position
|
||||||
|
) {
|
||||||
|
await replyHierarchyForButton(interaction, locale, 'moderation.hierarchy.moderator');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Target left the guild — ban by id is still fine.
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -14,60 +14,30 @@ import { registerCommands, routeCommand, routeContextMenu } from './commands.js'
|
|||||||
import { ensureRecurringJobs, startWorkers } from './jobs.js';
|
import { ensureRecurringJobs, startWorkers } from './jobs.js';
|
||||||
import type { BotContext } from './types.js';
|
import type { BotContext } from './types.js';
|
||||||
import { startHealthServer } from './health.js';
|
import { startHealthServer } from './health.js';
|
||||||
import {
|
|
||||||
handleModerationConfirmation,
|
|
||||||
isModerationConfirmation
|
|
||||||
} from './modules/moderation/confirmations.js';
|
|
||||||
import { getGuildLocale } from './i18n.js';
|
import { getGuildLocale } from './i18n.js';
|
||||||
import { t } from '@nexumi/shared';
|
import { t } from '@nexumi/shared';
|
||||||
import { handleAutoModMessage } from './modules/automod/actions.js';
|
import { handleAutoModMessage } from './modules/automod/actions.js';
|
||||||
import { registerLoggingEvents } from './modules/logging/events.js';
|
import { registerLoggingEvents } from './modules/logging/events.js';
|
||||||
import { registerWelcomeEvents } from './modules/welcome/events.js';
|
import { registerWelcomeEvents } from './modules/welcome/events.js';
|
||||||
import { registerVerificationEvents } from './modules/verification/events.js';
|
import { registerVerificationEvents } from './modules/verification/events.js';
|
||||||
import { handleVerificationButton } from './modules/verification/handlers.js';
|
|
||||||
import { isVerificationButton } from './modules/verification/service.js';
|
|
||||||
import { registerLevelingEvents } from './modules/leveling/events.js';
|
import { registerLevelingEvents } from './modules/leveling/events.js';
|
||||||
import {
|
import { registerUtilityEvents } from './modules/utility/index.js';
|
||||||
handleBlackjackButton,
|
import { registerTicketEvents } from './modules/tickets/index.js';
|
||||||
isBlackjackButton
|
import { registerSelfRoleEvents } from './modules/selfroles/index.js';
|
||||||
} from './modules/economy/blackjack-buttons.js';
|
|
||||||
import {
|
|
||||||
handleEmbedModal,
|
|
||||||
handlePollButton,
|
|
||||||
isEmbedModal,
|
|
||||||
isPollButton,
|
|
||||||
registerUtilityEvents
|
|
||||||
} from './modules/utility/index.js';
|
|
||||||
import { handleFunButton, isFunButton } from './modules/fun/index.js';
|
|
||||||
import { handleGiveawayButton, isGiveawayButton } from './modules/giveaways/index.js';
|
|
||||||
import {
|
|
||||||
handleTicketInteraction,
|
|
||||||
isTicketInteraction,
|
|
||||||
registerTicketEvents
|
|
||||||
} from './modules/tickets/index.js';
|
|
||||||
import {
|
|
||||||
handleSelfRoleInteraction,
|
|
||||||
isSelfRoleInteraction,
|
|
||||||
registerSelfRoleEvents
|
|
||||||
} from './modules/selfroles/index.js';
|
|
||||||
import { registerTagEvents } from './modules/tags/index.js';
|
import { registerTagEvents } from './modules/tags/index.js';
|
||||||
import { registerStarboardEvents } from './modules/starboard/index.js';
|
import { registerStarboardEvents } from './modules/starboard/index.js';
|
||||||
import {
|
import { registerTempVoiceEvents } from './modules/tempvoice/index.js';
|
||||||
handleSuggestionButton,
|
|
||||||
isSuggestionButton
|
|
||||||
} from './modules/suggestions/index.js';
|
|
||||||
import {
|
|
||||||
handleTempVoiceInteraction,
|
|
||||||
isTempVoiceInteraction,
|
|
||||||
isTempVoiceModal,
|
|
||||||
registerTempVoiceEvents
|
|
||||||
} from './modules/tempvoice/index.js';
|
|
||||||
import { registerStatsEvents } from './modules/stats/index.js';
|
import { registerStatsEvents } from './modules/stats/index.js';
|
||||||
import { handleGuildBackupButton, isGuildBackupButton } from './modules/guildbackup/index.js';
|
import { routeComponentInteraction } from './interaction-registry.js';
|
||||||
|
import { 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');
|
const isShard = process.argv.includes('--shard');
|
||||||
|
|
||||||
if (!isShard) {
|
if (!isShard) {
|
||||||
|
initSentry('bot-manager');
|
||||||
startHealthServer();
|
startHealthServer();
|
||||||
const manager = new ShardingManager(new URL('./index.js', import.meta.url).pathname, {
|
const manager = new ShardingManager(new URL('./index.js', import.meta.url).pathname, {
|
||||||
token: env.BOT_TOKEN,
|
token: env.BOT_TOKEN,
|
||||||
@@ -75,8 +45,25 @@ if (!isShard) {
|
|||||||
shardArgs: ['--shard']
|
shardArgs: ['--shard']
|
||||||
});
|
});
|
||||||
manager.on('shardCreate', (shard) => logger.info({ shardId: shard.id }, 'Shard spawned'));
|
manager.on('shardCreate', (shard) => logger.info({ shardId: shard.id }, 'Shard spawned'));
|
||||||
|
|
||||||
|
// Register slash commands once from the manager (not once per shard).
|
||||||
|
try {
|
||||||
|
await runOncePerCluster(
|
||||||
|
'nexumi:lock:register-commands',
|
||||||
|
300,
|
||||||
|
async () => {
|
||||||
|
await registerCommands();
|
||||||
|
},
|
||||||
|
'Command registration'
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Failed to register application commands from manager');
|
||||||
|
captureException(error, { phase: 'registerCommands' });
|
||||||
|
}
|
||||||
|
|
||||||
await manager.spawn();
|
await manager.spawn();
|
||||||
} else {
|
} else {
|
||||||
|
initSentry('bot-shard');
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
intents: [
|
intents: [
|
||||||
GatewayIntentBits.Guilds,
|
GatewayIntentBits.Guilds,
|
||||||
@@ -99,7 +86,7 @@ if (!isShard) {
|
|||||||
|
|
||||||
const context: BotContext = { client, prisma, redis };
|
const context: BotContext = { client, prisma, redis };
|
||||||
startWorkers(context);
|
startWorkers(context);
|
||||||
await ensureRecurringJobs();
|
|
||||||
registerLoggingEvents(context);
|
registerLoggingEvents(context);
|
||||||
registerWelcomeEvents(context);
|
registerWelcomeEvents(context);
|
||||||
registerVerificationEvents(context);
|
registerVerificationEvents(context);
|
||||||
@@ -113,12 +100,24 @@ if (!isShard) {
|
|||||||
registerStatsEvents(context);
|
registerStatsEvents(context);
|
||||||
|
|
||||||
client.on(Events.ClientReady, async () => {
|
client.on(Events.ClientReady, async () => {
|
||||||
logger.info({ tag: client.user?.tag }, 'Bot ready');
|
logger.info({ tag: client.user?.tag, shards: client.shard?.ids }, 'Bot ready');
|
||||||
|
|
||||||
|
if (isPrimaryShard(client)) {
|
||||||
try {
|
try {
|
||||||
await registerCommands();
|
await runOncePerCluster(
|
||||||
|
'nexumi:lock:ensure-recurring-jobs',
|
||||||
|
120,
|
||||||
|
async () => {
|
||||||
|
await ensureRecurringJobs();
|
||||||
|
},
|
||||||
|
'Recurring job registration'
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, 'Failed to register application commands');
|
logger.error({ error }, 'Failed to ensure recurring jobs');
|
||||||
|
captureException(error, { phase: 'ensureRecurringJobs' });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { applyBotPresence, publishBotStatus } = await import('./presence.js');
|
const { applyBotPresence, publishBotStatus } = await import('./presence.js');
|
||||||
await applyBotPresence(context);
|
await applyBotPresence(context);
|
||||||
@@ -126,18 +125,61 @@ if (!isShard) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn({ error }, 'Failed to apply initial bot presence/status');
|
logger.warn({ error }, 'Failed to apply initial bot presence/status');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await publishShardMetrics(context.redis, client);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ error }, 'Failed to publish initial shard metrics');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
client.on(Events.GuildCreate, async (guild) => {
|
||||||
|
try {
|
||||||
|
if (await isGuildBlacklisted(context.prisma, guild.id)) {
|
||||||
|
logger.info({ guildId: guild.id }, 'Leaving blacklisted guild');
|
||||||
|
await guild.leave();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error, guildId: guild.id }, 'GuildCreate blacklist check failed');
|
||||||
|
captureException(error, { guildId: guild.id });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
publishShardMetrics(context.redis, client).catch(() => undefined);
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
client.on(Events.MessageCreate, async (message) => {
|
client.on(Events.MessageCreate, async (message) => {
|
||||||
try {
|
try {
|
||||||
|
await recordEventMetric(context.redis);
|
||||||
|
if (message.author.bot) return;
|
||||||
|
if (await isUserBlacklisted(context.prisma, message.author.id)) return;
|
||||||
|
if (message.guildId && (await isGuildBlacklisted(context.prisma, message.guildId))) return;
|
||||||
await handleAutoModMessage(context, message);
|
await handleAutoModMessage(context, message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error, messageId: message.id }, 'AutoMod message handler failed');
|
logger.error({ error, messageId: message.id }, 'AutoMod message handler failed');
|
||||||
|
captureException(error, { messageId: message.id });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on(Events.InteractionCreate, async (interaction: Interaction) => {
|
client.on(Events.InteractionCreate, async (interaction: Interaction) => {
|
||||||
try {
|
try {
|
||||||
|
if (
|
||||||
|
interaction.user &&
|
||||||
|
(await isUserBlacklisted(context.prisma, interaction.user.id)) &&
|
||||||
|
!interaction.isChatInputCommand() &&
|
||||||
|
!interaction.isMessageContextMenuCommand()
|
||||||
|
) {
|
||||||
|
if (interaction.isRepliable() && !interaction.replied && !interaction.deferred) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'generic.userBlacklisted'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (interaction.isChatInputCommand()) {
|
if (interaction.isChatInputCommand()) {
|
||||||
await routeCommand(interaction, context);
|
await routeCommand(interaction, context);
|
||||||
return;
|
return;
|
||||||
@@ -148,80 +190,23 @@ if (!isShard) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.isButton() && isModerationConfirmation(interaction.customId)) {
|
|
||||||
await handleModerationConfirmation(interaction, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interaction.isButton() && isVerificationButton(interaction.customId)) {
|
|
||||||
await handleVerificationButton(interaction, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interaction.isButton() && isBlackjackButton(interaction.customId)) {
|
|
||||||
await handleBlackjackButton(interaction, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interaction.isButton() && isPollButton(interaction.customId)) {
|
|
||||||
await handlePollButton(interaction, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interaction.isButton() && isFunButton(interaction.customId)) {
|
|
||||||
await handleFunButton(interaction, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interaction.isButton() && isGiveawayButton(interaction.customId)) {
|
|
||||||
await handleGiveawayButton(interaction, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(interaction.isButton() ||
|
interaction.isButton() ||
|
||||||
interaction.isStringSelectMenu() ||
|
interaction.isStringSelectMenu() ||
|
||||||
interaction.isModalSubmit()) &&
|
interaction.isUserSelectMenu() ||
|
||||||
isTicketInteraction(interaction.customId)
|
interaction.isRoleSelectMenu() ||
|
||||||
|
interaction.isChannelSelectMenu() ||
|
||||||
|
interaction.isMentionableSelectMenu() ||
|
||||||
|
interaction.isModalSubmit()
|
||||||
) {
|
) {
|
||||||
await handleTicketInteraction(interaction, context);
|
await routeComponentInteraction(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;
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, 'Interaction handling failed');
|
logger.error({ error }, 'Interaction handling failed');
|
||||||
|
captureException(error, {
|
||||||
|
interactionType: interaction.type,
|
||||||
|
guildId: interaction.guildId
|
||||||
|
});
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
const message = t(locale, 'generic.error');
|
const message = t(locale, 'generic.error');
|
||||||
if (interaction.isRepliable()) {
|
if (interaction.isRepliable()) {
|
||||||
|
|||||||
154
apps/bot/src/interaction-registry.ts
Normal file
154
apps/bot/src/interaction-registry.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import type {
|
||||||
|
ButtonInteraction,
|
||||||
|
ChannelSelectMenuInteraction,
|
||||||
|
MentionableSelectMenuInteraction,
|
||||||
|
ModalSubmitInteraction,
|
||||||
|
RoleSelectMenuInteraction,
|
||||||
|
StringSelectMenuInteraction,
|
||||||
|
UserSelectMenuInteraction
|
||||||
|
} from 'discord.js';
|
||||||
|
import type { BotContext } from './types.js';
|
||||||
|
import {
|
||||||
|
handleModerationConfirmation,
|
||||||
|
isModerationConfirmation
|
||||||
|
} from './modules/moderation/confirmations.js';
|
||||||
|
import { handleVerificationButton } from './modules/verification/handlers.js';
|
||||||
|
import { isVerificationButton } from './modules/verification/service.js';
|
||||||
|
import {
|
||||||
|
handleBlackjackButton,
|
||||||
|
isBlackjackButton
|
||||||
|
} from './modules/economy/blackjack-buttons.js';
|
||||||
|
import {
|
||||||
|
handleEmbedModal,
|
||||||
|
handlePollButton,
|
||||||
|
isEmbedModal,
|
||||||
|
isPollButton
|
||||||
|
} from './modules/utility/index.js';
|
||||||
|
import { handleFunButton, isFunButton } from './modules/fun/index.js';
|
||||||
|
import { handleGiveawayButton, isGiveawayButton } from './modules/giveaways/index.js';
|
||||||
|
import { handleTicketInteraction, isTicketInteraction } from './modules/tickets/index.js';
|
||||||
|
import {
|
||||||
|
handleSelfRoleInteraction,
|
||||||
|
isSelfRoleInteraction
|
||||||
|
} from './modules/selfroles/index.js';
|
||||||
|
import {
|
||||||
|
handleSuggestionButton,
|
||||||
|
isSuggestionButton
|
||||||
|
} from './modules/suggestions/index.js';
|
||||||
|
import {
|
||||||
|
handleTempVoiceInteraction,
|
||||||
|
isTempVoiceInteraction,
|
||||||
|
isTempVoiceModal
|
||||||
|
} from './modules/tempvoice/index.js';
|
||||||
|
import { handleGuildBackupButton, isGuildBackupButton } from './modules/guildbackup/index.js';
|
||||||
|
import {
|
||||||
|
handleComponentsV2Interaction,
|
||||||
|
isComponentsV2Interaction
|
||||||
|
} from './modules/components-v2/index.js';
|
||||||
|
|
||||||
|
type AnyComponentInteraction =
|
||||||
|
| ButtonInteraction
|
||||||
|
| StringSelectMenuInteraction
|
||||||
|
| UserSelectMenuInteraction
|
||||||
|
| RoleSelectMenuInteraction
|
||||||
|
| ChannelSelectMenuInteraction
|
||||||
|
| MentionableSelectMenuInteraction
|
||||||
|
| ModalSubmitInteraction;
|
||||||
|
|
||||||
|
type InteractionHandler = {
|
||||||
|
match: (customId: string) => boolean;
|
||||||
|
handle: (interaction: AnyComponentInteraction, context: BotContext) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function asButton(interaction: AnyComponentInteraction): ButtonInteraction {
|
||||||
|
return interaction as ButtonInteraction;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asModal(interaction: AnyComponentInteraction): ModalSubmitInteraction {
|
||||||
|
return interaction as ModalSubmitInteraction;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlers: InteractionHandler[] = [
|
||||||
|
{
|
||||||
|
match: isModerationConfirmation,
|
||||||
|
handle: (interaction, context) => handleModerationConfirmation(asButton(interaction), context)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: isVerificationButton,
|
||||||
|
handle: (interaction, context) => handleVerificationButton(asButton(interaction), context)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: isBlackjackButton,
|
||||||
|
handle: (interaction, context) => handleBlackjackButton(asButton(interaction), context)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: isPollButton,
|
||||||
|
handle: (interaction, context) => handlePollButton(asButton(interaction), context)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: isFunButton,
|
||||||
|
handle: (interaction, context) => handleFunButton(asButton(interaction), context)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: isGiveawayButton,
|
||||||
|
handle: (interaction, context) => handleGiveawayButton(asButton(interaction), context)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: isTicketInteraction,
|
||||||
|
handle: (interaction, context) =>
|
||||||
|
handleTicketInteraction(
|
||||||
|
interaction as Parameters<typeof handleTicketInteraction>[0],
|
||||||
|
context
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: isSelfRoleInteraction,
|
||||||
|
handle: (interaction, context) =>
|
||||||
|
handleSelfRoleInteraction(
|
||||||
|
interaction as Parameters<typeof handleSelfRoleInteraction>[0],
|
||||||
|
context
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: isSuggestionButton,
|
||||||
|
handle: (interaction, context) => handleSuggestionButton(asButton(interaction), context)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: (customId) => isTempVoiceInteraction(customId) || isTempVoiceModal(customId),
|
||||||
|
handle: (interaction, context) =>
|
||||||
|
handleTempVoiceInteraction(
|
||||||
|
interaction as Parameters<typeof handleTempVoiceInteraction>[0],
|
||||||
|
context
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: isGuildBackupButton,
|
||||||
|
handle: (interaction, context) => handleGuildBackupButton(asButton(interaction), context)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: isEmbedModal,
|
||||||
|
handle: (interaction, context) => handleEmbedModal(asModal(interaction), context)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: isComponentsV2Interaction,
|
||||||
|
handle: (interaction, context) =>
|
||||||
|
handleComponentsV2Interaction(
|
||||||
|
interaction as Parameters<typeof handleComponentsV2Interaction>[0],
|
||||||
|
context
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function routeComponentInteraction(
|
||||||
|
interaction: AnyComponentInteraction,
|
||||||
|
context: BotContext
|
||||||
|
): Promise<boolean> {
|
||||||
|
const customId = interaction.customId;
|
||||||
|
for (const handler of handlers) {
|
||||||
|
if (handler.match(customId)) {
|
||||||
|
await handler.handle(interaction, context);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
6
apps/bot/src/interaction-reply.ts
Normal file
6
apps/bot/src/interaction-reply.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -8,16 +8,18 @@ import { logger } from './logger.js';
|
|||||||
import type { BotContext } from './types.js';
|
import type { BotContext } from './types.js';
|
||||||
import { env } from './env.js';
|
import { env } from './env.js';
|
||||||
import { refreshPhishingDomains } from './modules/automod/filters.js';
|
import { refreshPhishingDomains } from './modules/automod/filters.js';
|
||||||
|
import { activateLockdown, deactivateLockdown } from './modules/automod/actions.js';
|
||||||
import { completeVerification } from './modules/verification/handlers.js';
|
import { completeVerification } from './modules/verification/handlers.js';
|
||||||
import { closePoll } from './modules/utility/poll.js';
|
import { closePoll } from './modules/utility/poll.js';
|
||||||
import { sendReminder } from './modules/utility/reminders.js';
|
import { sendReminder } from './modules/utility/reminders.js';
|
||||||
import { expireSelfRole } from './modules/selfroles/service.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 { closeInactiveTickets } from './modules/tickets/index.js';
|
||||||
import { expireBirthdayRole, runBirthdayCheck } from './modules/birthdays/index.js';
|
import { expireBirthdayRole, runBirthdayCheck } from './modules/birthdays/index.js';
|
||||||
import { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/index.js';
|
import { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/index.js';
|
||||||
import { pollAllFeeds } from './modules/feeds/index.js';
|
import { pollAllFeeds } from './modules/feeds/index.js';
|
||||||
import { sendScheduledMessage } from './modules/scheduler/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 { runGuildBackupRestoreJob } from './modules/guildbackup/index.js';
|
||||||
import { runSuggestionStatusUpdateJob } from './modules/suggestions/index.js';
|
import { runSuggestionStatusUpdateJob } from './modules/suggestions/index.js';
|
||||||
import { runPresenceRefreshJob } from './presence.js';
|
import { runPresenceRefreshJob } from './presence.js';
|
||||||
@@ -32,6 +34,7 @@ import {
|
|||||||
feedsQueueName,
|
feedsQueueName,
|
||||||
giveawayQueueName,
|
giveawayQueueName,
|
||||||
guildBackupQueueName,
|
guildBackupQueueName,
|
||||||
|
messagesQueueName,
|
||||||
moderationQueueName,
|
moderationQueueName,
|
||||||
presenceQueue,
|
presenceQueue,
|
||||||
presenceQueueName,
|
presenceQueueName,
|
||||||
@@ -63,6 +66,9 @@ type TempBanJob = {
|
|||||||
type VerificationCompleteJob = {
|
type VerificationCompleteJob = {
|
||||||
guildId: string;
|
guildId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
ipHash?: string | null;
|
||||||
|
userAgentHash?: string | null;
|
||||||
|
captchaProvider?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ReminderSendJob = {
|
type ReminderSendJob = {
|
||||||
@@ -117,12 +123,70 @@ export function startWorkers(context: BotContext): Worker[] {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const guild = await context.client.guilds.fetch(job.data.guildId);
|
const { guildId, userId, reason } = job.data;
|
||||||
|
const guild = context.client.guilds.cache.get(guildId);
|
||||||
|
if (!guild) {
|
||||||
|
if (!context.client.shard) {
|
||||||
|
throw new Error(`Guild ${guildId} not available for temp ban expiration`);
|
||||||
|
}
|
||||||
|
const results = await context.client.shard.broadcastEval(
|
||||||
|
async (c, payload) => {
|
||||||
|
const g = c.guilds.cache.get(payload.guildId);
|
||||||
|
if (!g) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const me = await g.members.fetchMe();
|
||||||
|
if (!me.permissions.has(PermissionFlagsBits.BanMembers)) {
|
||||||
|
throw new Error('Missing BanMembers permission for temp ban expiration');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await g.members.unban(payload.userId, payload.reason ?? 'Temporary ban expired');
|
||||||
|
} catch (error) {
|
||||||
|
const code =
|
||||||
|
typeof error === 'object' && error && 'code' in error
|
||||||
|
? Number((error as { code: unknown }).code)
|
||||||
|
: 0;
|
||||||
|
// Unknown ban (10026) — already unbanned
|
||||||
|
if (code === 10026) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{ context: { guildId, userId, reason } }
|
||||||
|
);
|
||||||
|
if (!results.some(Boolean)) {
|
||||||
|
throw new Error(`Guild ${guildId} not found on any shard for temp ban expiration`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
const me = await guild.members.fetchMe();
|
const me = await guild.members.fetchMe();
|
||||||
if (!me.permissions.has(PermissionFlagsBits.BanMembers)) {
|
if (!me.permissions.has(PermissionFlagsBits.BanMembers)) {
|
||||||
throw new Error('Missing BanMembers permission for temp ban expiration');
|
throw new Error('Missing BanMembers permission for temp ban expiration');
|
||||||
}
|
}
|
||||||
await guild.members.unban(job.data.userId, job.data.reason ?? 'Temporary ban expired');
|
try {
|
||||||
|
await guild.members.unban(userId, reason ?? 'Temporary ban expired');
|
||||||
|
} catch (error) {
|
||||||
|
const code =
|
||||||
|
typeof error === 'object' && error && 'code' in error
|
||||||
|
? Number((error as { code: unknown }).code)
|
||||||
|
: 0;
|
||||||
|
if (code !== 10026) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { createCase } = await import('./modules/moderation/service.js');
|
||||||
|
await createCase(context, {
|
||||||
|
guildId,
|
||||||
|
targetUserId: userId,
|
||||||
|
moderatorId: context.client.user?.id ?? 'system',
|
||||||
|
action: 'UNBAN',
|
||||||
|
reason: reason ?? 'Temporary ban expired',
|
||||||
|
source: 'job',
|
||||||
|
metadata: { tempBanExpire: true }
|
||||||
|
});
|
||||||
},
|
},
|
||||||
{ connection: redis }
|
{ connection: redis }
|
||||||
);
|
);
|
||||||
@@ -149,11 +213,28 @@ export function startWorkers(context: BotContext): Worker[] {
|
|||||||
const automodWorker = new Worker(
|
const automodWorker = new Worker(
|
||||||
automodQueueName,
|
automodQueueName,
|
||||||
async (job) => {
|
async (job) => {
|
||||||
if (job.name !== 'refreshPhishingList') {
|
if (job.name === 'refreshPhishingList') {
|
||||||
return;
|
|
||||||
}
|
|
||||||
const count = await refreshPhishingDomains(redis);
|
const count = await refreshPhishingDomains(redis);
|
||||||
logger.info({ domainCount: count }, 'Phishing domain list refreshed');
|
logger.info({ domainCount: count }, 'Phishing domain list refreshed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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 }
|
{ connection: redis }
|
||||||
);
|
);
|
||||||
@@ -168,7 +249,11 @@ export function startWorkers(context: BotContext): Worker[] {
|
|||||||
if (job.name !== 'verificationComplete') {
|
if (job.name !== 'verificationComplete') {
|
||||||
return;
|
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 }
|
{ connection: redis }
|
||||||
);
|
);
|
||||||
@@ -218,7 +303,17 @@ export function startWorkers(context: BotContext): Worker[] {
|
|||||||
giveawayQueueName,
|
giveawayQueueName,
|
||||||
async (job) => {
|
async (job) => {
|
||||||
if (job.name === 'giveawayEnd') {
|
if (job.name === 'giveawayEnd') {
|
||||||
|
try {
|
||||||
await endGiveaway(context, (job.data as GiveawayEndJob).giveawayId);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (job.name === 'giveawayCreate') {
|
if (job.name === 'giveawayCreate') {
|
||||||
@@ -281,6 +376,20 @@ export function startWorkers(context: BotContext): Worker[] {
|
|||||||
logger.error({ jobId: job?.id, error }, 'Schedule job failed');
|
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 }>(
|
const guildBackupWorker = new Worker<{ backupId: string; guildId: string }>(
|
||||||
guildBackupQueueName,
|
guildBackupQueueName,
|
||||||
async (job) => {
|
async (job) => {
|
||||||
@@ -349,6 +458,7 @@ export function startWorkers(context: BotContext): Worker[] {
|
|||||||
statsWorker,
|
statsWorker,
|
||||||
feedsWorker,
|
feedsWorker,
|
||||||
scheduleWorker,
|
scheduleWorker,
|
||||||
|
messagesWorker,
|
||||||
guildBackupWorker,
|
guildBackupWorker,
|
||||||
suggestionsWorker,
|
suggestionsWorker,
|
||||||
presenceWorker,
|
presenceWorker,
|
||||||
|
|||||||
426
apps/bot/src/lib/components-v2-payload.ts
Normal file
426
apps/bot/src/lib/components-v2-payload.ts
Normal 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;
|
||||||
|
}
|
||||||
89
apps/bot/src/lib/embed-payload.ts
Normal file
89
apps/bot/src/lib/embed-payload.ts
Normal 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
129
apps/bot/src/metrics.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import type { Redis } from 'ioredis';
|
||||||
|
import type { Client } from 'discord.js';
|
||||||
|
|
||||||
|
const KEY_COMMANDS_TOTAL = 'nexumi:metrics:commands_total';
|
||||||
|
const KEY_COMMANDS_ERRORS = 'nexumi:metrics:commands_errors';
|
||||||
|
const KEY_COMMAND_DURATION_SUM = 'nexumi:metrics:command_duration_ms_sum';
|
||||||
|
const KEY_COMMAND_DURATION_COUNT = 'nexumi:metrics:command_duration_ms_count';
|
||||||
|
const KEY_EVENTS_TOTAL = 'nexumi:metrics:events_total';
|
||||||
|
const shardKey = (id: number) => `nexumi:metrics:shard:${id}`;
|
||||||
|
|
||||||
|
export async function recordCommandMetric(
|
||||||
|
redis: Redis,
|
||||||
|
opts: { ok: boolean; durationMs: number }
|
||||||
|
): Promise<void> {
|
||||||
|
const multi = redis.multi();
|
||||||
|
multi.incr(KEY_COMMANDS_TOTAL);
|
||||||
|
if (!opts.ok) {
|
||||||
|
multi.incr(KEY_COMMANDS_ERRORS);
|
||||||
|
}
|
||||||
|
multi.incrbyfloat(KEY_COMMAND_DURATION_SUM, opts.durationMs);
|
||||||
|
multi.incr(KEY_COMMAND_DURATION_COUNT);
|
||||||
|
await multi.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordEventMetric(redis: Redis): Promise<void> {
|
||||||
|
await redis.incr(KEY_EVENTS_TOTAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishShardMetrics(redis: Redis, client: Client): Promise<void> {
|
||||||
|
const ids = client.shard?.ids ?? [0];
|
||||||
|
const ping = client.ws.ping;
|
||||||
|
const guilds = client.guilds.cache.size;
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
ping,
|
||||||
|
guilds,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
});
|
||||||
|
await Promise.all(ids.map((id) => redis.set(shardKey(id), payload, 'EX', 120)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MetricsSnapshot = {
|
||||||
|
up: 1;
|
||||||
|
commandsTotal: number;
|
||||||
|
commandsErrors: number;
|
||||||
|
commandDurationSumMs: number;
|
||||||
|
commandDurationCount: number;
|
||||||
|
eventsTotal: number;
|
||||||
|
shards: Array<{ id: number; ping: number; guilds: number; updatedAt: number }>;
|
||||||
|
queueWaiting?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function collectMetricsSnapshot(redis: Redis): Promise<MetricsSnapshot> {
|
||||||
|
const [commandsTotal, commandsErrors, durationSum, durationCount, eventsTotal, shardKeys] =
|
||||||
|
await Promise.all([
|
||||||
|
redis.get(KEY_COMMANDS_TOTAL),
|
||||||
|
redis.get(KEY_COMMANDS_ERRORS),
|
||||||
|
redis.get(KEY_COMMAND_DURATION_SUM),
|
||||||
|
redis.get(KEY_COMMAND_DURATION_COUNT),
|
||||||
|
redis.get(KEY_EVENTS_TOTAL),
|
||||||
|
redis.keys('nexumi:metrics:shard:*')
|
||||||
|
]);
|
||||||
|
|
||||||
|
const shards: MetricsSnapshot['shards'] = [];
|
||||||
|
if (shardKeys.length > 0) {
|
||||||
|
const values = await redis.mget(...shardKeys);
|
||||||
|
for (let i = 0; i < shardKeys.length; i++) {
|
||||||
|
const id = Number(shardKeys[i]!.replace('nexumi:metrics:shard:', ''));
|
||||||
|
const raw = values[i];
|
||||||
|
if (!raw || Number.isNaN(id)) continue;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as { ping: number; guilds: number; updatedAt: number };
|
||||||
|
shards.push({ id, ping: parsed.ping, guilds: parsed.guilds, updatedAt: parsed.updatedAt });
|
||||||
|
} catch {
|
||||||
|
// ignore malformed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shards.sort((a, b) => a.id - b.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
up: 1,
|
||||||
|
commandsTotal: Number(commandsTotal ?? 0),
|
||||||
|
commandsErrors: Number(commandsErrors ?? 0),
|
||||||
|
commandDurationSumMs: Number(durationSum ?? 0),
|
||||||
|
commandDurationCount: Number(durationCount ?? 0),
|
||||||
|
eventsTotal: Number(eventsTotal ?? 0),
|
||||||
|
shards
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPrometheus(snapshot: MetricsSnapshot): string {
|
||||||
|
const lines: string[] = [
|
||||||
|
'# HELP nexumi_up 1 if the metrics endpoint is healthy',
|
||||||
|
'# TYPE nexumi_up gauge',
|
||||||
|
`nexumi_up ${snapshot.up}`,
|
||||||
|
'# HELP nexumi_commands_total Total slash commands handled',
|
||||||
|
'# TYPE nexumi_commands_total counter',
|
||||||
|
`nexumi_commands_total ${snapshot.commandsTotal}`,
|
||||||
|
'# HELP nexumi_commands_errors_total Total slash command failures',
|
||||||
|
'# TYPE nexumi_commands_errors_total counter',
|
||||||
|
`nexumi_commands_errors_total ${snapshot.commandsErrors}`,
|
||||||
|
'# HELP nexumi_command_duration_ms_sum Sum of command durations in milliseconds',
|
||||||
|
'# TYPE nexumi_command_duration_ms_sum counter',
|
||||||
|
`nexumi_command_duration_ms_sum ${snapshot.commandDurationSumMs}`,
|
||||||
|
'# HELP nexumi_command_duration_ms_count Count of timed commands',
|
||||||
|
'# TYPE nexumi_command_duration_ms_count counter',
|
||||||
|
`nexumi_command_duration_ms_count ${snapshot.commandDurationCount}`,
|
||||||
|
'# HELP nexumi_events_total Approximate event throughput counter',
|
||||||
|
'# TYPE nexumi_events_total counter',
|
||||||
|
`nexumi_events_total ${snapshot.eventsTotal}`,
|
||||||
|
'# HELP nexumi_guilds Guild count per shard',
|
||||||
|
'# TYPE nexumi_guilds gauge',
|
||||||
|
'# HELP nexumi_shard_ping_ms Websocket heartbeat latency per shard',
|
||||||
|
'# TYPE nexumi_shard_ping_ms gauge'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const shard of snapshot.shards) {
|
||||||
|
lines.push(`nexumi_guilds{shard="${shard.id}"} ${shard.guilds}`);
|
||||||
|
lines.push(`nexumi_shard_ping_ms{shard="${shard.id}"} ${shard.ping}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.queueWaiting !== undefined) {
|
||||||
|
lines.push('# HELP nexumi_queue_waiting Approximate waiting jobs across known queues');
|
||||||
|
lines.push('# TYPE nexumi_queue_waiting gauge');
|
||||||
|
lines.push(`nexumi_queue_waiting ${snapshot.queueWaiting}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${lines.join('\n')}\n`;
|
||||||
|
}
|
||||||
33
apps/bot/src/module-gates.test.ts
Normal file
33
apps/bot/src/module-gates.test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { moduleForCommand } from './module-gates.js';
|
||||||
|
|
||||||
|
describe('moduleForCommand', () => {
|
||||||
|
it('maps moderation commands', () => {
|
||||||
|
expect(moduleForCommand('ban')).toBe('moderation');
|
||||||
|
expect(moduleForCommand('warn')).toBe('moderation');
|
||||||
|
expect(moduleForCommand('case')).toBe('moderation');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps redis-only and prisma modules', () => {
|
||||||
|
expect(moduleForCommand('giveaway')).toBe('giveaways');
|
||||||
|
expect(moduleForCommand('tag')).toBe('tags');
|
||||||
|
expect(moduleForCommand('backup')).toBe('guildbackup');
|
||||||
|
expect(moduleForCommand('economy' as string)).toBeNull();
|
||||||
|
expect(moduleForCommand('balance')).toBe('economy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves core commands unmapped', () => {
|
||||||
|
expect(moduleForCommand('help')).toBeNull();
|
||||||
|
expect(moduleForCommand('info')).toBeNull();
|
||||||
|
expect(moduleForCommand('gdpr')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('feature flag rollout hashing', () => {
|
||||||
|
it('is stable for the same guild/key pair', () => {
|
||||||
|
const a = createHash('sha256').update('moderation:guild-1').digest().readUInt32BE(0) % 100;
|
||||||
|
const b = createHash('sha256').update('moderation:guild-1').digest().readUInt32BE(0) % 100;
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
275
apps/bot/src/module-gates.ts
Normal file
275
apps/bot/src/module-gates.ts
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
import type { DashboardModuleId } from '@nexumi/shared';
|
||||||
|
import type { PrismaClient } from '@prisma/client';
|
||||||
|
import type { Redis } from 'ioredis';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
|
const REDIS_ONLY_MODULES = new Set<DashboardModuleId>([
|
||||||
|
'giveaways',
|
||||||
|
'tags',
|
||||||
|
'selfroles',
|
||||||
|
'feeds',
|
||||||
|
'scheduler',
|
||||||
|
'guildbackup',
|
||||||
|
'messages'
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Slash command name → dashboard module. Core/GDPR/utility (except gated) omit. */
|
||||||
|
const COMMAND_MODULE_MAP: Record<string, DashboardModuleId> = {
|
||||||
|
ban: 'moderation',
|
||||||
|
unban: 'moderation',
|
||||||
|
kick: 'moderation',
|
||||||
|
timeout: 'moderation',
|
||||||
|
untimeout: 'moderation',
|
||||||
|
warn: 'moderation',
|
||||||
|
purge: 'moderation',
|
||||||
|
slowmode: 'moderation',
|
||||||
|
lock: 'moderation',
|
||||||
|
unlock: 'moderation',
|
||||||
|
nick: 'moderation',
|
||||||
|
case: 'moderation',
|
||||||
|
modnote: 'moderation',
|
||||||
|
automod: 'automod',
|
||||||
|
welcome: 'welcome',
|
||||||
|
verify: 'verification',
|
||||||
|
rank: 'leveling',
|
||||||
|
leaderboard: 'leveling',
|
||||||
|
xp: 'leveling',
|
||||||
|
balance: 'economy',
|
||||||
|
daily: 'economy',
|
||||||
|
weekly: 'economy',
|
||||||
|
work: 'economy',
|
||||||
|
pay: 'economy',
|
||||||
|
gamble: 'economy',
|
||||||
|
slots: 'economy',
|
||||||
|
blackjack: 'economy',
|
||||||
|
coinflip: 'economy',
|
||||||
|
shop: 'economy',
|
||||||
|
inventory: 'economy',
|
||||||
|
eco: 'economy',
|
||||||
|
'8ball': 'fun',
|
||||||
|
dice: 'fun',
|
||||||
|
rps: 'fun',
|
||||||
|
choose: 'fun',
|
||||||
|
trivia: 'fun',
|
||||||
|
tictactoe: 'fun',
|
||||||
|
connect4: 'fun',
|
||||||
|
hangman: 'fun',
|
||||||
|
meme: 'fun',
|
||||||
|
cat: 'fun',
|
||||||
|
dog: 'fun',
|
||||||
|
giveaway: 'giveaways',
|
||||||
|
ticket: 'tickets',
|
||||||
|
selfroles: 'selfroles',
|
||||||
|
tag: 'tags',
|
||||||
|
starboard: 'starboard',
|
||||||
|
suggest: 'suggestions',
|
||||||
|
suggestion: 'suggestions',
|
||||||
|
birthday: 'birthdays',
|
||||||
|
voice: 'tempvoice',
|
||||||
|
invites: 'stats',
|
||||||
|
stats: 'stats',
|
||||||
|
feeds: 'feeds',
|
||||||
|
schedule: 'scheduler',
|
||||||
|
announce: 'scheduler',
|
||||||
|
backup: 'guildbackup',
|
||||||
|
embed: 'messages'
|
||||||
|
};
|
||||||
|
|
||||||
|
function redisModulesKey(guildId: string): string {
|
||||||
|
return `dashboard:modules:${guildId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function funConfigKey(guildId: string): string {
|
||||||
|
return `fun:config:${guildId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashPercent(input: string): number {
|
||||||
|
const digest = createHash('sha256').update(input).digest();
|
||||||
|
return digest.readUInt32BE(0) % 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moduleForCommand(commandName: string): DashboardModuleId | null {
|
||||||
|
return COMMAND_MODULE_MAP[commandName] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isFeatureFlagEnabled(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
key: string,
|
||||||
|
guildId: string | null
|
||||||
|
): Promise<boolean> {
|
||||||
|
const flag = await prisma.featureFlag.findUnique({ where: { key } });
|
||||||
|
if (!flag) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (guildId && flag.whitelistGuildIds.includes(guildId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!flag.enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (flag.rolloutPercent >= 100) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (flag.rolloutPercent <= 0 || !guildId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return hashPercent(`${flag.key}:${guildId}`) < flag.rolloutPercent;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isRedisModuleEnabled(
|
||||||
|
redis: Redis,
|
||||||
|
guildId: string,
|
||||||
|
moduleId: DashboardModuleId
|
||||||
|
): Promise<boolean> {
|
||||||
|
const value = await redis.hget(redisModulesKey(guildId), moduleId);
|
||||||
|
if (value === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return value === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isFunEnabled(redis: Redis, guildId: string): Promise<boolean> {
|
||||||
|
const raw = await redis.get(funConfigKey(guildId));
|
||||||
|
if (!raw) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as { enabled?: boolean };
|
||||||
|
return parsed.enabled ?? true;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isModuleEnabled(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
redis: Redis,
|
||||||
|
guildId: string,
|
||||||
|
moduleId: DashboardModuleId
|
||||||
|
): Promise<boolean> {
|
||||||
|
const flagOk = await isFeatureFlagEnabled(prisma, moduleId, guildId);
|
||||||
|
if (!flagOk) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (REDIS_ONLY_MODULES.has(moduleId)) {
|
||||||
|
return isRedisModuleEnabled(redis, guildId, moduleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (moduleId) {
|
||||||
|
case 'moderation': {
|
||||||
|
const settings = await prisma.guildSettings.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { moderationEnabled: true }
|
||||||
|
});
|
||||||
|
return settings?.moderationEnabled ?? true;
|
||||||
|
}
|
||||||
|
case 'automod': {
|
||||||
|
const config = await prisma.autoModConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { enabled: true }
|
||||||
|
});
|
||||||
|
return config?.enabled ?? true;
|
||||||
|
}
|
||||||
|
case 'logging': {
|
||||||
|
const config = await prisma.loggingConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { enabled: true }
|
||||||
|
});
|
||||||
|
return config?.enabled ?? true;
|
||||||
|
}
|
||||||
|
case 'welcome': {
|
||||||
|
const config = await prisma.welcomeConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { welcomeEnabled: true, leaveEnabled: true }
|
||||||
|
});
|
||||||
|
if (!config) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return Boolean(config.welcomeEnabled || config.leaveEnabled);
|
||||||
|
}
|
||||||
|
case 'verification': {
|
||||||
|
const config = await prisma.verificationConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { enabled: true }
|
||||||
|
});
|
||||||
|
return config?.enabled ?? false;
|
||||||
|
}
|
||||||
|
case 'leveling': {
|
||||||
|
const config = await prisma.levelingConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { enabled: true }
|
||||||
|
});
|
||||||
|
return config?.enabled ?? true;
|
||||||
|
}
|
||||||
|
case 'economy': {
|
||||||
|
const config = await prisma.economyConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { enabled: true }
|
||||||
|
});
|
||||||
|
return config?.enabled ?? true;
|
||||||
|
}
|
||||||
|
case 'fun':
|
||||||
|
return isFunEnabled(redis, guildId);
|
||||||
|
case 'tickets': {
|
||||||
|
const config = await prisma.ticketConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { enabled: true }
|
||||||
|
});
|
||||||
|
return config?.enabled ?? true;
|
||||||
|
}
|
||||||
|
case 'starboard': {
|
||||||
|
const config = await prisma.starboardConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { enabled: true }
|
||||||
|
});
|
||||||
|
return config?.enabled ?? false;
|
||||||
|
}
|
||||||
|
case 'suggestions': {
|
||||||
|
const config = await prisma.suggestionConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { enabled: true }
|
||||||
|
});
|
||||||
|
return config?.enabled ?? true;
|
||||||
|
}
|
||||||
|
case 'birthdays': {
|
||||||
|
const config = await prisma.birthdayConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { enabled: true }
|
||||||
|
});
|
||||||
|
return config?.enabled ?? true;
|
||||||
|
}
|
||||||
|
case 'tempvoice': {
|
||||||
|
const config = await prisma.tempVoiceConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { enabled: true }
|
||||||
|
});
|
||||||
|
return config?.enabled ?? false;
|
||||||
|
}
|
||||||
|
case 'stats': {
|
||||||
|
const config = await prisma.statsConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { enabled: true }
|
||||||
|
});
|
||||||
|
return config?.enabled ?? false;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isUserBlacklisted(prisma: PrismaClient, userId: string): Promise<boolean> {
|
||||||
|
const row = await prisma.globalUserBlacklist.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
return Boolean(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isGuildBlacklisted(prisma: PrismaClient, guildId: string): Promise<boolean> {
|
||||||
|
const row = await prisma.guildBlacklist.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
return Boolean(row);
|
||||||
|
}
|
||||||
@@ -204,6 +204,16 @@ export async function handleAntiRaidJoin(
|
|||||||
return;
|
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 key = `automod:raid:${member.guild.id}`;
|
||||||
const count = await context.redis.incr(key);
|
const count = await context.redis.incr(key);
|
||||||
if (count === 1) {
|
if (count === 1) {
|
||||||
@@ -214,10 +224,6 @@ export async function handleAntiRaidJoin(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.lockdownActive) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await context.prisma.autoModConfig.update({
|
await context.prisma.autoModConfig.update({
|
||||||
where: { guildId: member.guild.id },
|
where: { guildId: member.guild.id },
|
||||||
data: { lockdownActive: true }
|
data: { lockdownActive: true }
|
||||||
@@ -225,16 +231,45 @@ export async function handleAntiRaidJoin(
|
|||||||
|
|
||||||
if (config.antiRaidAction === 'LOCKDOWN') {
|
if (config.antiRaidAction === 'LOCKDOWN') {
|
||||||
await activateLockdown(member.guild);
|
await activateLockdown(member.guild);
|
||||||
|
} else if (config.antiRaidAction === 'VERIFY') {
|
||||||
|
await applyRaidVerifyRole(context, member);
|
||||||
}
|
}
|
||||||
|
|
||||||
const alertChannel = member.guild.systemChannel ?? member.guild.publicUpdatesChannel;
|
const alertChannel = member.guild.systemChannel ?? member.guild.publicUpdatesChannel;
|
||||||
if (alertChannel && alertChannel.isTextBased()) {
|
if (alertChannel && alertChannel.isTextBased()) {
|
||||||
await (alertChannel as TextChannel).send(
|
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(
|
export async function handleAntiNukeAction(
|
||||||
context: BotContext,
|
context: BotContext,
|
||||||
guildId: string,
|
guildId: string,
|
||||||
|
|||||||
@@ -18,12 +18,17 @@ export async function getAutoModConfig(prisma: PrismaClient, guildId: string) {
|
|||||||
|
|
||||||
export async function ensureDefaultAutoModRules(prisma: PrismaClient, guildId: string) {
|
export async function ensureDefaultAutoModRules(prisma: PrismaClient, guildId: string) {
|
||||||
await ensureGuild(prisma, guildId);
|
await ensureGuild(prisma, guildId);
|
||||||
const existing = await prisma.autoModRule.count({ where: { guildId } });
|
const existing = await prisma.autoModRule.findMany({
|
||||||
if (existing > 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
await prisma.autoModRule.createMany({
|
await prisma.autoModRule.createMany({
|
||||||
data: DEFAULT_AUTOMOD_RULES.map((rule) => ({
|
data: missing.map((rule) => ({
|
||||||
guildId,
|
guildId,
|
||||||
ruleType: rule.ruleType,
|
ruleType: rule.ruleType,
|
||||||
action: rule.action,
|
action: rule.action,
|
||||||
|
|||||||
4
apps/bot/src/modules/components-v2/index.ts
Normal file
4
apps/bot/src/modules/components-v2/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export {
|
||||||
|
handleComponentsV2Interaction,
|
||||||
|
isComponentsV2Interaction
|
||||||
|
} from './interactions.js';
|
||||||
335
apps/bot/src/modules/components-v2/interactions.ts
Normal file
335
apps/bot/src/modules/components-v2/interactions.ts
Normal 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;
|
||||||
120
apps/bot/src/modules/components-v2/send.ts
Normal file
120
apps/bot/src/modules/components-v2/send.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
export { giveawayCommands } from './commands.js';
|
export { giveawayCommands } from './commands.js';
|
||||||
export { isGiveawayButton, handleGiveawayButton } from './buttons.js';
|
export { isGiveawayButton, handleGiveawayButton } from './buttons.js';
|
||||||
export { endGiveaway, scheduleGiveawayEnd, runGiveawayCreateJob } from './service.js';
|
export { endGiveaway, scheduleGiveawayEnd, runGiveawayCreateJob, GiveawayError } from './service.js';
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ function requirementLines(locale: 'de' | 'en', giveaway: Giveaway): string[] {
|
|||||||
|
|
||||||
export function buildGiveawayEmbed(locale: 'de' | 'en', giveaway: Giveaway): EmbedBuilder {
|
export function buildGiveawayEmbed(locale: 'de' | 'en', giveaway: Giveaway): EmbedBuilder {
|
||||||
const ended = giveaway.endedAt !== null;
|
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()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle(t(locale, 'giveaway.embed.title'))
|
.setTitle(t(locale, 'giveaway.embed.title'))
|
||||||
.setColor(ended ? 0x6b7280 : 0x6366f1)
|
.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') });
|
embed.setFooter({ text: t(locale, 'giveaway.embed.ended') });
|
||||||
} else if (giveaway.paused) {
|
} else if (giveaway.paused) {
|
||||||
embed.setDescription(t(locale, 'giveaway.embed.paused'));
|
embed.setDescription(
|
||||||
embed.setFooter({
|
`${t(locale, 'giveaway.embed.paused')}\n\n${tf(locale, 'giveaway.embed.endsAt', {
|
||||||
text: tf(locale, 'giveaway.embed.endsAt', {
|
time: endsAtText
|
||||||
time: `<t:${Math.floor(giveaway.endsAt.getTime() / 1000)}:R>`
|
})}`
|
||||||
})
|
);
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
embed.setDescription(t(locale, 'giveaway.embed.active'));
|
embed.setDescription(
|
||||||
embed.setFooter({
|
`${t(locale, 'giveaway.embed.active')}\n\n${tf(locale, 'giveaway.embed.endsAt', {
|
||||||
text: tf(locale, 'giveaway.embed.endsAt', {
|
time: endsAtText
|
||||||
time: `<t:${Math.floor(giveaway.endsAt.getTime() / 1000)}:R>`
|
})}`
|
||||||
})
|
);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const requirements = requirementLines(locale, giveaway);
|
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 } });
|
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
||||||
if (!giveaway || giveaway.endedAt) {
|
if (!giveaway) {
|
||||||
return;
|
throw new GiveawayError('not_found');
|
||||||
|
}
|
||||||
|
if (giveaway.endedAt) {
|
||||||
|
throw new GiveawayError('already_ended');
|
||||||
}
|
}
|
||||||
|
|
||||||
await cancelGiveawayJob(giveawayId);
|
await cancelGiveawayJob(giveawayId);
|
||||||
@@ -338,6 +344,7 @@ export async function endGiveaway(context: BotContext, giveawayId: string): Prom
|
|||||||
if (winners.length > 0) {
|
if (winners.length > 0) {
|
||||||
await dmWinners(context, updated, winners, locale);
|
await dmWinners(context, updated, winners, locale);
|
||||||
}
|
}
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function rerollGiveaway(
|
export async function rerollGiveaway(
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ const backupCommand: SlashCommand = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await promptDeleteConfirmation(interaction, locale, backupId);
|
await promptDeleteConfirmation(interaction, context, locale, backupId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ const backupCommand: SlashCommand = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await promptRestoreConfirmation(interaction, locale, backupId);
|
await promptRestoreConfirmation(interaction, context, locale, backupId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import { type Locale, t, tf } from '@nexumi/shared';
|
import { type Locale, t, tf } from '@nexumi/shared';
|
||||||
import type { BotContext } from '../../types.js';
|
import type { BotContext } from '../../types.js';
|
||||||
import { getGuildLocale } from '../../i18n.js';
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
|
import {
|
||||||
|
deleteConfirmation,
|
||||||
|
getConfirmation,
|
||||||
|
setConfirmation,
|
||||||
|
takeConfirmation
|
||||||
|
} from '../../confirmation-store.js';
|
||||||
import {
|
import {
|
||||||
deleteGuildBackup,
|
deleteGuildBackup,
|
||||||
enqueueGuildBackupRestore,
|
enqueueGuildBackupRestore,
|
||||||
@@ -22,7 +28,8 @@ import {
|
|||||||
const DELETE_CONFIRM_PREFIX = 'gbackup:confirm:delete:';
|
const DELETE_CONFIRM_PREFIX = 'gbackup:confirm:delete:';
|
||||||
const RESTORE_CONFIRM_PREFIX = 'gbackup:confirm:restore:';
|
const RESTORE_CONFIRM_PREFIX = 'gbackup:confirm:restore:';
|
||||||
const CANCEL_PREFIX = 'gbackup:cancel:';
|
const CANCEL_PREFIX = 'gbackup:cancel:';
|
||||||
const TTL_MS = 120_000;
|
const NAMESPACE = 'guildbackup';
|
||||||
|
const TTL_SECONDS = 120;
|
||||||
|
|
||||||
type PendingDelete = {
|
type PendingDelete = {
|
||||||
action: 'delete';
|
action: 'delete';
|
||||||
@@ -30,7 +37,6 @@ type PendingDelete = {
|
|||||||
userId: string;
|
userId: string;
|
||||||
guildId: string;
|
guildId: string;
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
expiresAt: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type PendingRestore = {
|
type PendingRestore = {
|
||||||
@@ -40,22 +46,10 @@ type PendingRestore = {
|
|||||||
userId: string;
|
userId: string;
|
||||||
guildId: string;
|
guildId: string;
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
expiresAt: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type PendingEntry = PendingDelete | PendingRestore;
|
type PendingEntry = PendingDelete | PendingRestore;
|
||||||
|
|
||||||
const pending = new Map<string, PendingEntry>();
|
|
||||||
|
|
||||||
function pruneExpired(): void {
|
|
||||||
const now = Date.now();
|
|
||||||
for (const [token, entry] of pending) {
|
|
||||||
if (entry.expiresAt <= now) {
|
|
||||||
pending.delete(token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRows(token: string, locale: Locale, confirmPrefix: string): ActionRowBuilder<ButtonBuilder>[] {
|
function buildRows(token: string, locale: Locale, confirmPrefix: string): ActionRowBuilder<ButtonBuilder>[] {
|
||||||
return [
|
return [
|
||||||
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
@@ -73,19 +67,24 @@ function buildRows(token: string, locale: Locale, confirmPrefix: string): Action
|
|||||||
|
|
||||||
export async function promptDeleteConfirmation(
|
export async function promptDeleteConfirmation(
|
||||||
interaction: ChatInputCommandInteraction,
|
interaction: ChatInputCommandInteraction,
|
||||||
|
context: BotContext,
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
backupId: string
|
backupId: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
pruneExpired();
|
|
||||||
const token = randomUUID();
|
const token = randomUUID();
|
||||||
pending.set(token, {
|
await setConfirmation(
|
||||||
|
context.redis,
|
||||||
|
NAMESPACE,
|
||||||
|
token,
|
||||||
|
{
|
||||||
action: 'delete',
|
action: 'delete',
|
||||||
backupId,
|
backupId,
|
||||||
userId: interaction.user.id,
|
userId: interaction.user.id,
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
locale,
|
locale
|
||||||
expiresAt: Date.now() + TTL_MS
|
} satisfies PendingDelete,
|
||||||
});
|
TTL_SECONDS
|
||||||
|
);
|
||||||
|
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: tf(locale, 'guildbackup.confirm.delete', { id: backupId }),
|
content: tf(locale, 'guildbackup.confirm.delete', { id: backupId }),
|
||||||
@@ -96,20 +95,25 @@ export async function promptDeleteConfirmation(
|
|||||||
|
|
||||||
export async function promptRestoreConfirmation(
|
export async function promptRestoreConfirmation(
|
||||||
interaction: ChatInputCommandInteraction,
|
interaction: ChatInputCommandInteraction,
|
||||||
|
context: BotContext,
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
backupId: string
|
backupId: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
pruneExpired();
|
|
||||||
const token = randomUUID();
|
const token = randomUUID();
|
||||||
pending.set(token, {
|
await setConfirmation(
|
||||||
|
context.redis,
|
||||||
|
NAMESPACE,
|
||||||
|
token,
|
||||||
|
{
|
||||||
action: 'restore',
|
action: 'restore',
|
||||||
step: 1,
|
step: 1,
|
||||||
backupId,
|
backupId,
|
||||||
userId: interaction.user.id,
|
userId: interaction.user.id,
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
locale,
|
locale
|
||||||
expiresAt: Date.now() + TTL_MS
|
} satisfies PendingRestore,
|
||||||
});
|
TTL_SECONDS
|
||||||
|
);
|
||||||
|
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: tf(locale, 'guildbackup.confirm.restore.step1', { id: backupId }),
|
content: tf(locale, 'guildbackup.confirm.restore.step1', { id: backupId }),
|
||||||
@@ -122,8 +126,6 @@ export async function handleGuildBackupButton(
|
|||||||
interaction: ButtonInteraction,
|
interaction: ButtonInteraction,
|
||||||
context: BotContext
|
context: BotContext
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
pruneExpired();
|
|
||||||
|
|
||||||
const customId = interaction.customId;
|
const customId = interaction.customId;
|
||||||
const isDeleteConfirm = customId.startsWith(DELETE_CONFIRM_PREFIX);
|
const isDeleteConfirm = customId.startsWith(DELETE_CONFIRM_PREFIX);
|
||||||
const isRestoreConfirm = customId.startsWith(RESTORE_CONFIRM_PREFIX);
|
const isRestoreConfirm = customId.startsWith(RESTORE_CONFIRM_PREFIX);
|
||||||
@@ -141,48 +143,57 @@ export async function handleGuildBackupButton(
|
|||||||
: CANCEL_PREFIX.length
|
: CANCEL_PREFIX.length
|
||||||
);
|
);
|
||||||
|
|
||||||
const entry = pending.get(token);
|
const peeked = await getConfirmation<PendingEntry>(context.redis, NAMESPACE, token);
|
||||||
if (!entry) {
|
if (!peeked) {
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
await interaction.reply({ content: t(locale, 'guildbackup.confirm.expired'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'guildbackup.confirm.expired'), ephemeral: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.user.id !== entry.userId) {
|
if (interaction.user.id !== peeked.userId) {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: t(entry.locale, 'guildbackup.confirm.unauthorized'),
|
content: t(peeked.locale, 'guildbackup.confirm.unauthorized'),
|
||||||
ephemeral: true
|
ephemeral: true
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCancel) {
|
if (isCancel) {
|
||||||
pending.delete(token);
|
await deleteConfirmation(context.redis, NAMESPACE, token);
|
||||||
await interaction.update({
|
await interaction.update({
|
||||||
content: t(entry.locale, 'guildbackup.confirm.cancelled'),
|
content: t(peeked.locale, 'guildbackup.confirm.cancelled'),
|
||||||
components: []
|
components: []
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.action === 'delete') {
|
if (peeked.action === 'delete') {
|
||||||
pending.delete(token);
|
const entry = await takeConfirmation<PendingDelete>(context.redis, NAMESPACE, token);
|
||||||
|
if (!entry) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
await interaction.reply({ content: t(locale, 'guildbackup.confirm.expired'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
await executeDelete(interaction, context, entry);
|
await executeDelete(interaction, context, entry);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.step === 1) {
|
if (peeked.step === 1) {
|
||||||
entry.step = 2;
|
const next: PendingRestore = { ...peeked, step: 2 };
|
||||||
entry.expiresAt = Date.now() + TTL_MS;
|
await setConfirmation(context.redis, NAMESPACE, token, next, TTL_SECONDS);
|
||||||
pending.set(token, entry);
|
|
||||||
await interaction.update({
|
await interaction.update({
|
||||||
content: tf(entry.locale, 'guildbackup.confirm.restore.step2', { id: entry.backupId }),
|
content: tf(peeked.locale, 'guildbackup.confirm.restore.step2', { id: peeked.backupId }),
|
||||||
components: buildRows(token, entry.locale, RESTORE_CONFIRM_PREFIX)
|
components: buildRows(token, peeked.locale, RESTORE_CONFIRM_PREFIX)
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
pending.delete(token);
|
const entry = await takeConfirmation<PendingRestore>(context.redis, NAMESPACE, token);
|
||||||
|
if (!entry) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
await interaction.reply({ content: t(locale, 'guildbackup.confirm.expired'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
await executeRestore(interaction, context, entry);
|
await executeRestore(interaction, context, entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ export async function applyLevelRewards(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const reward = rewards.find((entry) => entry.level === newLevel);
|
const reward = [...rewards].reverse().find((entry) => entry.level <= newLevel);
|
||||||
const removable = rewards
|
const removable = rewards
|
||||||
.map((entry) => entry.roleId)
|
.map((entry) => entry.roleId)
|
||||||
.filter((roleId) => member.roles.cache.has(roleId) && rewardRoleIds.has(roleId));
|
.filter((roleId) => member.roles.cache.has(roleId) && rewardRoleIds.has(roleId));
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { TextChannel, type ButtonInteraction } from 'discord.js';
|
import { TextChannel, type ButtonInteraction } from 'discord.js';
|
||||||
import { type Locale, t } from '@nexumi/shared';
|
import { type Locale, t, tf } from '@nexumi/shared';
|
||||||
import type { BotContext } from '../../types.js';
|
import type { BotContext } from '../../types.js';
|
||||||
|
import { assertBannableForConfirm } from '../../hierarchy.js';
|
||||||
import { createCase, scheduleTempBanExpire } from './service.js';
|
import { createCase, scheduleTempBanExpire } from './service.js';
|
||||||
import { parseDuration } from './duration.js';
|
import { tryParseDuration } from './duration.js';
|
||||||
|
|
||||||
type BanPayload = {
|
type BanPayload = {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -36,8 +37,24 @@ export async function executeBan(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!(await assertBannableForConfirm(interaction, locale, payload.userId))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let durationMs: number | null = null;
|
||||||
|
if (payload.duration) {
|
||||||
|
durationMs = tryParseDuration(payload.duration);
|
||||||
|
if (durationMs === null || durationMs <= 0) {
|
||||||
|
await interaction.update({
|
||||||
|
content: t(locale, 'moderation.duration.invalid'),
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await guild.members.ban(payload.userId, { reason: payload.reason });
|
await guild.members.ban(payload.userId, { reason: payload.reason });
|
||||||
await createCase(context, {
|
const modCase = await createCase(context, {
|
||||||
guildId: guild.id,
|
guildId: guild.id,
|
||||||
targetUserId: payload.userId,
|
targetUserId: payload.userId,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -47,21 +64,17 @@ export async function executeBan(
|
|||||||
source: 'button_confirmation',
|
source: 'button_confirmation',
|
||||||
metadata: {
|
metadata: {
|
||||||
confirmed: true,
|
confirmed: true,
|
||||||
duration: payload.duration
|
duration: payload.duration,
|
||||||
|
durationMs
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (payload.duration) {
|
if (durationMs !== null) {
|
||||||
await scheduleTempBanExpire(
|
await scheduleTempBanExpire(guild.id, payload.userId, durationMs, payload.reason);
|
||||||
guild.id,
|
|
||||||
payload.userId,
|
|
||||||
parseDuration(payload.duration),
|
|
||||||
payload.reason
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await interaction.update({
|
await interaction.update({
|
||||||
content: t(locale, 'moderation.success'),
|
content: tf(locale, 'moderation.success.case', { caseNumber: modCase.caseNumber }),
|
||||||
components: []
|
components: []
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -97,7 +110,7 @@ export async function executePurge(
|
|||||||
deletedCount = deleted.size;
|
deletedCount = deleted.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
await createCase(context, {
|
const modCase = await createCase(context, {
|
||||||
guildId: guild.id,
|
guildId: guild.id,
|
||||||
targetUserId: interaction.user.id,
|
targetUserId: interaction.user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -122,7 +135,10 @@ export async function executePurge(
|
|||||||
});
|
});
|
||||||
|
|
||||||
await interaction.update({
|
await interaction.update({
|
||||||
content: t(locale, 'moderation.success'),
|
content: tf(locale, 'moderation.purge.done', {
|
||||||
|
deletedCount,
|
||||||
|
caseNumber: modCase.caseNumber
|
||||||
|
}),
|
||||||
components: []
|
components: []
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -143,7 +159,7 @@ export async function executeCaseDelete(
|
|||||||
where: { guildId_caseNumber: { guildId, caseNumber: payload.caseNumber } }
|
where: { guildId_caseNumber: { guildId, caseNumber: payload.caseNumber } }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing || existing.deletedAt) {
|
||||||
await interaction.update({
|
await interaction.update({
|
||||||
content: t(locale, 'moderation.case.notFound'),
|
content: t(locale, 'moderation.case.notFound'),
|
||||||
components: []
|
components: []
|
||||||
@@ -156,7 +172,7 @@ export async function executeCaseDelete(
|
|||||||
data: { deletedAt: new Date() }
|
data: { deletedAt: new Date() }
|
||||||
});
|
});
|
||||||
|
|
||||||
await createCase(context, {
|
const modCase = await createCase(context, {
|
||||||
guildId,
|
guildId,
|
||||||
targetUserId: existing.targetUserId,
|
targetUserId: existing.targetUserId,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -172,7 +188,7 @@ export async function executeCaseDelete(
|
|||||||
});
|
});
|
||||||
|
|
||||||
await interaction.update({
|
await interaction.update({
|
||||||
content: t(locale, 'moderation.success'),
|
content: tf(locale, 'moderation.success.case', { caseNumber: modCase.caseNumber }),
|
||||||
components: []
|
components: []
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
PermissionFlagsBits,
|
||||||
SlashCommandBuilder,
|
SlashCommandBuilder,
|
||||||
type SlashCommandStringOption,
|
type SlashCommandStringOption,
|
||||||
type SlashCommandUserOption
|
type SlashCommandUserOption
|
||||||
@@ -26,7 +27,9 @@ function reasonOpt(o: SlashCommandStringOption, required = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const banCommandData = applyCommandDescription(
|
export const banCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('ban'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('ban')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers),
|
||||||
'moderation.ban.description'
|
'moderation.ban.description'
|
||||||
)
|
)
|
||||||
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
|
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
|
||||||
@@ -36,7 +39,9 @@ export const banCommandData = applyCommandDescription(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const unbanCommandData = applyCommandDescription(
|
export const unbanCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('unban'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('unban')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers),
|
||||||
'moderation.unban.description'
|
'moderation.unban.description'
|
||||||
)
|
)
|
||||||
.addStringOption((o) =>
|
.addStringOption((o) =>
|
||||||
@@ -45,14 +50,18 @@ export const unbanCommandData = applyCommandDescription(
|
|||||||
.addStringOption((o) => reasonOpt(o));
|
.addStringOption((o) => reasonOpt(o));
|
||||||
|
|
||||||
export const kickCommandData = applyCommandDescription(
|
export const kickCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('kick'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('kick')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.KickMembers),
|
||||||
'moderation.kick.description'
|
'moderation.kick.description'
|
||||||
)
|
)
|
||||||
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
|
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
|
||||||
.addStringOption((o) => reasonOpt(o));
|
.addStringOption((o) => reasonOpt(o));
|
||||||
|
|
||||||
export const timeoutCommandData = applyCommandDescription(
|
export const timeoutCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('timeout'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('timeout')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
|
||||||
'moderation.timeout.description'
|
'moderation.timeout.description'
|
||||||
)
|
)
|
||||||
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
|
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
|
||||||
@@ -62,14 +71,18 @@ export const timeoutCommandData = applyCommandDescription(
|
|||||||
.addStringOption((o) => reasonOpt(o));
|
.addStringOption((o) => reasonOpt(o));
|
||||||
|
|
||||||
export const untimeoutCommandData = applyCommandDescription(
|
export const untimeoutCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('untimeout'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('untimeout')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
|
||||||
'moderation.untimeout.description'
|
'moderation.untimeout.description'
|
||||||
)
|
)
|
||||||
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
|
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
|
||||||
.addStringOption((o) => reasonOpt(o));
|
.addStringOption((o) => reasonOpt(o));
|
||||||
|
|
||||||
export const warnCommandData = applyCommandDescription(
|
export const warnCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('warn'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('warn')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
|
||||||
'moderation.warn.description'
|
'moderation.warn.description'
|
||||||
)
|
)
|
||||||
.addSubcommand((s) =>
|
.addSubcommand((s) =>
|
||||||
@@ -123,7 +136,9 @@ export const warnCommandData = applyCommandDescription(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const purgeCommandData = applyCommandDescription(
|
export const purgeCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('purge'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('purge')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages),
|
||||||
'moderation.purge.description'
|
'moderation.purge.description'
|
||||||
)
|
)
|
||||||
.addIntegerOption((o) =>
|
.addIntegerOption((o) =>
|
||||||
@@ -138,24 +153,38 @@ export const purgeCommandData = applyCommandDescription(
|
|||||||
.addStringOption((o) => applyOptionDescription(o.setName('regex'), 'moderation.purge.options.regex'));
|
.addStringOption((o) => applyOptionDescription(o.setName('regex'), 'moderation.purge.options.regex'));
|
||||||
|
|
||||||
export const slowmodeCommandData = applyCommandDescription(
|
export const slowmodeCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('slowmode'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('slowmode')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels),
|
||||||
'moderation.slowmode.description'
|
'moderation.slowmode.description'
|
||||||
).addIntegerOption((o) =>
|
).addIntegerOption((o) =>
|
||||||
applyOptionDescription(o.setName('seconds').setRequired(true).setMinValue(0).setMaxValue(21600), 'moderation.slowmode.options.seconds')
|
applyOptionDescription(o.setName('seconds').setRequired(true).setMinValue(0).setMaxValue(21600), 'moderation.slowmode.options.seconds')
|
||||||
);
|
);
|
||||||
|
|
||||||
export const lockCommandData = applyCommandDescription(
|
export const lockCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('lock'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('lock')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels)
|
||||||
|
.addBooleanOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('server'), 'moderation.lock.options.server')
|
||||||
|
),
|
||||||
'moderation.lock.description'
|
'moderation.lock.description'
|
||||||
);
|
);
|
||||||
|
|
||||||
export const unlockCommandData = applyCommandDescription(
|
export const unlockCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('unlock'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('unlock')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels)
|
||||||
|
.addBooleanOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('server'), 'moderation.unlock.options.server')
|
||||||
|
),
|
||||||
'moderation.unlock.description'
|
'moderation.unlock.description'
|
||||||
);
|
);
|
||||||
|
|
||||||
export const nickCommandData = applyCommandDescription(
|
export const nickCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('nick'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('nick')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageNicknames),
|
||||||
'moderation.nick.description'
|
'moderation.nick.description'
|
||||||
)
|
)
|
||||||
.addSubcommand((s) =>
|
.addSubcommand((s) =>
|
||||||
@@ -172,7 +201,9 @@ export const nickCommandData = applyCommandDescription(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const caseCommandData = applyCommandDescription(
|
export const caseCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('case'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('case')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
|
||||||
'moderation.case.description'
|
'moderation.case.description'
|
||||||
)
|
)
|
||||||
.addSubcommand((s) =>
|
.addSubcommand((s) =>
|
||||||
@@ -196,7 +227,9 @@ export const caseCommandData = applyCommandDescription(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const modNoteCommandData = applyCommandDescription(
|
export const modNoteCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('modnote'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('modnote')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
|
||||||
'moderation.modnote.description'
|
'moderation.modnote.description'
|
||||||
)
|
)
|
||||||
.addSubcommand((s) =>
|
.addSubcommand((s) =>
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import {
|
|||||||
ChannelType,
|
ChannelType,
|
||||||
PermissionFlagsBits,
|
PermissionFlagsBits,
|
||||||
TextChannel,
|
TextChannel,
|
||||||
type ChatInputCommandInteraction,
|
type ChatInputCommandInteraction
|
||||||
type GuildMember
|
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
import { t, tf } from '@nexumi/shared';
|
import { t, tf } from '@nexumi/shared';
|
||||||
import type { SlashCommand } from '../../types.js';
|
import type { SlashCommand } from '../../types.js';
|
||||||
import { requirePermission } from '../../permissions.js';
|
import { requirePermission } from '../../permissions.js';
|
||||||
|
import { assertModerableMember } from '../../hierarchy.js';
|
||||||
import { applyWarnEscalation, createCase } from './service.js';
|
import { applyWarnEscalation, createCase } from './service.js';
|
||||||
import { parseDuration } from './duration.js';
|
import { parseDuration, parseTimeoutDuration, tryParseDuration } from './duration.js';
|
||||||
import { getGuildLocale } from '../../i18n.js';
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
import { promptDestructiveConfirmation } from './confirmations.js';
|
import { promptDestructiveConfirmation } from './confirmations.js';
|
||||||
import {
|
import {
|
||||||
@@ -51,6 +51,17 @@ async function ensureMod(
|
|||||||
return requirePermission(interaction, permission, locale);
|
return requirePermission(interaction, permission, locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function replySuccessCase(
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
locale: 'de' | 'en',
|
||||||
|
caseNumber: number
|
||||||
|
): Promise<void> {
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'moderation.success.case', { caseNumber }),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const banCommand: SlashCommand = {
|
const banCommand: SlashCommand = {
|
||||||
data: banCommandData,
|
data: banCommandData,
|
||||||
async execute(interaction, context) {
|
async execute(interaction, context) {
|
||||||
@@ -59,7 +70,22 @@ const banCommand: SlashCommand = {
|
|||||||
const user = interaction.options.getUser('user', true);
|
const user = interaction.options.getUser('user', true);
|
||||||
const reason = reasonFrom(interaction, locale);
|
const reason = reasonFrom(interaction, locale);
|
||||||
const duration = interaction.options.getString('duration');
|
const duration = interaction.options.getString('duration');
|
||||||
await promptDestructiveConfirmation(interaction, locale, {
|
|
||||||
|
if (duration) {
|
||||||
|
const durationMs = tryParseDuration(duration);
|
||||||
|
if (durationMs === null || durationMs <= 0) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'moderation.duration.invalid'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hierarchy = await assertModerableMember(interaction, user.id, locale, 'ban');
|
||||||
|
if (!hierarchy.ok) return;
|
||||||
|
|
||||||
|
await promptDestructiveConfirmation(interaction, context, locale, {
|
||||||
action: 'ban',
|
action: 'ban',
|
||||||
payload: {
|
payload: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -78,7 +104,7 @@ const unbanCommand: SlashCommand = {
|
|||||||
const userId = interaction.options.getString('user_id', true);
|
const userId = interaction.options.getString('user_id', true);
|
||||||
const reason = reasonFrom(interaction, locale);
|
const reason = reasonFrom(interaction, locale);
|
||||||
await interaction.guild!.members.unban(userId, reason);
|
await interaction.guild!.members.unban(userId, reason);
|
||||||
await createCase(context, {
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: userId,
|
targetUserId: userId,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -86,7 +112,7 @@ const unbanCommand: SlashCommand = {
|
|||||||
action: 'UNBAN',
|
action: 'UNBAN',
|
||||||
reason
|
reason
|
||||||
});
|
});
|
||||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -97,9 +123,10 @@ const kickCommand: SlashCommand = {
|
|||||||
if (!(await ensureMod(interaction, PermissionFlagsBits.KickMembers, locale))) return;
|
if (!(await ensureMod(interaction, PermissionFlagsBits.KickMembers, locale))) return;
|
||||||
const user = interaction.options.getUser('user', true);
|
const user = interaction.options.getUser('user', true);
|
||||||
const reason = reasonFrom(interaction, locale);
|
const reason = reasonFrom(interaction, locale);
|
||||||
const member = (await interaction.guild!.members.fetch(user.id)) as GuildMember;
|
const hierarchy = await assertModerableMember(interaction, user.id, locale, 'kick');
|
||||||
await member.kick(reason);
|
if (!hierarchy.ok || !hierarchy.member) return;
|
||||||
await createCase(context, {
|
await hierarchy.member.kick(reason);
|
||||||
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: user.id,
|
targetUserId: user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -107,7 +134,7 @@ const kickCommand: SlashCommand = {
|
|||||||
action: 'KICK',
|
action: 'KICK',
|
||||||
reason
|
reason
|
||||||
});
|
});
|
||||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -117,11 +144,21 @@ const timeoutCommand: SlashCommand = {
|
|||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return;
|
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return;
|
||||||
const user = interaction.options.getUser('user', true);
|
const user = interaction.options.getUser('user', true);
|
||||||
const duration = parseDuration(interaction.options.getString('duration', true));
|
let duration: number;
|
||||||
|
try {
|
||||||
|
duration = parseTimeoutDuration(interaction.options.getString('duration', true));
|
||||||
|
} catch {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'moderation.duration.invalidTimeout'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const reason = reasonFrom(interaction, locale);
|
const reason = reasonFrom(interaction, locale);
|
||||||
const member = await interaction.guild!.members.fetch(user.id);
|
const hierarchy = await assertModerableMember(interaction, user.id, locale, 'timeout');
|
||||||
await member.timeout(duration, reason);
|
if (!hierarchy.ok || !hierarchy.member) return;
|
||||||
await createCase(context, {
|
await hierarchy.member.timeout(duration, reason);
|
||||||
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: user.id,
|
targetUserId: user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -130,7 +167,7 @@ const timeoutCommand: SlashCommand = {
|
|||||||
reason,
|
reason,
|
||||||
metadata: { durationMs: duration }
|
metadata: { durationMs: duration }
|
||||||
});
|
});
|
||||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -141,9 +178,10 @@ const untimeoutCommand: SlashCommand = {
|
|||||||
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return;
|
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return;
|
||||||
const user = interaction.options.getUser('user', true);
|
const user = interaction.options.getUser('user', true);
|
||||||
const reason = reasonFrom(interaction, locale);
|
const reason = reasonFrom(interaction, locale);
|
||||||
const member = await interaction.guild!.members.fetch(user.id);
|
const hierarchy = await assertModerableMember(interaction, user.id, locale, 'timeout');
|
||||||
await member.timeout(null, reason);
|
if (!hierarchy.ok || !hierarchy.member) return;
|
||||||
await createCase(context, {
|
await hierarchy.member.timeout(null, reason);
|
||||||
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: user.id,
|
targetUserId: user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -151,7 +189,7 @@ const untimeoutCommand: SlashCommand = {
|
|||||||
action: 'UN_TIMEOUT',
|
action: 'UN_TIMEOUT',
|
||||||
reason
|
reason
|
||||||
});
|
});
|
||||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -166,7 +204,19 @@ const warnCommand: SlashCommand = {
|
|||||||
const action = interaction.options.getString('action', true);
|
const action = interaction.options.getString('action', true);
|
||||||
const durationInput = interaction.options.getString('duration');
|
const durationInput = interaction.options.getString('duration');
|
||||||
const reason = interaction.options.getString('reason');
|
const reason = interaction.options.getString('reason');
|
||||||
const durationMs = durationInput ? parseDuration(durationInput) : null;
|
let durationMs: number | null = null;
|
||||||
|
if (durationInput) {
|
||||||
|
try {
|
||||||
|
durationMs =
|
||||||
|
action === 'TIMEOUT' ? parseTimeoutDuration(durationInput) : parseDuration(durationInput);
|
||||||
|
} catch {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'moderation.duration.invalid'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (action === 'TIMEOUT' && !durationMs) {
|
if (action === 'TIMEOUT' && !durationMs) {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: t(locale, 'moderation.escalation.durationRequired'),
|
content: t(locale, 'moderation.escalation.durationRequired'),
|
||||||
@@ -267,7 +317,8 @@ const warnCommand: SlashCommand = {
|
|||||||
interaction,
|
interaction,
|
||||||
context,
|
context,
|
||||||
user.id,
|
user.id,
|
||||||
interaction.user.id
|
interaction.user.id,
|
||||||
|
locale
|
||||||
);
|
);
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: escalationResult
|
content: escalationResult
|
||||||
@@ -300,8 +351,18 @@ const warnCommand: SlashCommand = {
|
|||||||
|
|
||||||
if (sub === 'remove') {
|
if (sub === 'remove') {
|
||||||
const warningId = interaction.options.getString('warning_id', true);
|
const warningId = interaction.options.getString('warning_id', true);
|
||||||
const warning = await context.prisma.warning.delete({ where: { id: warningId } });
|
const warning = await context.prisma.warning.findFirst({
|
||||||
await createCase(context, {
|
where: { id: warningId, guildId: interaction.guildId! }
|
||||||
|
});
|
||||||
|
if (!warning) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'moderation.warning.notFound'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await context.prisma.warning.delete({ where: { id: warning.id } });
|
||||||
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: warning.userId,
|
targetUserId: warning.userId,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -309,13 +370,15 @@ const warnCommand: SlashCommand = {
|
|||||||
action: 'WARN_REMOVE',
|
action: 'WARN_REMOVE',
|
||||||
reason: `Warning ${warningId} removed`
|
reason: `Warning ${warningId} removed`
|
||||||
});
|
});
|
||||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = interaction.options.getUser('user', true);
|
const user = interaction.options.getUser('user', true);
|
||||||
await context.prisma.warning.deleteMany({ where: { guildId: interaction.guildId!, userId: user.id } });
|
await context.prisma.warning.deleteMany({
|
||||||
await createCase(context, {
|
where: { guildId: interaction.guildId!, userId: user.id }
|
||||||
|
});
|
||||||
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: user.id,
|
targetUserId: user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -323,7 +386,7 @@ const warnCommand: SlashCommand = {
|
|||||||
action: 'WARN_CLEAR',
|
action: 'WARN_CLEAR',
|
||||||
reason: 'Warnings cleared'
|
reason: 'Warnings cleared'
|
||||||
});
|
});
|
||||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -352,7 +415,7 @@ const purgeCommand: SlashCommand = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await promptDestructiveConfirmation(interaction, locale, {
|
await promptDestructiveConfirmation(interaction, context, locale, {
|
||||||
action: 'purge',
|
action: 'purge',
|
||||||
payload: {
|
payload: {
|
||||||
channelId: interaction.channel.id,
|
channelId: interaction.channel.id,
|
||||||
@@ -376,7 +439,7 @@ const slowmodeCommand: SlashCommand = {
|
|||||||
if (interaction.channel instanceof TextChannel) {
|
if (interaction.channel instanceof TextChannel) {
|
||||||
await interaction.channel.setRateLimitPerUser(seconds);
|
await interaction.channel.setRateLimitPerUser(seconds);
|
||||||
}
|
}
|
||||||
await createCase(context, {
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: interaction.user.id,
|
targetUserId: interaction.user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -384,7 +447,7 @@ const slowmodeCommand: SlashCommand = {
|
|||||||
action: 'SLOWMODE',
|
action: 'SLOWMODE',
|
||||||
reason: `Set slowmode to ${seconds}s`
|
reason: `Set slowmode to ${seconds}s`
|
||||||
});
|
});
|
||||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -393,20 +456,33 @@ const lockCommand: SlashCommand = {
|
|||||||
async execute(interaction, context) {
|
async execute(interaction, context) {
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels, locale))) return;
|
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels, locale))) return;
|
||||||
if (interaction.channel?.type === ChannelType.GuildText) {
|
const serverWide = interaction.options.getBoolean('server') ?? false;
|
||||||
await interaction.channel.permissionOverwrites.edit(interaction.guild!.roles.everyone, {
|
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
|
SendMessages: false
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await createCase(context, {
|
} else if (interaction.channel?.type === ChannelType.GuildText) {
|
||||||
|
await interaction.channel.permissionOverwrites.edit(guild.roles.everyone, {
|
||||||
|
SendMessages: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: interaction.user.id,
|
targetUserId: interaction.user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
...auditFrom(interaction),
|
...auditFrom(interaction),
|
||||||
action: 'LOCK',
|
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) {
|
async execute(interaction, context) {
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels, locale))) return;
|
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels, locale))) return;
|
||||||
if (interaction.channel?.type === ChannelType.GuildText) {
|
const serverWide = interaction.options.getBoolean('server') ?? false;
|
||||||
await interaction.channel.permissionOverwrites.edit(interaction.guild!.roles.everyone, {
|
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
|
SendMessages: null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await createCase(context, {
|
} else if (interaction.channel?.type === ChannelType.GuildText) {
|
||||||
|
await interaction.channel.permissionOverwrites.edit(guild.roles.everyone, {
|
||||||
|
SendMessages: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: interaction.user.id,
|
targetUserId: interaction.user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
...auditFrom(interaction),
|
...auditFrom(interaction),
|
||||||
action: 'UNLOCK',
|
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;
|
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageNicknames, locale))) return;
|
||||||
const sub = interaction.options.getSubcommand();
|
const sub = interaction.options.getSubcommand();
|
||||||
const user = interaction.options.getUser('user', true);
|
const user = interaction.options.getUser('user', true);
|
||||||
const member = await interaction.guild!.members.fetch(user.id);
|
const hierarchy = await assertModerableMember(interaction, user.id, locale, 'nick');
|
||||||
|
if (!hierarchy.ok || !hierarchy.member) return;
|
||||||
|
const member = hierarchy.member;
|
||||||
if (sub === 'set') {
|
if (sub === 'set') {
|
||||||
const nickname = interaction.options.getString('nickname', true);
|
const nickname = interaction.options.getString('nickname', true);
|
||||||
await member.setNickname(nickname);
|
await member.setNickname(nickname);
|
||||||
await createCase(context, {
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: user.id,
|
targetUserId: user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -451,9 +542,10 @@ const nickCommand: SlashCommand = {
|
|||||||
action: 'NICK_SET',
|
action: 'NICK_SET',
|
||||||
reason: nickname
|
reason: nickname
|
||||||
});
|
});
|
||||||
|
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||||
} else {
|
} else {
|
||||||
await member.setNickname(null);
|
await member.setNickname(null);
|
||||||
await createCase(context, {
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: user.id,
|
targetUserId: user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -461,8 +553,8 @@ const nickCommand: SlashCommand = {
|
|||||||
action: 'NICK_RESET',
|
action: 'NICK_RESET',
|
||||||
reason: 'Nickname reset'
|
reason: 'Nickname reset'
|
||||||
});
|
});
|
||||||
|
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||||
}
|
}
|
||||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -474,8 +566,12 @@ const caseCommand: SlashCommand = {
|
|||||||
const sub = interaction.options.getSubcommand();
|
const sub = interaction.options.getSubcommand();
|
||||||
const caseNumber = interaction.options.getInteger('id', true);
|
const caseNumber = interaction.options.getInteger('id', true);
|
||||||
if (sub === 'view') {
|
if (sub === 'view') {
|
||||||
const found = await context.prisma.case.findUnique({
|
const found = await context.prisma.case.findFirst({
|
||||||
where: { guildId_caseNumber: { guildId: interaction.guildId!, caseNumber } }
|
where: {
|
||||||
|
guildId: interaction.guildId!,
|
||||||
|
caseNumber,
|
||||||
|
deletedAt: null
|
||||||
|
}
|
||||||
});
|
});
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: found ? JSON.stringify(found, null, 2) : t(locale, 'moderation.case.notFound'),
|
content: found ? JSON.stringify(found, null, 2) : t(locale, 'moderation.case.notFound'),
|
||||||
@@ -485,14 +581,31 @@ const caseCommand: SlashCommand = {
|
|||||||
}
|
}
|
||||||
if (sub === 'edit') {
|
if (sub === 'edit') {
|
||||||
const reason = interaction.options.getString('reason', true);
|
const reason = interaction.options.getString('reason', true);
|
||||||
await context.prisma.case.update({
|
const existing = await context.prisma.case.findFirst({
|
||||||
where: { guildId_caseNumber: { guildId: interaction.guildId!, caseNumber } },
|
where: {
|
||||||
data: { reason }
|
guildId: interaction.guildId!,
|
||||||
|
caseNumber,
|
||||||
|
deletedAt: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'moderation.case.notFound'),
|
||||||
|
ephemeral: true
|
||||||
});
|
});
|
||||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await promptDestructiveConfirmation(interaction, locale, {
|
await context.prisma.case.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { reason }
|
||||||
|
});
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'moderation.success.case', { caseNumber }),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await promptDestructiveConfirmation(interaction, context, locale, {
|
||||||
action: 'case_delete',
|
action: 'case_delete',
|
||||||
payload: { caseNumber }
|
payload: { caseNumber }
|
||||||
});
|
});
|
||||||
@@ -517,7 +630,7 @@ const modNoteCommand: SlashCommand = {
|
|||||||
note
|
note
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
await createCase(context, {
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: user.id,
|
targetUserId: user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -525,7 +638,7 @@ const modNoteCommand: SlashCommand = {
|
|||||||
action: 'MODNOTE_ADD',
|
action: 'MODNOTE_ADD',
|
||||||
reason: note
|
reason: note
|
||||||
});
|
});
|
||||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const notes = await context.prisma.modNote.findMany({
|
const notes = await context.prisma.modNote.findMany({
|
||||||
|
|||||||
@@ -9,11 +9,18 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import { type Locale, t, tf } from '@nexumi/shared';
|
import { type Locale, t, tf } from '@nexumi/shared';
|
||||||
import type { BotContext } from '../../types.js';
|
import type { BotContext } from '../../types.js';
|
||||||
import { getGuildLocale } from '../../i18n.js';
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
|
import {
|
||||||
|
deleteConfirmation,
|
||||||
|
getConfirmation,
|
||||||
|
setConfirmation,
|
||||||
|
takeConfirmation
|
||||||
|
} from '../../confirmation-store.js';
|
||||||
import { executeBan, executeCaseDelete, executePurge } from './actions.js';
|
import { executeBan, executeCaseDelete, executePurge } from './actions.js';
|
||||||
|
|
||||||
const CONFIRM_PREFIX = 'mod:confirm:';
|
const CONFIRM_PREFIX = 'mod:confirm:';
|
||||||
const CANCEL_PREFIX = 'mod:cancel:';
|
const CANCEL_PREFIX = 'mod:cancel:';
|
||||||
const TTL_MS = 60_000;
|
const NAMESPACE = 'moderation';
|
||||||
|
const TTL_SECONDS = 60;
|
||||||
|
|
||||||
type BanPayload = {
|
type BanPayload = {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -44,20 +51,8 @@ type PendingEntry = PendingConfirmation & {
|
|||||||
moderatorId: string;
|
moderatorId: string;
|
||||||
guildId: string;
|
guildId: string;
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
expiresAt: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const pending = new Map<string, PendingEntry>();
|
|
||||||
|
|
||||||
function pruneExpired(): void {
|
|
||||||
const now = Date.now();
|
|
||||||
for (const [token, entry] of pending) {
|
|
||||||
if (entry.expiresAt <= now) {
|
|
||||||
pending.delete(token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmationMessage(locale: Locale, entry: PendingConfirmation): string {
|
function confirmationMessage(locale: Locale, entry: PendingConfirmation): string {
|
||||||
if (entry.action === 'ban') {
|
if (entry.action === 'ban') {
|
||||||
return tf(locale, 'moderation.confirm.ban', {
|
return tf(locale, 'moderation.confirm.ban', {
|
||||||
@@ -89,18 +84,23 @@ function buildRows(token: string, locale: Locale): ActionRowBuilder<ButtonBuilde
|
|||||||
|
|
||||||
export async function promptDestructiveConfirmation(
|
export async function promptDestructiveConfirmation(
|
||||||
interaction: ChatInputCommandInteraction,
|
interaction: ChatInputCommandInteraction,
|
||||||
|
context: BotContext,
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
entry: PendingConfirmation
|
entry: PendingConfirmation
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
pruneExpired();
|
|
||||||
const token = randomUUID();
|
const token = randomUUID();
|
||||||
pending.set(token, {
|
await setConfirmation(
|
||||||
|
context.redis,
|
||||||
|
NAMESPACE,
|
||||||
|
token,
|
||||||
|
{
|
||||||
...entry,
|
...entry,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
locale,
|
locale
|
||||||
expiresAt: Date.now() + TTL_MS
|
} satisfies PendingEntry,
|
||||||
});
|
TTL_SECONDS
|
||||||
|
);
|
||||||
|
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: confirmationMessage(locale, entry),
|
content: confirmationMessage(locale, entry),
|
||||||
@@ -113,8 +113,6 @@ export async function handleModerationConfirmation(
|
|||||||
interaction: ButtonInteraction,
|
interaction: ButtonInteraction,
|
||||||
context: BotContext
|
context: BotContext
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
pruneExpired();
|
|
||||||
|
|
||||||
const customId = interaction.customId;
|
const customId = interaction.customId;
|
||||||
const isConfirm = customId.startsWith(CONFIRM_PREFIX);
|
const isConfirm = customId.startsWith(CONFIRM_PREFIX);
|
||||||
const isCancel = customId.startsWith(CANCEL_PREFIX);
|
const isCancel = customId.startsWith(CANCEL_PREFIX);
|
||||||
@@ -123,31 +121,37 @@ export async function handleModerationConfirmation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const token = customId.slice(isConfirm ? CONFIRM_PREFIX.length : CANCEL_PREFIX.length);
|
const token = customId.slice(isConfirm ? CONFIRM_PREFIX.length : CANCEL_PREFIX.length);
|
||||||
const entry = pending.get(token);
|
const peeked = await getConfirmation<PendingEntry>(context.redis, NAMESPACE, token);
|
||||||
if (!entry) {
|
if (!peeked) {
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
await interaction.reply({ content: t(locale, 'moderation.confirm.expired'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'moderation.confirm.expired'), ephemeral: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.user.id !== entry.moderatorId) {
|
if (interaction.user.id !== peeked.moderatorId) {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: t(entry.locale, 'moderation.confirm.unauthorized'),
|
content: t(peeked.locale, 'moderation.confirm.unauthorized'),
|
||||||
ephemeral: true
|
ephemeral: true
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
pending.delete(token);
|
|
||||||
|
|
||||||
if (isCancel) {
|
if (isCancel) {
|
||||||
|
await deleteConfirmation(context.redis, NAMESPACE, token);
|
||||||
await interaction.update({
|
await interaction.update({
|
||||||
content: t(entry.locale, 'moderation.confirm.cancelled'),
|
content: t(peeked.locale, 'moderation.confirm.cancelled'),
|
||||||
components: []
|
components: []
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const entry = await takeConfirmation<PendingEntry>(context.redis, NAMESPACE, token);
|
||||||
|
if (!entry) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
await interaction.reply({ content: t(locale, 'moderation.confirm.expired'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (entry.action === 'ban') {
|
if (entry.action === 'ban') {
|
||||||
await executeBan(interaction, context, entry.locale, entry.payload);
|
await executeBan(interaction, context, entry.locale, entry.payload);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { parseDuration } from './duration.js';
|
import {
|
||||||
|
MAX_TIMEOUT_MS,
|
||||||
|
parseDuration,
|
||||||
|
parseTimeoutDuration,
|
||||||
|
tryParseDuration
|
||||||
|
} from './duration.js';
|
||||||
|
|
||||||
describe('parseDuration', () => {
|
describe('parseDuration', () => {
|
||||||
it('parses minutes', () => {
|
it('parses minutes', () => {
|
||||||
@@ -14,3 +19,27 @@ describe('parseDuration', () => {
|
|||||||
expect(() => parseDuration('tomorrow')).toThrow();
|
expect(() => parseDuration('tomorrow')).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('parseTimeoutDuration', () => {
|
||||||
|
it('accepts durations within Discord limit', () => {
|
||||||
|
expect(parseTimeoutDuration('1d')).toBe(86_400_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects durations over 28 days', () => {
|
||||||
|
expect(() => parseTimeoutDuration('30d')).toThrow(/28/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes MAX_TIMEOUT_MS as 28 days', () => {
|
||||||
|
expect(MAX_TIMEOUT_MS).toBe(28 * 24 * 60 * 60 * 1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tryParseDuration', () => {
|
||||||
|
it('returns null for invalid input', () => {
|
||||||
|
expect(tryParseDuration('nope')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns ms for valid input', () => {
|
||||||
|
expect(tryParseDuration('10s')).toBe(10_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,10 +1,39 @@
|
|||||||
|
export const MAX_TIMEOUT_MS = 28 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export class DurationParseError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'DurationParseError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function parseDuration(input: string): number {
|
export function parseDuration(input: string): number {
|
||||||
const m = input.match(/^(\d+)([smhd])$/i);
|
const m = input.match(/^(\d+)([smhd])$/i);
|
||||||
if (!m) {
|
if (!m) {
|
||||||
throw new Error('Duration must be like 30m, 12h, 7d');
|
throw new DurationParseError('Duration must be like 30m, 12h, 7d');
|
||||||
}
|
}
|
||||||
const amount = Number(m[1]);
|
const amount = Number(m[1]);
|
||||||
const unit = m[2].toLowerCase();
|
const unit = m[2]!.toLowerCase();
|
||||||
const factor = unit === 's' ? 1000 : unit === 'm' ? 60000 : unit === 'h' ? 3600000 : 86400000;
|
const factor = unit === 's' ? 1000 : unit === 'm' ? 60000 : unit === 'h' ? 3600000 : 86400000;
|
||||||
return amount * factor;
|
return amount * factor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse a duration and enforce Discord's 28-day timeout maximum. */
|
||||||
|
export function parseTimeoutDuration(input: string): number {
|
||||||
|
const ms = parseDuration(input);
|
||||||
|
if (ms <= 0) {
|
||||||
|
throw new DurationParseError('Duration must be positive');
|
||||||
|
}
|
||||||
|
if (ms > MAX_TIMEOUT_MS) {
|
||||||
|
throw new DurationParseError('Timeout duration cannot exceed 28 days');
|
||||||
|
}
|
||||||
|
return ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tryParseDuration(input: string): number | null {
|
||||||
|
try {
|
||||||
|
return parseDuration(input);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { moderationQueue } from '../../queues.js';
|
|||||||
import type { BotContext } from '../../types.js';
|
import type { BotContext } from '../../types.js';
|
||||||
import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js';
|
import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js';
|
||||||
import type { Prisma } from '@prisma/client';
|
import type { Prisma } from '@prisma/client';
|
||||||
import { buildCaseMetadata, type CaseAuditSource } from '@nexumi/shared';
|
import { buildCaseMetadata, type CaseAuditSource, t, type Locale } from '@nexumi/shared';
|
||||||
|
import { MAX_TIMEOUT_MS } from './duration.js';
|
||||||
|
|
||||||
type CreateCaseInput = {
|
type CreateCaseInput = {
|
||||||
guildId: string;
|
guildId: string;
|
||||||
@@ -22,18 +23,23 @@ export async function createCase(context: BotContext, input: CreateCaseInput) {
|
|||||||
create: { id: input.guildId }
|
create: { id: input.guildId }
|
||||||
});
|
});
|
||||||
|
|
||||||
const lastCase = await context.prisma.case.findFirst({
|
|
||||||
where: { guildId: input.guildId },
|
|
||||||
orderBy: { caseNumber: 'desc' }
|
|
||||||
});
|
|
||||||
|
|
||||||
const metadata = buildCaseMetadata({
|
const metadata = buildCaseMetadata({
|
||||||
source: input.source ?? 'slash_command',
|
source: input.source ?? 'slash_command',
|
||||||
channelId: input.channelId,
|
channelId: input.channelId,
|
||||||
extras: input.metadata
|
extras: input.metadata
|
||||||
});
|
});
|
||||||
|
|
||||||
return context.prisma.case.create({
|
let created = null;
|
||||||
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
|
try {
|
||||||
|
created = await context.prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
const lastCase = await tx.case.findFirst({
|
||||||
|
where: { guildId: input.guildId },
|
||||||
|
orderBy: { caseNumber: 'desc' },
|
||||||
|
select: { caseNumber: true }
|
||||||
|
});
|
||||||
|
return tx.case.create({
|
||||||
data: {
|
data: {
|
||||||
guildId: input.guildId,
|
guildId: input.guildId,
|
||||||
caseNumber: (lastCase?.caseNumber ?? 0) + 1,
|
caseNumber: (lastCase?.caseNumber ?? 0) + 1,
|
||||||
@@ -43,7 +49,29 @@ export async function createCase(context: BotContext, input: CreateCaseInput) {
|
|||||||
reason: input.reason,
|
reason: input.reason,
|
||||||
metadata: metadata as Prisma.InputJsonValue
|
metadata: metadata as Prisma.InputJsonValue
|
||||||
}
|
}
|
||||||
}).then(async (created) => {
|
});
|
||||||
|
},
|
||||||
|
{ isolationLevel: 'Serializable' }
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
const code =
|
||||||
|
typeof error === 'object' && error && 'code' in error
|
||||||
|
? String((error as { code: unknown }).code)
|
||||||
|
: '';
|
||||||
|
if (code !== 'P2002' && attempt === 4) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (attempt === 4) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!created) {
|
||||||
|
throw new Error('Failed to create case');
|
||||||
|
}
|
||||||
|
|
||||||
const { logModAction } = await import('../logging/events.js');
|
const { logModAction } = await import('../logging/events.js');
|
||||||
await logModAction(context, {
|
await logModAction(context, {
|
||||||
guildId: input.guildId,
|
guildId: input.guildId,
|
||||||
@@ -54,7 +82,10 @@ export async function createCase(context: BotContext, input: CreateCaseInput) {
|
|||||||
reason: input.reason
|
reason: input.reason
|
||||||
});
|
});
|
||||||
return created;
|
return created;
|
||||||
});
|
}
|
||||||
|
|
||||||
|
export function tempBanJobId(guildId: string, userId: string): string {
|
||||||
|
return `tempBan:${guildId}:${userId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function scheduleTempBanExpire(
|
export async function scheduleTempBanExpire(
|
||||||
@@ -63,10 +94,17 @@ export async function scheduleTempBanExpire(
|
|||||||
durationMs: number,
|
durationMs: number,
|
||||||
reason?: string
|
reason?: string
|
||||||
) {
|
) {
|
||||||
|
const jobId = tempBanJobId(guildId, userId);
|
||||||
|
const existing = await moderationQueue.getJob(jobId);
|
||||||
|
if (existing) {
|
||||||
|
await existing.remove();
|
||||||
|
}
|
||||||
|
|
||||||
await moderationQueue.add(
|
await moderationQueue.add(
|
||||||
'tempBanExpire',
|
'tempBanExpire',
|
||||||
{ guildId, userId, reason: reason ?? null },
|
{ guildId, userId, reason: reason ?? null },
|
||||||
{
|
{
|
||||||
|
jobId,
|
||||||
delay: durationMs,
|
delay: durationMs,
|
||||||
removeOnComplete: 1000,
|
removeOnComplete: 1000,
|
||||||
removeOnFail: 500
|
removeOnFail: 500
|
||||||
@@ -78,7 +116,8 @@ export async function applyWarnEscalation(
|
|||||||
interaction: ChatInputCommandInteraction,
|
interaction: ChatInputCommandInteraction,
|
||||||
context: BotContext,
|
context: BotContext,
|
||||||
userId: string,
|
userId: string,
|
||||||
moderatorId: string
|
moderatorId: string,
|
||||||
|
locale: Locale
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const guildId = interaction.guildId;
|
const guildId = interaction.guildId;
|
||||||
if (!guildId || !interaction.guild) {
|
if (!guildId || !interaction.guild) {
|
||||||
@@ -98,16 +137,25 @@ export async function applyWarnEscalation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const member = await interaction.guild.members.fetch(userId);
|
const member = await interaction.guild.members.fetch(userId);
|
||||||
const reason = rule.reason ?? `Auto escalation at ${warningCount} warnings`;
|
const reason =
|
||||||
|
rule.reason ??
|
||||||
|
t(locale, 'moderation.escalation.defaultReason').replace('{warnCount}', String(warningCount));
|
||||||
|
|
||||||
if (rule.action === 'TIMEOUT') {
|
if (rule.action === 'TIMEOUT') {
|
||||||
if (!rule.durationMs) {
|
if (!rule.durationMs) {
|
||||||
return `Escalation rule for ${warningCount} warns skipped (missing duration).`;
|
return t(locale, 'moderation.escalation.skippedDuration').replace(
|
||||||
|
'{warnCount}',
|
||||||
|
String(warningCount)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.ModerateMembers)) {
|
if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.ModerateMembers)) {
|
||||||
return `Escalation rule for ${warningCount} warns skipped (missing bot permissions).`;
|
return t(locale, 'moderation.escalation.skippedPermission').replace(
|
||||||
|
'{warnCount}',
|
||||||
|
String(warningCount)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await member.timeout(rule.durationMs, reason);
|
const durationMs = Math.min(rule.durationMs, MAX_TIMEOUT_MS);
|
||||||
|
await member.timeout(durationMs, reason);
|
||||||
await createCase(context, {
|
await createCase(context, {
|
||||||
guildId,
|
guildId,
|
||||||
targetUserId: userId,
|
targetUserId: userId,
|
||||||
@@ -116,14 +164,20 @@ export async function applyWarnEscalation(
|
|||||||
reason,
|
reason,
|
||||||
channelId: interaction.channelId ?? undefined,
|
channelId: interaction.channelId ?? undefined,
|
||||||
source: 'escalation',
|
source: 'escalation',
|
||||||
metadata: { escalation: true, durationMs: rule.durationMs, warningCount }
|
metadata: { escalation: true, durationMs, warningCount }
|
||||||
});
|
});
|
||||||
return `Auto escalation applied: timeout (${rule.durationMs} ms).`;
|
return t(locale, 'moderation.escalation.appliedTimeout').replace(
|
||||||
|
'{durationMs}',
|
||||||
|
String(durationMs)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rule.action === 'KICK') {
|
if (rule.action === 'KICK') {
|
||||||
if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.KickMembers)) {
|
if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.KickMembers)) {
|
||||||
return `Escalation rule for ${warningCount} warns skipped (missing bot permissions).`;
|
return t(locale, 'moderation.escalation.skippedPermission').replace(
|
||||||
|
'{warnCount}',
|
||||||
|
String(warningCount)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await member.kick(reason);
|
await member.kick(reason);
|
||||||
await createCase(context, {
|
await createCase(context, {
|
||||||
@@ -136,11 +190,14 @@ export async function applyWarnEscalation(
|
|||||||
source: 'escalation',
|
source: 'escalation',
|
||||||
metadata: { escalation: true, warningCount }
|
metadata: { escalation: true, warningCount }
|
||||||
});
|
});
|
||||||
return 'Auto escalation applied: kick.';
|
return t(locale, 'moderation.escalation.appliedKick');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.BanMembers)) {
|
if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.BanMembers)) {
|
||||||
return `Escalation rule for ${warningCount} warns skipped (missing bot permissions).`;
|
return t(locale, 'moderation.escalation.skippedPermission').replace(
|
||||||
|
'{warnCount}',
|
||||||
|
String(warningCount)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await interaction.guild.members.ban(userId, { reason });
|
await interaction.guild.members.ban(userId, { reason });
|
||||||
await createCase(context, {
|
await createCase(context, {
|
||||||
@@ -153,5 +210,5 @@ export async function applyWarnEscalation(
|
|||||||
source: 'escalation',
|
source: 'escalation',
|
||||||
metadata: { escalation: true, warningCount }
|
metadata: { escalation: true, warningCount }
|
||||||
});
|
});
|
||||||
return 'Auto escalation applied: ban.';
|
return t(locale, 'moderation.escalation.appliedBan');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
EmbedBuilder,
|
|
||||||
PermissionFlagsBits,
|
PermissionFlagsBits,
|
||||||
type GuildTextBasedChannel,
|
type GuildTextBasedChannel,
|
||||||
type MessageCreateOptions,
|
type MessageCreateOptions,
|
||||||
type TextChannel
|
type TextChannel
|
||||||
} from 'discord.js';
|
} 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 type { ScheduledMessage } from '@prisma/client';
|
||||||
import { ensureGuild } from '../../guild.js';
|
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 { logger } from '../../logger.js';
|
||||||
import { scheduleQueue } from '../../queues.js';
|
import { scheduleQueue } from '../../queues.js';
|
||||||
import type { BotContext } from '../../types.js';
|
import type { BotContext } from '../../types.js';
|
||||||
@@ -91,23 +92,31 @@ function parseStoredEmbed(raw: unknown) {
|
|||||||
export function buildAnnouncementPayload(schedule: {
|
export function buildAnnouncementPayload(schedule: {
|
||||||
content: string | null;
|
content: string | null;
|
||||||
embed: unknown;
|
embed: unknown;
|
||||||
|
components?: unknown;
|
||||||
rolePingId: string | null;
|
rolePingId: string | null;
|
||||||
|
id?: string;
|
||||||
}): MessageCreateOptions {
|
}): MessageCreateOptions {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const embedData = parseStoredEmbed(schedule.embed);
|
const embedData = parseStoredEmbed(schedule.embed);
|
||||||
const embeds: EmbedBuilder[] = [];
|
const embed = buildEmbedFromPayload(embedData, {
|
||||||
|
renderText: expandBracketChannelMentions
|
||||||
|
});
|
||||||
|
|
||||||
if (embedData) {
|
const baseContent = expandBracketChannelMentions(schedule.content?.trim() ?? '');
|
||||||
const embed = new EmbedBuilder().setColor(embedData.color ?? 0x6366f1);
|
|
||||||
if (embedData.title) {
|
|
||||||
embed.setTitle(embedData.title);
|
|
||||||
}
|
|
||||||
if (embedData.description) {
|
|
||||||
embed.setDescription(embedData.description);
|
|
||||||
}
|
|
||||||
embeds.push(embed);
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseContent = schedule.content?.trim() ?? '';
|
|
||||||
const roleMention = schedule.rolePingId ? `<@&${schedule.rolePingId}>` : '';
|
const roleMention = schedule.rolePingId ? `<@&${schedule.rolePingId}>` : '';
|
||||||
const content = [roleMention, baseContent].filter(Boolean).join(' ').trim();
|
const content = [roleMention, baseContent].filter(Boolean).join(' ').trim();
|
||||||
|
|
||||||
@@ -115,8 +124,8 @@ export function buildAnnouncementPayload(schedule: {
|
|||||||
if (content) {
|
if (content) {
|
||||||
payload.content = content;
|
payload.content = content;
|
||||||
}
|
}
|
||||||
if (embeds.length > 0) {
|
if (embed) {
|
||||||
payload.embeds = embeds;
|
payload.embeds = [embed];
|
||||||
}
|
}
|
||||||
if (schedule.rolePingId) {
|
if (schedule.rolePingId) {
|
||||||
payload.allowedMentions = { roles: [schedule.rolePingId] };
|
payload.allowedMentions = { roles: [schedule.rolePingId] };
|
||||||
@@ -264,7 +273,11 @@ export async function listScheduledMessages(
|
|||||||
.map((schedule) => {
|
.map((schedule) => {
|
||||||
const preview =
|
const preview =
|
||||||
schedule.content?.slice(0, 60) ??
|
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
|
const timing = schedule.cron
|
||||||
? tf(locale, 'scheduler.list.cron', { cron: schedule.cron })
|
? tf(locale, 'scheduler.list.cron', { cron: schedule.cron })
|
||||||
: schedule.runAt
|
: schedule.runAt
|
||||||
@@ -322,7 +335,7 @@ export async function sendScheduledMessage(context: BotContext, scheduleId: stri
|
|||||||
try {
|
try {
|
||||||
const channel = await assertSendableChannel(context, schedule.channelId);
|
const channel = await assertSendableChannel(context, schedule.channelId);
|
||||||
const payload = buildAnnouncementPayload(schedule);
|
const payload = buildAnnouncementPayload(schedule);
|
||||||
if (!payload.content && !payload.embeds?.length) {
|
if (!payload.content && !payload.embeds?.length && !payload.components?.length) {
|
||||||
throw new SchedulerError('content_required');
|
throw new SchedulerError('content_required');
|
||||||
}
|
}
|
||||||
await (channel as TextChannel).send(payload);
|
await (channel as TextChannel).send(payload);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PermissionFlagsBits } from 'discord.js';
|
import { PermissionFlagsBits, type InteractionReplyOptions } from 'discord.js';
|
||||||
import { TagResponseTypeSchema, t, tf } from '@nexumi/shared';
|
import { TagResponseTypeSchema, t, tf } from '@nexumi/shared';
|
||||||
import type { SlashCommand } from '../../types.js';
|
import type { SlashCommand } from '../../types.js';
|
||||||
import { getGuildLocale } from '../../i18n.js';
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
@@ -67,7 +67,7 @@ const tagCommand: SlashCommand = {
|
|||||||
interaction.channelId!,
|
interaction.channelId!,
|
||||||
args
|
args
|
||||||
);
|
);
|
||||||
await interaction.reply(payload);
|
await interaction.reply(payload as InteractionReplyOptions);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { EmbedBuilder, type Guild, type GuildMember } from 'discord.js';
|
import type { Guild, GuildMember, MessageCreateOptions } from 'discord.js';
|
||||||
import {
|
import {
|
||||||
applyTagPlaceholders,
|
applyTagPlaceholders,
|
||||||
|
componentsV2HasContent,
|
||||||
|
embedHasContent,
|
||||||
|
parseMessageComponentsV2,
|
||||||
TagResponseTypeSchema,
|
TagResponseTypeSchema,
|
||||||
WelcomeEmbedSchema,
|
WelcomeEmbedSchema,
|
||||||
type TagResponseType
|
type MessageComponentsV2,
|
||||||
|
type TagResponseType,
|
||||||
|
type WelcomeEmbed
|
||||||
} from '@nexumi/shared';
|
} from '@nexumi/shared';
|
||||||
import type { Tag } from '@prisma/client';
|
import type { Tag } from '@prisma/client';
|
||||||
import { ensureGuild } from '../../guild.js';
|
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 type { BotContext } from '../../types.js';
|
||||||
import { assertPremiumLimit, PremiumLimitError } from '../../premium.js';
|
import { assertPremiumLimit, PremiumLimitError } from '../../premium.js';
|
||||||
|
|
||||||
@@ -50,7 +57,9 @@ export function buildTagPlaceholderVars(
|
|||||||
'user.name': member?.displayName ?? '',
|
'user.name': member?.displayName ?? '',
|
||||||
'user.tag': member?.user.tag ?? '',
|
'user.tag': member?.user.tag ?? '',
|
||||||
'user.id': member?.id ?? '',
|
'user.id': member?.id ?? '',
|
||||||
|
'user.avatar': member?.user.displayAvatarURL({ size: 256 }) ?? '',
|
||||||
server: guild.name,
|
server: guild.name,
|
||||||
|
'server.icon': guild.iconURL({ size: 128 }) ?? '',
|
||||||
args
|
args
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -65,10 +74,7 @@ export function parseTagEmbed(raw: unknown) {
|
|||||||
return parsed.success ? parsed.data : null;
|
return parsed.success ? parsed.data : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TagReplyPayload = {
|
export type TagReplyPayload = MessageCreateOptions;
|
||||||
content?: string;
|
|
||||||
embeds?: EmbedBuilder[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function buildTagReply(
|
export function buildTagReply(
|
||||||
tag: Tag,
|
tag: Tag,
|
||||||
@@ -79,16 +85,22 @@ export function buildTagReply(
|
|||||||
const vars = buildTagPlaceholderVars(member, guild, args);
|
const vars = buildTagPlaceholderVars(member, guild, args);
|
||||||
const responseType = TagResponseTypeSchema.parse(tag.responseType);
|
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') {
|
if (responseType === 'EMBED') {
|
||||||
const embedData = parseTagEmbed(tag.embed);
|
const embedData = parseTagEmbed(tag.embed);
|
||||||
const embed = new EmbedBuilder().setColor(embedData?.color ?? 0x6366f1);
|
const embed = buildEmbedFromPayload(embedData, {
|
||||||
if (embedData?.title) {
|
renderText: (value) => renderTagContent(value, vars)
|
||||||
embed.setTitle(renderTagContent(embedData.title, vars));
|
});
|
||||||
}
|
return embed ? { embeds: [embed] } : { content: '—' };
|
||||||
if (embedData?.description) {
|
|
||||||
embed.setDescription(renderTagContent(embedData.description, vars));
|
|
||||||
}
|
|
||||||
return { embeds: [embed] };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = renderTagContent(tag.content ?? '', vars);
|
const content = renderTagContent(tag.content ?? '', vars);
|
||||||
@@ -132,7 +144,8 @@ export async function createTag(
|
|||||||
name: string;
|
name: string;
|
||||||
responseType: TagResponseType;
|
responseType: TagResponseType;
|
||||||
content?: string | null;
|
content?: string | null;
|
||||||
embed?: { title?: string; description?: string; color?: number } | null;
|
embed?: WelcomeEmbed | null;
|
||||||
|
components?: MessageComponentsV2 | null;
|
||||||
triggerWord?: string | null;
|
triggerWord?: string | null;
|
||||||
allowedRoleIds?: string[];
|
allowedRoleIds?: string[];
|
||||||
allowedChannelIds?: string[];
|
allowedChannelIds?: string[];
|
||||||
@@ -150,10 +163,14 @@ export async function createTag(
|
|||||||
throw new TagError('content_required');
|
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');
|
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 } });
|
const currentCount = await context.prisma.tag.count({ where: { guildId: params.guildId } });
|
||||||
try {
|
try {
|
||||||
await assertPremiumLimit(context.prisma, params.guildId, 'tags', currentCount);
|
await assertPremiumLimit(context.prisma, params.guildId, 'tags', currentCount);
|
||||||
@@ -171,6 +188,7 @@ export async function createTag(
|
|||||||
responseType: params.responseType,
|
responseType: params.responseType,
|
||||||
content: params.content ?? null,
|
content: params.content ?? null,
|
||||||
embed: params.embed ?? undefined,
|
embed: params.embed ?? undefined,
|
||||||
|
components: params.components ?? undefined,
|
||||||
triggerWord: params.triggerWord?.trim() || null,
|
triggerWord: params.triggerWord?.trim() || null,
|
||||||
allowedRoleIds: params.allowedRoleIds ?? [],
|
allowedRoleIds: params.allowedRoleIds ?? [],
|
||||||
allowedChannelIds: params.allowedChannelIds ?? [],
|
allowedChannelIds: params.allowedChannelIds ?? [],
|
||||||
@@ -187,7 +205,8 @@ export async function updateTag(
|
|||||||
newName?: string;
|
newName?: string;
|
||||||
responseType?: TagResponseType;
|
responseType?: TagResponseType;
|
||||||
content?: string | null;
|
content?: string | null;
|
||||||
embed?: { title?: string; description?: string; color?: number } | null;
|
embed?: WelcomeEmbed | null;
|
||||||
|
components?: MessageComponentsV2 | null;
|
||||||
triggerWord?: string | null;
|
triggerWord?: string | null;
|
||||||
allowedRoleIds?: string[];
|
allowedRoleIds?: string[];
|
||||||
allowedChannelIds?: string[];
|
allowedChannelIds?: string[];
|
||||||
@@ -201,15 +220,23 @@ export async function updateTag(
|
|||||||
const responseType = updates.responseType ?? TagResponseTypeSchema.parse(existing.responseType);
|
const responseType = updates.responseType ?? TagResponseTypeSchema.parse(existing.responseType);
|
||||||
const content = updates.content !== undefined ? updates.content : existing.content;
|
const content = updates.content !== undefined ? updates.content : existing.content;
|
||||||
const embed = updates.embed !== undefined ? updates.embed : parseTagEmbed(existing.embed);
|
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()) {
|
if (responseType === 'TEXT' && !content?.trim()) {
|
||||||
throw new TagError('content_required');
|
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');
|
throw new TagError('embed_required');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (responseType === 'COMPONENTS_V2' && !componentsV2HasContent(components)) {
|
||||||
|
throw new TagError('components_required');
|
||||||
|
}
|
||||||
|
|
||||||
let newName: string | undefined;
|
let newName: string | undefined;
|
||||||
if (updates.newName) {
|
if (updates.newName) {
|
||||||
newName = normalizeTagName(updates.newName);
|
newName = normalizeTagName(updates.newName);
|
||||||
@@ -225,6 +252,7 @@ export async function updateTag(
|
|||||||
responseType,
|
responseType,
|
||||||
content,
|
content,
|
||||||
embed: embed ?? undefined,
|
embed: embed ?? undefined,
|
||||||
|
components: components ?? undefined,
|
||||||
triggerWord:
|
triggerWord:
|
||||||
updates.triggerWord !== undefined ? updates.triggerWord?.trim() || null : existing.triggerWord,
|
updates.triggerWord !== undefined ? updates.triggerWord?.trim() || null : existing.triggerWord,
|
||||||
allowedRoleIds: updates.allowedRoleIds ?? existing.allowedRoleIds,
|
allowedRoleIds: updates.allowedRoleIds ?? existing.allowedRoleIds,
|
||||||
|
|||||||
@@ -180,6 +180,40 @@ export const editsnipeCommandData = applyCommandDescription(
|
|||||||
export const embedCommandData = applyCommandDescription(
|
export const embedCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('embed'),
|
new SlashCommandBuilder().setName('embed'),
|
||||||
'utility.embed.description'
|
'utility.embed.description'
|
||||||
).addSubcommand((s) =>
|
)
|
||||||
|
.addSubcommand((s) =>
|
||||||
applyCommandDescription(s.setName('builder'), 'utility.embed.builder.description')
|
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'
|
||||||
|
)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -422,7 +422,123 @@ const embedCommand: SlashCommand = {
|
|||||||
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageMessages, locale))) {
|
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageMessages, locale))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sub = interaction.options.getSubcommand();
|
||||||
|
if (sub === 'builder') {
|
||||||
await showEmbedBuilderModal(interaction);
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
83
apps/bot/src/modules/verification/alt-detection.test.ts
Normal file
83
apps/bot/src/modules/verification/alt-detection.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
62
apps/bot/src/modules/verification/alt-detection.ts
Normal file
62
apps/bot/src/modules/verification/alt-detection.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -26,6 +26,18 @@ export const verifyCommandData = applyCommandDescription(
|
|||||||
{ name: 'Captcha', value: 'CAPTCHA' }
|
{ 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) =>
|
.addIntegerOption((o) =>
|
||||||
applyOptionDescription(o.setName('min_account_age_days'), 'verify.setup.options.min_account_age_days')
|
applyOptionDescription(o.setName('min_account_age_days'), 'verify.setup.options.min_account_age_days')
|
||||||
.setMinValue(0)
|
.setMinValue(0)
|
||||||
@@ -39,6 +51,9 @@ export const verifyCommandData = applyCommandDescription(
|
|||||||
{ name: 'None', value: 'NONE' }
|
{ name: 'None', value: 'NONE' }
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.addBooleanOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('alt_detection'), 'verify.setup.options.alt_detection')
|
||||||
|
)
|
||||||
)
|
)
|
||||||
.addSubcommand((s) =>
|
.addSubcommand((s) =>
|
||||||
applyCommandDescription(s.setName('panel'), 'verify.panel.description')
|
applyCommandDescription(s.setName('panel'), 'verify.panel.description')
|
||||||
|
|||||||
@@ -1,21 +1,41 @@
|
|||||||
import { PermissionFlagsBits } from 'discord.js';
|
import {
|
||||||
|
PermissionFlagsBits,
|
||||||
|
type GuildBasedChannel,
|
||||||
|
type GuildTextBasedChannel
|
||||||
|
} from 'discord.js';
|
||||||
import { t } from '@nexumi/shared';
|
import { t } from '@nexumi/shared';
|
||||||
import type { SlashCommand } from '../../types.js';
|
import type { SlashCommand } from '../../types.js';
|
||||||
import { getGuildLocale } from '../../i18n.js';
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
import { requirePermission } from '../../permissions.js';
|
import { requirePermission } from '../../permissions.js';
|
||||||
|
import { ephemeral } from '../../interaction-reply.js';
|
||||||
import { verifyCommandData } from './command-definitions.js';
|
import { verifyCommandData } from './command-definitions.js';
|
||||||
import {
|
import { getVerificationConfig, updateVerificationConfig } from './service.js';
|
||||||
getVerificationConfig,
|
|
||||||
updateVerificationConfig
|
|
||||||
} from './service.js';
|
|
||||||
import { buildVerificationPanel } from './handlers.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 = {
|
const verifyCommand: SlashCommand = {
|
||||||
data: verifyCommandData,
|
data: verifyCommandData,
|
||||||
async execute(interaction, context) {
|
async execute(interaction, context) {
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
if (!interaction.inGuild()) {
|
if (!interaction.inGuild()) {
|
||||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
|
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
|
||||||
@@ -30,11 +50,20 @@ const verifyCommand: SlashCommand = {
|
|||||||
const verifiedRole = interaction.options.getRole('verified_role', true);
|
const verifiedRole = interaction.options.getRole('verified_role', true);
|
||||||
const unverifiedRole = interaction.options.getRole('unverified_role');
|
const unverifiedRole = interaction.options.getRole('unverified_role');
|
||||||
const mode = (interaction.options.getString('mode') ?? 'BUTTON') as 'BUTTON' | 'CAPTCHA';
|
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 minAccountAgeDays = interaction.options.getInteger('min_account_age_days') ?? 0;
|
||||||
const failAction = (interaction.options.getString('fail_action') ?? 'KICK') as
|
const failAction = (interaction.options.getString('fail_action') ?? 'KICK') as
|
||||||
| 'KICK'
|
| 'KICK'
|
||||||
| 'BAN'
|
| 'BAN'
|
||||||
| 'NONE';
|
| 'NONE';
|
||||||
|
const altDetectionEnabled =
|
||||||
|
interaction.options.getBoolean('alt_detection') ?? false;
|
||||||
|
|
||||||
await updateVerificationConfig(context.prisma, guildId, {
|
await updateVerificationConfig(context.prisma, guildId, {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -42,17 +71,22 @@ const verifyCommand: SlashCommand = {
|
|||||||
verifiedRoleId: verifiedRole.id,
|
verifiedRoleId: verifiedRole.id,
|
||||||
unverifiedRoleId: unverifiedRole?.id ?? null,
|
unverifiedRoleId: unverifiedRole?.id ?? null,
|
||||||
mode,
|
mode,
|
||||||
|
captchaProvider,
|
||||||
minAccountAgeDays,
|
minAccountAgeDays,
|
||||||
failAction
|
failAction,
|
||||||
|
altDetectionEnabled
|
||||||
});
|
});
|
||||||
|
|
||||||
await interaction.reply({ content: t(locale, 'verification.setupDone'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'verification.setupDone'), ...ephemeral() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await getVerificationConfig(context.prisma, guildId);
|
const config = await getVerificationConfig(context.prisma, guildId);
|
||||||
if (!config.enabled || !config.verifiedRoleId) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,12 +97,16 @@ const verifyCommand: SlashCommand = {
|
|||||||
? await interaction.guild!.channels.fetch(config.channelId)
|
? await interaction.guild!.channels.fetch(config.channelId)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (!panelChannel?.isTextBased() || panelChannel.isDMBased()) {
|
const me = interaction.guild!.members.me;
|
||||||
await interaction.reply({ content: t(locale, 'verification.invalidChannel'), ephemeral: true });
|
if (!panelChannel || !me || !botCanPostPanel(panelChannel, me.id)) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'verification.panelMissingPermission'),
|
||||||
|
...ephemeral()
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const panel = buildVerificationPanel(config);
|
const panel = buildVerificationPanel(config, locale);
|
||||||
const message = await panelChannel.send(panel);
|
const message = await panelChannel.send(panel);
|
||||||
await context.prisma.verificationConfig.update({
|
await context.prisma.verificationConfig.update({
|
||||||
where: { guildId },
|
where: { guildId },
|
||||||
@@ -77,7 +115,7 @@ const verifyCommand: SlashCommand = {
|
|||||||
panelMessageId: message.id
|
panelMessageId: message.id
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
await interaction.reply({ content: t(locale, 'verification.panelPosted'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'verification.panelPosted'), ...ephemeral() });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
type ButtonInteraction,
|
type ButtonInteraction,
|
||||||
type GuildMember
|
type GuildMember
|
||||||
} from 'discord.js';
|
} 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 type { BotContext } from '../../types.js';
|
||||||
import { getGuildLocale } from '../../i18n.js';
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
import {
|
import {
|
||||||
@@ -18,8 +18,16 @@ import {
|
|||||||
verificationButtonId,
|
verificationButtonId,
|
||||||
verificationCaptchaButtonId
|
verificationCaptchaButtonId
|
||||||
} from './service.js';
|
} from './service.js';
|
||||||
|
import { detectAltAccount } from './alt-detection.js';
|
||||||
import { env } from '../../env.js';
|
import { env } from '../../env.js';
|
||||||
import { logger } from '../../logger.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(
|
async function applyFailAction(
|
||||||
member: GuildMember,
|
member: GuildMember,
|
||||||
@@ -39,7 +47,8 @@ async function applyFailAction(
|
|||||||
export async function completeVerification(
|
export async function completeVerification(
|
||||||
context: BotContext,
|
context: BotContext,
|
||||||
guildId: string,
|
guildId: string,
|
||||||
userId: string
|
userId: string,
|
||||||
|
options: CompleteVerificationOptions = {}
|
||||||
): Promise<{ ok: boolean; reason?: string }> {
|
): Promise<{ ok: boolean; reason?: string }> {
|
||||||
const config = await getVerificationConfig(context.prisma, guildId);
|
const config = await getVerificationConfig(context.prisma, guildId);
|
||||||
if (!config.enabled || !config.verifiedRoleId) {
|
if (!config.enabled || !config.verifiedRoleId) {
|
||||||
@@ -62,6 +71,88 @@ export async function completeVerification(
|
|||||||
return { ok: false, reason: 'account_too_young' };
|
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;
|
const me = guild.members.me;
|
||||||
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
|
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
|
||||||
return { ok: false, reason: 'missing_permissions' };
|
return { ok: false, reason: 'missing_permissions' };
|
||||||
@@ -82,13 +173,40 @@ export async function completeVerification(
|
|||||||
await member.roles.add(config.verifiedRoleId);
|
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 };
|
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()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle('Verification')
|
.setTitle(t(locale, 'verification.panel.title'))
|
||||||
.setDescription('Click the button below to verify and gain access to the server.')
|
.setDescription(t(locale, 'verification.panel.description'))
|
||||||
.setColor(0x6366f1);
|
.setColor(0x6366f1);
|
||||||
|
|
||||||
const customId =
|
const customId =
|
||||||
@@ -99,7 +217,11 @@ export function buildVerificationPanel(config: Awaited<ReturnType<typeof getVeri
|
|||||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
.setCustomId(customId)
|
.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)
|
.setStyle(ButtonStyle.Success)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -119,25 +241,29 @@ export async function handleVerificationButton(
|
|||||||
const config = await getVerificationConfig(context.prisma, parsed.guildId);
|
const config = await getVerificationConfig(context.prisma, parsed.guildId);
|
||||||
|
|
||||||
if (!config.enabled) {
|
if (!config.enabled) {
|
||||||
await interaction.reply({ content: t(locale, 'verification.disabled'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'verification.disabled'), ...ephemeral() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.mode === 'captcha') {
|
if (parsed.mode === 'captcha') {
|
||||||
|
const providerParse = CaptchaProviderSchema.safeParse(config.captchaProvider);
|
||||||
|
const provider: CaptchaProvider = providerParse.success ? providerParse.data : 'MATH';
|
||||||
const challenge = await createCaptchaChallenge(
|
const challenge = await createCaptchaChallenge(
|
||||||
context.redis,
|
context.redis,
|
||||||
parsed.guildId,
|
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({
|
await interaction.reply({
|
||||||
content: tf(locale, 'verification.captchaLink', { url }),
|
content: tf(locale, 'verification.captchaLink', { url }),
|
||||||
ephemeral: true
|
...ephemeral()
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await interaction.deferReply({ ephemeral: true });
|
await interaction.deferReply(ephemeral());
|
||||||
const result = await completeVerification(context, parsed.guildId, interaction.user.id);
|
const result = await completeVerification(context, parsed.guildId, interaction.user.id);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
await interaction.editReply({ content: t(locale, 'verification.success') });
|
await interaction.editReply({ content: t(locale, 'verification.success') });
|
||||||
|
|||||||
@@ -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 { PrismaClient } from '@prisma/client';
|
||||||
import type { VerificationFailAction, VerificationMode } from '@nexumi/shared';
|
|
||||||
import { ensureGuild } from '../../guild.js';
|
|
||||||
import type { Redis } from 'ioredis';
|
import type { Redis } from 'ioredis';
|
||||||
|
import { ensureGuild } from '../../guild.js';
|
||||||
|
|
||||||
const CAPTCHA_PREFIX = 'verify:captcha:';
|
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) {
|
export async function getVerificationConfig(prisma: PrismaClient, guildId: string) {
|
||||||
await ensureGuild(prisma, guildId);
|
await ensureGuild(prisma, guildId);
|
||||||
let config = await prisma.verificationConfig.findUnique({ where: { 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(
|
export async function updateVerificationConfig(
|
||||||
prisma: PrismaClient,
|
prisma: PrismaClient,
|
||||||
guildId: string,
|
guildId: string,
|
||||||
data: {
|
data: VerificationConfigUpdate
|
||||||
enabled: boolean;
|
|
||||||
channelId: string;
|
|
||||||
verifiedRoleId: string;
|
|
||||||
unverifiedRoleId?: string | null;
|
|
||||||
mode: VerificationMode;
|
|
||||||
minAccountAgeDays: number;
|
|
||||||
failAction: VerificationFailAction;
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
await ensureGuild(prisma, guildId);
|
await ensureGuild(prisma, guildId);
|
||||||
return prisma.verificationConfig.upsert({
|
return prisma.verificationConfig.upsert({
|
||||||
@@ -65,27 +73,32 @@ export type CaptchaChallenge = {
|
|||||||
token: string;
|
token: string;
|
||||||
guildId: string;
|
guildId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
question: string;
|
provider: CaptchaProvider;
|
||||||
answerHash: string;
|
question?: string;
|
||||||
|
answerHash?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function createCaptchaChallenge(
|
export async function createCaptchaChallenge(
|
||||||
redis: Redis,
|
redis: Redis,
|
||||||
guildId: string,
|
guildId: string,
|
||||||
userId: string
|
userId: string,
|
||||||
|
provider: CaptchaProvider = 'MATH'
|
||||||
): Promise<CaptchaChallenge> {
|
): Promise<CaptchaChallenge> {
|
||||||
const a = randomInt(2, 12);
|
|
||||||
const b = randomInt(2, 12);
|
|
||||||
const answer = String(a + b);
|
|
||||||
const token = randomBytes(24).toString('hex');
|
const token = randomBytes(24).toString('hex');
|
||||||
const answerHash = createHash('sha256').update(answer).digest('hex');
|
|
||||||
const challenge: CaptchaChallenge = {
|
const challenge: CaptchaChallenge = {
|
||||||
token,
|
token,
|
||||||
guildId,
|
guildId,
|
||||||
userId,
|
userId,
|
||||||
question: `${a} + ${b}`,
|
provider
|
||||||
answerHash
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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);
|
await redis.set(`${CAPTCHA_PREFIX}${token}`, JSON.stringify(challenge), 'EX', 600);
|
||||||
return challenge;
|
return challenge;
|
||||||
}
|
}
|
||||||
@@ -98,7 +111,11 @@ export async function getCaptchaChallenge(
|
|||||||
if (!raw) {
|
if (!raw) {
|
||||||
return null;
|
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> {
|
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();
|
const ms = Date.now() - userCreatedAt.getTime();
|
||||||
return Math.floor(ms / (1000 * 60 * 60 * 24));
|
return Math.floor(ms / (1000 * 60 * 60 * 24));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hashClientSignal(salt: string, value: string): string {
|
||||||
|
return createHash('sha256').update(`${salt}:${value}`).digest('hex');
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PermissionFlagsBits } from 'discord.js';
|
import { MessageFlags, PermissionFlagsBits, type InteractionReplyOptions } from 'discord.js';
|
||||||
import { t } from '@nexumi/shared';
|
import { t } from '@nexumi/shared';
|
||||||
import type { SlashCommand } from '../../types.js';
|
import type { SlashCommand } from '../../types.js';
|
||||||
import { getGuildLocale } from '../../i18n.js';
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
@@ -7,6 +7,26 @@ import { welcomeCommandData } from './command-definitions.js';
|
|||||||
import { getWelcomeConfig, resolveWelcomePayload } from './service.js';
|
import { getWelcomeConfig, resolveWelcomePayload } from './service.js';
|
||||||
import { buildWelcomeMessage } from './renderer.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 = {
|
const welcomeCommand: SlashCommand = {
|
||||||
data: welcomeCommandData,
|
data: welcomeCommandData,
|
||||||
async execute(interaction, context) {
|
async execute(interaction, context) {
|
||||||
@@ -32,7 +52,7 @@ const welcomeCommand: SlashCommand = {
|
|||||||
|
|
||||||
if (sub === 'preview') {
|
if (sub === 'preview') {
|
||||||
const message = await buildWelcomeMessage(payload, guildMember, interaction.guild!);
|
const message = await buildWelcomeMessage(payload, guildMember, interaction.guild!);
|
||||||
await interaction.reply({ ...message, ephemeral: true });
|
await interaction.reply(toInteractionReply(message, true));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +68,9 @@ const welcomeCommand: SlashCommand = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const message = await buildWelcomeMessage(payload, guildMember, interaction.guild!);
|
const message = await buildWelcomeMessage(payload, guildMember, interaction.guild!);
|
||||||
|
if ('send' in channel) {
|
||||||
await channel.send(message);
|
await channel.send(message);
|
||||||
|
}
|
||||||
await interaction.reply({ content: t(locale, 'welcome.testSent'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'welcome.testSent'), ephemeral: true });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { PermissionFlagsBits, type GuildMember } from 'discord.js';
|
import { PermissionFlagsBits, type GuildMember } from 'discord.js';
|
||||||
import type { BotContext } from '../../types.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 { sendLeaveMessage, sendWelcomeMessage } from './renderer.js';
|
||||||
import { logger } from '../../logger.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) {
|
if (config.welcomeDmEnabled && config.welcomeDmContent && !member.user.bot) {
|
||||||
await member
|
await member
|
||||||
.send({ content: config.welcomeDmContent.replace('{user}', `<@${member.id}>`) })
|
.send({ content: renderWelcomeText(config.welcomeDmContent, member, member.guild) })
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,8 +55,7 @@ export async function handleMemberLeave(context: BotContext, member: GuildMember
|
|||||||
if (!channel?.isTextBased() || channel.isDMBased()) {
|
if (!channel?.isTextBased() || channel.isDMBased()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const content = config.leaveContent ?? '{user.tag} left {server}.';
|
await sendLeaveMessage(channel, resolveLeavePayload(config), member, member.guild);
|
||||||
await sendLeaveMessage(channel, content, member, member.guild);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerWelcomeEvents(context: BotContext): void {
|
export function registerWelcomeEvents(context: BotContext): void {
|
||||||
|
|||||||
@@ -1,34 +1,40 @@
|
|||||||
import {
|
import {
|
||||||
AttachmentBuilder,
|
AttachmentBuilder,
|
||||||
EmbedBuilder,
|
|
||||||
type Guild,
|
type Guild,
|
||||||
type GuildMember,
|
type GuildMember,
|
||||||
|
type MessageCreateOptions,
|
||||||
type SendableChannels,
|
type SendableChannels,
|
||||||
type TextBasedChannel
|
type TextBasedChannel
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
import { createCanvas, loadImage } from '@napi-rs/canvas';
|
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';
|
import { renderWelcomeText } from './service.js';
|
||||||
|
|
||||||
export async function buildWelcomeMessage(
|
export async function buildWelcomeMessage(
|
||||||
payload: WelcomePayload,
|
payload: WelcomePayload,
|
||||||
member: GuildMember,
|
member: GuildMember,
|
||||||
guild: Guild
|
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') {
|
if (payload.type === 'EMBED') {
|
||||||
const embed = new EmbedBuilder();
|
const embed = buildEmbedFromPayload(payload.embed, {
|
||||||
const data = payload.embed;
|
renderText: (value) => renderWelcomeText(value, member, guild),
|
||||||
if (data?.title) {
|
defaultThumbnailUrl: member.user.displayAvatarURL({ size: 256 })
|
||||||
embed.setTitle(renderWelcomeText(data.title, member, guild));
|
});
|
||||||
}
|
return embed ? { embeds: [embed] } : { content: renderWelcomeText('{user}', 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] };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.type === 'IMAGE') {
|
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(
|
export async function sendLeaveMessage(
|
||||||
channel: TextBasedChannel,
|
channel: TextBasedChannel,
|
||||||
content: string,
|
payload: LeavePayload | string,
|
||||||
member: GuildMember,
|
member: GuildMember,
|
||||||
guild: Guild
|
guild: Guild
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if ('send' in channel) {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { PrismaClient } from '@prisma/client';
|
import type { PrismaClient } from '@prisma/client';
|
||||||
import type { WelcomeEmbed, WelcomeMessageType } from '@nexumi/shared';
|
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 type { Guild, GuildMember } from 'discord.js';
|
||||||
import { ensureGuild } from '../../guild.js';
|
import { ensureGuild } from '../../guild.js';
|
||||||
|
|
||||||
@@ -19,8 +19,10 @@ export function buildPlaceholderVars(member: GuildMember, guild: Guild) {
|
|||||||
'user.name': member.displayName,
|
'user.name': member.displayName,
|
||||||
'user.tag': member.user.tag,
|
'user.tag': member.user.tag,
|
||||||
'user.id': member.id,
|
'user.id': member.id,
|
||||||
|
'user.avatar': member.user.displayAvatarURL({ size: 256 }),
|
||||||
server: guild.name,
|
server: guild.name,
|
||||||
memberCount: guild.memberCount
|
memberCount: guild.memberCount,
|
||||||
|
'server.icon': guild.iconURL({ size: 128 }) ?? ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +41,7 @@ export type WelcomePayload = {
|
|||||||
type: WelcomeMessageType;
|
type: WelcomeMessageType;
|
||||||
content?: string | null;
|
content?: string | null;
|
||||||
embed?: WelcomeEmbed | null;
|
embed?: WelcomeEmbed | null;
|
||||||
|
components?: import('@nexumi/shared').MessageComponentsV2 | null;
|
||||||
imageTitle?: string | null;
|
imageTitle?: string | null;
|
||||||
imageSubtitle?: string | null;
|
imageSubtitle?: string | null;
|
||||||
};
|
};
|
||||||
@@ -50,7 +53,26 @@ export function resolveWelcomePayload(
|
|||||||
type: config.welcomeType as WelcomeMessageType,
|
type: config.welcomeType as WelcomeMessageType,
|
||||||
content: config.welcomeContent,
|
content: config.welcomeContent,
|
||||||
embed: parseWelcomeEmbed(config.welcomeEmbed),
|
embed: parseWelcomeEmbed(config.welcomeEmbed),
|
||||||
|
components: parseMessageComponentsV2(config.welcomeComponents),
|
||||||
imageTitle: config.imageTitle,
|
imageTitle: config.imageTitle,
|
||||||
imageSubtitle: config.imageSubtitle
|
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)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export const feedsQueueName = 'feeds';
|
|||||||
export const feedsQueue = new Queue(feedsQueueName, { connection: redis });
|
export const feedsQueue = new Queue(feedsQueueName, { connection: redis });
|
||||||
export const scheduleQueueName = 'schedules';
|
export const scheduleQueueName = 'schedules';
|
||||||
export const scheduleQueue = new Queue(scheduleQueueName, { connection: redis });
|
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 guildBackupQueueName = 'guild-backups';
|
||||||
export const guildBackupQueue = new Queue(guildBackupQueueName, { connection: redis });
|
export const guildBackupQueue = new Queue(guildBackupQueueName, { connection: redis });
|
||||||
export const suggestionsQueueName = 'suggestions';
|
export const suggestionsQueueName = 'suggestions';
|
||||||
|
|||||||
36
apps/bot/src/sentry.ts
Normal file
36
apps/bot/src/sentry.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import * as Sentry from '@sentry/node';
|
||||||
|
import { env } from './env.js';
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
export function initSentry(service: 'bot-manager' | 'bot-shard' | 'webui'): void {
|
||||||
|
if (initialized || !env.SENTRY_DSN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Sentry.init({
|
||||||
|
dsn: env.SENTRY_DSN,
|
||||||
|
environment: env.NODE_ENV,
|
||||||
|
release: process.env.npm_package_version
|
||||||
|
? `nexumi@${process.env.npm_package_version}`
|
||||||
|
: 'nexumi@0.1.0',
|
||||||
|
tracesSampleRate: env.NODE_ENV === 'production' ? 0.1 : 1.0,
|
||||||
|
initialScope: {
|
||||||
|
tags: { service }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
initialized = true;
|
||||||
|
logger.info({ service }, 'Sentry initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function captureException(error: unknown, context?: Record<string, unknown>): void {
|
||||||
|
if (!env.SENTRY_DSN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Sentry.withScope((scope) => {
|
||||||
|
if (context) {
|
||||||
|
scope.setExtras(context);
|
||||||
|
}
|
||||||
|
Sentry.captureException(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
37
apps/bot/src/shard-lifecycle.ts
Normal file
37
apps/bot/src/shard-lifecycle.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Client } from 'discord.js';
|
||||||
|
import { redis } from './redis.js';
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when this process owns shard 0 (or has no shard manager = single process).
|
||||||
|
*/
|
||||||
|
export function isPrimaryShard(client: Client): boolean {
|
||||||
|
const ids = client.shard?.ids;
|
||||||
|
if (!ids || ids.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return ids.includes(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acquires a short-lived Redis lock. Returns true if this caller owns the lock.
|
||||||
|
*/
|
||||||
|
export async function acquireOnceLock(lockKey: string, ttlSeconds: number): Promise<boolean> {
|
||||||
|
const result = await redis.set(lockKey, String(Date.now()), 'EX', ttlSeconds, 'NX');
|
||||||
|
return result === 'OK';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runOncePerCluster(
|
||||||
|
lockKey: string,
|
||||||
|
ttlSeconds: number,
|
||||||
|
fn: () => Promise<void>,
|
||||||
|
label: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const acquired = await acquireOnceLock(lockKey, ttlSeconds);
|
||||||
|
if (!acquired) {
|
||||||
|
logger.info({ lockKey }, `${label} skipped (another process holds the lock)`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await fn();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@ import type { NextConfig } from 'next';
|
|||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
transpilePackages: ['@nexumi/shared'],
|
transpilePackages: ['@nexumi/shared'],
|
||||||
outputFileTracingRoot: path.join(__dirname, '../..')
|
outputFileTracingRoot: path.join(__dirname, '../..'),
|
||||||
|
serverExternalPackages: ['@prisma/client', 'ioredis', 'bullmq']
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -15,8 +15,10 @@
|
|||||||
"@nexumi/shared": "workspace:^",
|
"@nexumi/shared": "workspace:^",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"@radix-ui/react-avatar": "^1.2.3",
|
"@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-dropdown-menu": "^2.1.21",
|
||||||
"@radix-ui/react-label": "^2.1.12",
|
"@radix-ui/react-label": "^2.1.12",
|
||||||
|
"@radix-ui/react-popover": "^1.1.20",
|
||||||
"@radix-ui/react-select": "^2.3.4",
|
"@radix-ui/react-select": "^2.3.4",
|
||||||
"@radix-ui/react-separator": "^1.1.12",
|
"@radix-ui/react-separator": "^1.1.12",
|
||||||
"@radix-ui/react-slot": "^1.3.0",
|
"@radix-ui/react-slot": "^1.3.0",
|
||||||
@@ -24,6 +26,7 @@
|
|||||||
"bullmq": "^5.34.0",
|
"bullmq": "^5.34.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"ioredis": "^5.4.2",
|
"ioredis": "^5.4.2",
|
||||||
"lucide-react": "^1.25.0",
|
"lucide-react": "^1.25.0",
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { CaseListQuerySchema } from '@nexumi/shared';
|
import { CaseListQuerySchema, CaseUpdateDashboardSchema } from '@nexumi/shared';
|
||||||
import { type NextRequest, NextResponse } from 'next/server';
|
import { type NextRequest, NextResponse } from 'next/server';
|
||||||
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
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 {
|
interface RouteParams {
|
||||||
params: Promise<{ guildId: string }>;
|
params: Promise<{ guildId: string }>;
|
||||||
@@ -11,15 +13,63 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
const { guildId } = await params;
|
const { guildId } = await params;
|
||||||
try {
|
try {
|
||||||
await requireGuildAccess(guildId);
|
await requireGuildAccess(guildId);
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const query = CaseListQuerySchema.parse(Object.fromEntries(request.nextUrl.searchParams));
|
||||||
const query = CaseListQuerySchema.parse({
|
|
||||||
search: searchParams.get('search') ?? undefined,
|
|
||||||
action: searchParams.get('action') ?? undefined,
|
|
||||||
limit: searchParams.get('limit') ?? undefined
|
|
||||||
});
|
|
||||||
const cases = await listCases(guildId, query);
|
const cases = await listCases(guildId, query);
|
||||||
return NextResponse.json({ cases });
|
return NextResponse.json({ cases });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return toApiErrorResponse(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
18
apps/webui/src/app/api/guilds/[guildId]/channels/route.ts
Normal file
18
apps/webui/src/app/api/guilds/[guildId]/channels/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
apps/webui/src/app/api/guilds/[guildId]/messages/route.ts
Normal file
41
apps/webui/src/app/api/guilds/[guildId]/messages/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
apps/webui/src/app/api/guilds/[guildId]/roles/route.ts
Normal file
18
apps/webui/src/app/api/guilds/[guildId]/roles/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,8 +13,8 @@ export async function GET(_request: NextRequest, { params }: RouteParams) {
|
|||||||
const { guildId } = await params;
|
const { guildId } = await params;
|
||||||
try {
|
try {
|
||||||
await requireGuildAccess(guildId);
|
await requireGuildAccess(guildId);
|
||||||
const config = await getVerificationDashboard(guildId);
|
const payload = await getVerificationDashboard(guildId);
|
||||||
return NextResponse.json(config);
|
return NextResponse.json(payload);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return toApiErrorResponse(error);
|
return toApiErrorResponse(error);
|
||||||
}
|
}
|
||||||
@@ -28,15 +28,19 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
|||||||
const patch = VerificationConfigDashboardPatchSchema.parse(body);
|
const patch = VerificationConfigDashboardPatchSchema.parse(body);
|
||||||
|
|
||||||
const before = await getVerificationDashboard(guildId);
|
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, {
|
await writeDashboardAudit(prisma, {
|
||||||
guildId,
|
guildId,
|
||||||
actorUserId: session.user.id,
|
actorUserId: session.user.id,
|
||||||
action: 'verification.update',
|
action: 'verification.update',
|
||||||
path: `/dashboard/${guildId}/verification`,
|
path: `/dashboard/${guildId}/verification`,
|
||||||
before,
|
before: before.config,
|
||||||
after
|
after: afterConfig
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(after);
|
return NextResponse.json(after);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { WarningListQuerySchema } from '@nexumi/shared';
|
import { WarningListQuerySchema } from '@nexumi/shared';
|
||||||
import { type NextRequest, NextResponse } from 'next/server';
|
import { type NextRequest, NextResponse } from 'next/server';
|
||||||
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
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 {
|
interface RouteParams {
|
||||||
params: Promise<{ guildId: string }>;
|
params: Promise<{ guildId: string }>;
|
||||||
@@ -11,13 +13,36 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
const { guildId } = await params;
|
const { guildId } = await params;
|
||||||
try {
|
try {
|
||||||
await requireGuildAccess(guildId);
|
await requireGuildAccess(guildId);
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const query = WarningListQuerySchema.parse(Object.fromEntries(request.nextUrl.searchParams));
|
||||||
const query = WarningListQuerySchema.parse({
|
|
||||||
userId: searchParams.get('userId') ?? undefined
|
|
||||||
});
|
|
||||||
const warnings = await listWarnings(guildId, query);
|
const warnings = await listWarnings(guildId, query);
|
||||||
return NextResponse.json({ warnings });
|
return NextResponse.json({ warnings });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return toApiErrorResponse(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { Suspense } from 'react';
|
||||||
import { CommandsManager } from '@/components/modules/commands-manager';
|
import { CommandsManager } from '@/components/modules/commands-manager';
|
||||||
|
import { listGuildChannelsForDashboard } from '@/lib/discord-guild-resources';
|
||||||
import { getLocale, t } from '@/lib/i18n';
|
import { getLocale, t } from '@/lib/i18n';
|
||||||
|
import { getCommandGlobalRules } from '@/lib/module-configs/command-global-rules';
|
||||||
import { listCommandOverridesDashboard } from '@/lib/module-configs/commands';
|
import { listCommandOverridesDashboard } from '@/lib/module-configs/commands';
|
||||||
|
|
||||||
interface CommandsPageProps {
|
interface CommandsPageProps {
|
||||||
@@ -8,7 +11,12 @@ interface CommandsPageProps {
|
|||||||
|
|
||||||
export default async function CommandsPage({ params }: CommandsPageProps) {
|
export default async function CommandsPage({ params }: CommandsPageProps) {
|
||||||
const { guildId } = await params;
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<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>
|
<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>
|
<p className="text-sm text-muted-foreground">{t(locale, 'modulePages.commands.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
<CommandsManager guildId={guildId} initialCommands={commands} />
|
<Suspense fallback={null}>
|
||||||
|
<CommandsManager
|
||||||
|
guildId={guildId}
|
||||||
|
initialCommands={commands}
|
||||||
|
initialGlobalRules={globalRules}
|
||||||
|
channels={channels}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
13
apps/webui/src/app/dashboard/[guildId]/messages/loading.tsx
Normal file
13
apps/webui/src/app/dashboard/[guildId]/messages/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
apps/webui/src/app/dashboard/[guildId]/messages/page.tsx
Normal file
21
apps/webui/src/app/dashboard/[guildId]/messages/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { CaseListQuerySchema } from '@nexumi/shared';
|
import { CaseListQuerySchema, WarningListQuerySchema } from '@nexumi/shared';
|
||||||
import { CasesTable } from '@/components/modules/cases-table';
|
import { CasesTable } from '@/components/modules/cases-table';
|
||||||
import { ModerationForm } from '@/components/modules/moderation-form';
|
import { ModerationForm } from '@/components/modules/moderation-form';
|
||||||
|
import { WarningsTable } from '@/components/modules/warnings-table';
|
||||||
import { getLocale, t } from '@/lib/i18n';
|
import { getLocale, t } from '@/lib/i18n';
|
||||||
import { listCases } from '@/lib/module-configs/cases';
|
import { listCases } from '@/lib/module-configs/cases';
|
||||||
import { getModerationDashboard } from '@/lib/module-configs/moderation';
|
import { getModerationDashboard } from '@/lib/module-configs/moderation';
|
||||||
|
import { listWarnings } from '@/lib/module-configs/warnings';
|
||||||
|
|
||||||
interface ModerationPageProps {
|
interface ModerationPageProps {
|
||||||
params: Promise<{ guildId: string }>;
|
params: Promise<{ guildId: string }>;
|
||||||
@@ -11,10 +13,11 @@ interface ModerationPageProps {
|
|||||||
|
|
||||||
export default async function ModerationPage({ params }: ModerationPageProps) {
|
export default async function ModerationPage({ params }: ModerationPageProps) {
|
||||||
const { guildId } = await params;
|
const { guildId } = await params;
|
||||||
const [locale, moderation, cases] = await Promise.all([
|
const [locale, moderation, cases, warnings] = await Promise.all([
|
||||||
getLocale(),
|
getLocale(),
|
||||||
getModerationDashboard(guildId),
|
getModerationDashboard(guildId),
|
||||||
listCases(guildId, CaseListQuerySchema.parse({}))
|
listCases(guildId, CaseListQuerySchema.parse({})),
|
||||||
|
listWarnings(guildId, WarningListQuerySchema.parse({}))
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
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>
|
<p className="text-sm text-muted-foreground">{t(locale, 'modules.moderation.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
<ModerationForm guildId={guildId} initialValue={moderation} />
|
<ModerationForm guildId={guildId} initialValue={moderation} />
|
||||||
|
<WarningsTable guildId={guildId} initialWarnings={warnings} />
|
||||||
<CasesTable guildId={guildId} initialCases={cases} />
|
<CasesTable guildId={guildId} initialCases={cases} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { DASHBOARD_MODULES } from '@nexumi/shared';
|
||||||
|
import { Activity, MessageSquare, Mic, Users } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { getActivitySummary } from '@/lib/activity';
|
import { getActivitySummary } from '@/lib/activity';
|
||||||
import { Badge } from '@/components/ui/badge';
|
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 { listRecentDashboardAudit } from '@/lib/audit';
|
||||||
import { getLocale, t } from '@/lib/i18n';
|
import { getLocale, t } from '@/lib/i18n';
|
||||||
import { getModuleStatuses } from '@/lib/modules';
|
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) {
|
export default async function GuildOverviewPage({ params }: OverviewPageProps) {
|
||||||
const { guildId } = await params;
|
const { guildId } = await params;
|
||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
@@ -26,55 +32,108 @@ export default async function GuildOverviewPage({ params }: OverviewPageProps) {
|
|||||||
getActivitySummary(guildId)
|
getActivitySummary(guildId)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const enabledCount = modules.filter((module) => module.enabled).length;
|
||||||
|
const numberLocale = locale === 'de' ? 'de-DE' : 'en-US';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-10">
|
||||||
<h1 className="text-2xl font-semibold">{t(locale, 'dashboard.overview.title')}</h1>
|
<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">
|
<section className="space-y-4">
|
||||||
|
<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>
|
<h2 className="text-lg font-medium">{t(locale, 'dashboard.overview.activityTitle')}</h2>
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
</div>
|
||||||
<Card>
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<CardContent className="p-4">
|
<Card className="border-border/80 bg-card/80">
|
||||||
<p className="text-xs text-muted-foreground">{t(locale, 'dashboard.overview.activityMessages')}</p>
|
<CardContent className="flex items-start gap-3 p-5">
|
||||||
<p className="text-2xl font-semibold">{activitySummary.messageCount.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}</p>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card className="border-border/80 bg-card/80">
|
||||||
<CardContent className="p-4">
|
<CardContent className="flex items-start gap-3 p-5">
|
||||||
<p className="text-xs text-muted-foreground">{t(locale, 'dashboard.overview.activityVoiceMinutes')}</p>
|
<div className="rounded-md bg-primary/10 p-2 text-primary">
|
||||||
<p className="text-2xl font-semibold">{activitySummary.voiceMinutes.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}</p>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card className="border-border/80 bg-card/80">
|
||||||
<CardContent className="p-4">
|
<CardContent className="flex items-start gap-3 p-5">
|
||||||
<p className="text-xs text-muted-foreground">{t(locale, 'dashboard.overview.activityActiveUsers')}</p>
|
<div className="rounded-md bg-primary/10 p-2 text-primary">
|
||||||
<p className="text-2xl font-semibold">{activitySummary.activeUserCount.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}</p>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">{t(locale, 'dashboard.overview.activityHint')}</p>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
<h2 className="text-lg font-medium">{t(locale, 'dashboard.overview.moduleStatusTitle')}</h2>
|
<h2 className="text-lg font-medium">{t(locale, 'dashboard.overview.moduleStatusTitle')}</h2>
|
||||||
<Link href={`/dashboard/${guildId}/modules`} className="text-sm text-primary hover:underline">
|
{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')}
|
{t(locale, 'dashboard.overview.viewAllModules')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{modules.length === 0 ? (
|
{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">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{modules.map((module) => (
|
{modules.map((module) => (
|
||||||
<Card key={module.id}>
|
<Link key={module.id} href={`/dashboard/${guildId}/${moduleHref(module.id)}`}>
|
||||||
<CardContent className="flex items-center justify-between p-4">
|
<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>
|
<span className="font-medium">{t(locale, `modules.${module.id}.label`)}</span>
|
||||||
<Badge variant={module.enabled ? 'success' : 'secondary'}>
|
<Badge variant={module.enabled ? 'success' : 'secondary'}>
|
||||||
{t(locale, module.enabled ? 'common.enabled' : 'common.disabled')}
|
{t(locale, module.enabled ? 'common.enabled' : 'common.disabled')}
|
||||||
</Badge>
|
</Badge>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -82,22 +141,23 @@ export default async function GuildOverviewPage({ params }: OverviewPageProps) {
|
|||||||
|
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<h2 className="text-lg font-medium">{t(locale, 'dashboard.overview.recentAuditTitle')}</h2>
|
<h2 className="text-lg font-medium">{t(locale, 'dashboard.overview.recentAuditTitle')}</h2>
|
||||||
<Card>
|
<Card className="border-border/80">
|
||||||
<CardHeader>
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm text-muted-foreground">
|
<CardTitle className="text-base">{t(locale, 'dashboard.overview.recentAuditTitle')}</CardTitle>
|
||||||
{t(locale, 'dashboard.overview.recentAuditTitle')}
|
<CardDescription>{t(locale, 'dashboard.overview.auditPath')}</CardDescription>
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent>
|
||||||
{auditEntries.length === 0 ? (
|
{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">
|
<ul className="divide-y divide-border">
|
||||||
{auditEntries.map((entry) => (
|
{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">
|
<div className="min-w-0">
|
||||||
<p className="truncate font-medium">{entry.action}</p>
|
<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>
|
</div>
|
||||||
<span className="whitespace-nowrap text-xs text-muted-foreground">
|
<span className="whitespace-nowrap text-xs text-muted-foreground">
|
||||||
{formatDate(entry.createdAt, locale)}
|
{formatDate(entry.createdAt, locale)}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ interface VerificationPageProps {
|
|||||||
|
|
||||||
export default async function VerificationPage({ params }: VerificationPageProps) {
|
export default async function VerificationPage({ params }: VerificationPageProps) {
|
||||||
const { guildId } = await params;
|
const { guildId } = await params;
|
||||||
const [locale, config] = await Promise.all([getLocale(), getVerificationDashboard(guildId)]);
|
const [locale, payload] = await Promise.all([getLocale(), getVerificationDashboard(guildId)]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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>
|
<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>
|
<p className="text-sm text-muted-foreground">{t(locale, 'modules.verification.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
<VerificationForm guildId={guildId} initialValue={config} />
|
<VerificationForm
|
||||||
|
guildId={guildId}
|
||||||
|
initialValue={payload.config}
|
||||||
|
availableCaptchaProviders={payload.availableCaptchaProviders}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,13 @@
|
|||||||
import type { DashboardGuild } from '@nexumi/shared';
|
import { DashboardGuildGrid } from '@/components/dashboard/dashboard-guild-grid';
|
||||||
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 { requireAuthOrRedirect } from '@/lib/auth';
|
import { requireAuthOrRedirect } from '@/lib/auth';
|
||||||
import { env } from '@/lib/env';
|
import { env } from '@/lib/env';
|
||||||
import { getManageableGuilds } from '@/lib/guilds';
|
import { getManageableGuilds } from '@/lib/guilds';
|
||||||
import { getLocale, t } from '@/lib/i18n';
|
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 {
|
function buildInviteUrl(guildId: string): string {
|
||||||
const url = new URL('https://discord.com/api/oauth2/authorize');
|
const url = new URL('https://discord.com/api/oauth2/authorize');
|
||||||
url.searchParams.set('client_id', env.BOT_CLIENT_ID);
|
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('scope', 'bot applications.commands');
|
||||||
url.searchParams.set('guild_id', guildId);
|
url.searchParams.set('guild_id', guildId);
|
||||||
return url.toString();
|
return url.toString();
|
||||||
@@ -38,6 +18,11 @@ export default async function DashboardGuildListPage() {
|
|||||||
const guilds = await getManageableGuilds(session);
|
const guilds = await getManageableGuilds(session);
|
||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
|
|
||||||
|
const inviteUrls: Record<string, string> = {};
|
||||||
|
for (const guild of guilds) {
|
||||||
|
inviteUrls[guild.id] = buildInviteUrl(guild.id);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen justify-center px-6 py-10">
|
<main className="flex min-h-screen justify-center px-6 py-10">
|
||||||
<div className="w-full max-w-[1200px] space-y-6">
|
<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>
|
<p className="text-sm text-muted-foreground">{t(locale, 'dashboard.guildList.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{guilds.length === 0 ? (
|
<DashboardGuildGrid
|
||||||
<Card>
|
guilds={guilds}
|
||||||
<CardContent className="py-10 text-center text-sm text-muted-foreground">
|
inviteUrls={inviteUrls}
|
||||||
{t(locale, 'dashboard.guildList.empty')}
|
labels={{
|
||||||
</CardContent>
|
empty: t(locale, 'dashboard.guildList.empty'),
|
||||||
</Card>
|
botMissing: t(locale, 'dashboard.guildList.botMissing'),
|
||||||
) : (
|
manage: t(locale, 'dashboard.guildList.manage'),
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
invite: t(locale, 'dashboard.guildList.invite')
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -81,4 +81,11 @@ body {
|
|||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: var(--font-sans), ui-sans-serif, system-ui, sans-serif;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,13 @@
|
|||||||
|
import { LegalDocument } from '@/components/site/legal-document';
|
||||||
import { LegalShell } from '@/components/site/legal-shell';
|
import { LegalShell } from '@/components/site/legal-shell';
|
||||||
import { env } from '@/lib/env';
|
|
||||||
import { getLocale, t } from '@/lib/i18n';
|
import { getLocale, t } from '@/lib/i18n';
|
||||||
|
|
||||||
export default async function ImpressumPage() {
|
export default async function ImpressumPage() {
|
||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
const configured = Boolean(env.LEGAL_OPERATOR_NAME && env.LEGAL_OPERATOR_ADDRESS && env.LEGAL_OPERATOR_EMAIL);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LegalShell title={t(locale, 'legal.impressum.title')}>
|
<LegalShell title={t(locale, 'legal.impressum.title')}>
|
||||||
<p className="text-sm text-muted-foreground">{t(locale, 'legal.impressum.intro')}</p>
|
<LegalDocument locale={locale} page="impressum" />
|
||||||
{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>
|
|
||||||
</LegalShell>
|
</LegalShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { Inter } from 'next/font/google';
|
import { Inter } from 'next/font/google';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Toaster } from 'sonner';
|
|
||||||
import { LocaleProvider } from '@/components/locale-provider';
|
import { LocaleProvider } from '@/components/locale-provider';
|
||||||
import { ThemeProvider } from '@/components/layout/theme-provider';
|
import { ThemeProvider } from '@/components/layout/theme-provider';
|
||||||
|
import { Toaster } from '@/components/ui/toaster';
|
||||||
import { getLocale, MESSAGES } from '@/lib/i18n';
|
import { getLocale, MESSAGES } from '@/lib/i18n';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ const inter = Inter({ subsets: ['latin'], variable: '--font-inter', display: 'sw
|
|||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Nexumi — Discord Bot',
|
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 }) {
|
export default async function RootLayout({ children }: { children: ReactNode }) {
|
||||||
@@ -23,7 +23,7 @@ export default async function RootLayout({ children }: { children: ReactNode })
|
|||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<LocaleProvider locale={locale} messages={MESSAGES[locale]}>
|
<LocaleProvider locale={locale} messages={MESSAGES[locale]}>
|
||||||
{children}
|
{children}
|
||||||
<Toaster richColors position="top-right" />
|
<Toaster />
|
||||||
</LocaleProvider>
|
</LocaleProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,52 +1,12 @@
|
|||||||
import { redirect } from 'next/navigation';
|
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 {
|
interface LoginPageProps {
|
||||||
searchParams: Promise<{ error?: string }>;
|
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) {
|
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||||
const session = await getSession();
|
|
||||||
if (session) {
|
|
||||||
redirect('/dashboard');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error } = await searchParams;
|
const { error } = await searchParams;
|
||||||
const locale = await getLocale();
|
const target = error ? `/?error=${encodeURIComponent(error)}` : '/';
|
||||||
const errorMessage =
|
redirect(target);
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,111 +1,17 @@
|
|||||||
import { DASHBOARD_MODULES, type DashboardModuleGroup } from '@nexumi/shared';
|
import { redirect } from 'next/navigation';
|
||||||
import Image from 'next/image';
|
import { LoginCard } from '@/components/auth/login-card';
|
||||||
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';
|
import { getSession } from '@/lib/session';
|
||||||
|
|
||||||
const GROUP_ORDER: DashboardModuleGroup[] = [
|
interface HomePageProps {
|
||||||
'moderation',
|
searchParams: Promise<{ error?: string }>;
|
||||||
'engagement',
|
}
|
||||||
'community',
|
|
||||||
'utility',
|
export default async function HomePage({ searchParams }: HomePageProps) {
|
||||||
'integrations'
|
const session = await getSession();
|
||||||
];
|
if (session) {
|
||||||
|
redirect('/dashboard');
|
||||||
export default async function LandingPage() {
|
}
|
||||||
const [locale, stats, session] = await Promise.all([getLocale(), getPublicStats(), getSession()]);
|
|
||||||
const inviteUrl = buildBotInviteUrl();
|
const { error } = await searchParams;
|
||||||
const isLoggedIn = Boolean(session);
|
return <LoginCard error={error} />;
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { LegalDocument } from '@/components/site/legal-document';
|
||||||
import { LegalShell } from '@/components/site/legal-shell';
|
import { LegalShell } from '@/components/site/legal-shell';
|
||||||
import { getLocale, t } from '@/lib/i18n';
|
import { getLocale, t } from '@/lib/i18n';
|
||||||
|
|
||||||
@@ -6,12 +7,7 @@ export default async function PrivacyPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<LegalShell title={t(locale, 'legal.privacy.title')}>
|
<LegalShell title={t(locale, 'legal.privacy.title')}>
|
||||||
<p className="text-sm text-amber-600 dark:text-amber-400">{t(locale, 'legal.scaffoldNotice')}</p>
|
<LegalDocument locale={locale} page="privacy" />
|
||||||
{['p1', 'p2', 'p3', 'p4', 'p5'].map((key) => (
|
|
||||||
<p key={key} className="text-sm text-muted-foreground">
|
|
||||||
{t(locale, `legal.privacy.${key}`)}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</LegalShell>
|
</LegalShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { LegalDocument } from '@/components/site/legal-document';
|
||||||
import { LegalShell } from '@/components/site/legal-shell';
|
import { LegalShell } from '@/components/site/legal-shell';
|
||||||
import { getLocale, t } from '@/lib/i18n';
|
import { getLocale, t } from '@/lib/i18n';
|
||||||
|
|
||||||
@@ -6,12 +7,7 @@ export default async function TermsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<LegalShell title={t(locale, 'legal.terms.title')}>
|
<LegalShell title={t(locale, 'legal.terms.title')}>
|
||||||
<p className="text-sm text-amber-600 dark:text-amber-400">{t(locale, 'legal.scaffoldNotice')}</p>
|
<LegalDocument locale={locale} page="terms" />
|
||||||
{['p1', 'p2', 'p3', 'p4'].map((key) => (
|
|
||||||
<p key={key} className="text-sm text-muted-foreground">
|
|
||||||
{t(locale, `legal.terms.${key}`)}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</LegalShell>
|
</LegalShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
243
apps/webui/src/app/verify/captcha/route.ts
Normal file
243
apps/webui/src/app/verify/captcha/route.ts
Normal 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('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
117
apps/webui/src/archived/landing-page.tsx
Normal file
117
apps/webui/src/archived/landing-page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
apps/webui/src/components/auth/login-card.tsx
Normal file
40
apps/webui/src/components/auth/login-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
apps/webui/src/components/dashboard/dashboard-guild-grid.tsx
Normal file
86
apps/webui/src/components/dashboard/dashboard-guild-grid.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
176
apps/webui/src/components/layout/dashboard-command-palette.tsx
Normal file
176
apps/webui/src/components/layout/dashboard-command-palette.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import type { DashboardGuild, SessionUser } from '@nexumi/shared';
|
import type { DashboardGuild, SessionUser } from '@nexumi/shared';
|
||||||
import type { ReactNode } from 'react';
|
import { Menu } from 'lucide-react';
|
||||||
import { ServerSwitcher } from './server-switcher';
|
import { useState, type ReactNode } from 'react';
|
||||||
import { Sidebar } from './sidebar';
|
import { DashboardCommandPalette } from '@/components/layout/dashboard-command-palette';
|
||||||
import { UserMenu } from './user-menu';
|
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 {
|
interface DashboardShellProps {
|
||||||
guildId: string;
|
guildId: string;
|
||||||
@@ -21,15 +29,36 @@ export function DashboardShell({
|
|||||||
showOwnerLink = false,
|
showOwnerLink = false,
|
||||||
children
|
children
|
||||||
}: DashboardShellProps) {
|
}: DashboardShellProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen bg-background">
|
||||||
|
<HashScroll />
|
||||||
<Sidebar guildId={guildId} />
|
<Sidebar guildId={guildId} />
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<header className="flex h-14 items-center justify-between border-b border-border px-6">
|
<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} />
|
<ServerSwitcher currentGuild={currentGuild} otherGuilds={otherGuilds} />
|
||||||
|
<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} />
|
<UserMenu user={user} showOwnerLink={showOwnerLink} />
|
||||||
|
</div>
|
||||||
</header>
|
</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>
|
<div className="mx-auto w-full max-w-[1200px]">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
47
apps/webui/src/components/layout/field-anchor.tsx
Normal file
47
apps/webui/src/components/layout/field-anchor.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,7 +1,36 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { DASHBOARD_MODULES, type DashboardModuleGroup } from '@nexumi/shared';
|
import { DASHBOARD_MODULES, type DashboardModuleGroup, type DashboardModuleId } from '@nexumi/shared';
|
||||||
import { LayoutGrid, Settings, ShieldCheck, SlidersHorizontal, Terminal } from 'lucide-react';
|
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 Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
@@ -15,19 +44,56 @@ const MODULE_GROUP_ORDER: DashboardModuleGroup[] = [
|
|||||||
'integrations'
|
'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;
|
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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
|
onClick={onNavigate}
|
||||||
className={cn(
|
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
|
active
|
||||||
? 'bg-accent text-accent-foreground'
|
? 'border-l-2 border-primary bg-primary/10 pl-2 text-foreground'
|
||||||
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground'
|
: 'border-l-2 border-transparent text-muted-foreground hover:bg-accent/60 hover:text-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{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 pathname = usePathname();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const base = `/dashboard/${guildId}`;
|
const base = `/dashboard/${guildId}`;
|
||||||
@@ -46,39 +112,50 @@ export function Sidebar({ guildId }: SidebarProps) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="flex h-full w-60 flex-col gap-6 overflow-y-auto border-r border-border px-3 py-4">
|
<nav className={cn('flex h-full flex-col gap-5 overflow-y-auto px-3 py-4', className)}>
|
||||||
<div className="flex flex-col gap-1">
|
<Link
|
||||||
<NavLink href={base} active={pathname === base}>
|
href={base}
|
||||||
<LayoutGrid className="size-4" />
|
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')}
|
{t('nav.overview')}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink href={`${base}/settings`} active={pathname === `${base}/settings`}>
|
<NavLink href={`${base}/settings`} active={pathname === `${base}/settings`} onNavigate={onNavigate}>
|
||||||
<Settings className="size-4" />
|
<Settings className="size-4 shrink-0" />
|
||||||
{t('nav.settings')}
|
{t('nav.settings')}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink href={`${base}/modules`} active={pathname === `${base}/modules`}>
|
<NavLink href={`${base}/modules`} active={pathname === `${base}/modules`} onNavigate={onNavigate}>
|
||||||
<SlidersHorizontal className="size-4" />
|
<SlidersHorizontal className="size-4 shrink-0" />
|
||||||
{t('nav.modules')}
|
{t('nav.modules')}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink href={`${base}/access`} active={pathname === `${base}/access`}>
|
<NavLink href={`${base}/access`} active={pathname === `${base}/access`} onNavigate={onNavigate}>
|
||||||
<ShieldCheck className="size-4" />
|
<ShieldCheck className="size-4 shrink-0" />
|
||||||
{t('nav.access')}
|
{t('nav.access')}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink href={`${base}/commands`} active={pathname === `${base}/commands`}>
|
<NavLink href={`${base}/commands`} active={pathname === `${base}/commands`} onNavigate={onNavigate}>
|
||||||
<Terminal className="size-4" />
|
<Terminal className="size-4 shrink-0" />
|
||||||
{t('nav.commands')}
|
{t('nav.commands')}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{groups.map(({ group, modules }) => (
|
{groups.map(({ group, modules }) => (
|
||||||
<div key={group} className="flex flex-col gap-1">
|
<div key={group} className="flex flex-col gap-0.5">
|
||||||
<p className="px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
<p className="mb-1 px-2.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/80">
|
||||||
{t(`moduleGroups.${group}`)}
|
{t(`moduleGroups.${group}`)}
|
||||||
</p>
|
</p>
|
||||||
{modules.map((module) => {
|
{modules.map((module) => {
|
||||||
const href = `${base}/${module.href}`;
|
const href = `${base}/${module.href}`;
|
||||||
|
const Icon = MODULE_ICONS[module.id] ?? Radio;
|
||||||
return (
|
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`)}
|
{t(`modules.${module.id}.label`)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
@@ -88,3 +165,12 @@ export function Sidebar({ guildId }: SidebarProps) {
|
|||||||
</nav>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,20 +1,48 @@
|
|||||||
'use client';
|
'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 { useTranslations } from '@/components/locale-provider';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
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 { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
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 { Switch } from '@/components/ui/switch';
|
||||||
import type { SettingsSaveResult } from '@/components/settings/settings-form';
|
import type { SettingsSaveResult } from '@/components/settings/settings-form';
|
||||||
import { SettingsForm } 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> {
|
async function saveAutomod(guildId: string, value: AutomodConfigDashboard): Promise<SettingsSaveResult> {
|
||||||
try {
|
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`, {
|
const response = await fetch(`/api/guilds/${guildId}/automod`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(value)
|
body: JSON.stringify(cleaned)
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = (await response.json().catch(() => null)) as { error?: string } | null;
|
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 {
|
interface AutomodFormProps {
|
||||||
guildId: string;
|
guildId: string;
|
||||||
initialValue: AutomodConfigDashboard;
|
initialValue: AutomodConfigDashboard;
|
||||||
@@ -35,7 +338,10 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
|
|||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsForm<AutomodConfigDashboard> initialValue={initialValue} onSave={(value) => saveAutomod(guildId, value)}>
|
<SettingsForm<AutomodConfigDashboard>
|
||||||
|
initialValue={initialValue}
|
||||||
|
onSave={(value) => saveAutomod(guildId, value)}
|
||||||
|
>
|
||||||
{({ value, setValue }) => (
|
{({ value, setValue }) => (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -44,6 +350,7 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
|
|||||||
<CardDescription>{t('modulePages.automod.description')}</CardDescription>
|
<CardDescription>{t('modulePages.automod.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
<FieldAnchor id="field-automod-enabled">
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label>{t('modulePages.automod.enabledLabel')}</Label>
|
<Label>{t('modulePages.automod.enabledLabel')}</Label>
|
||||||
@@ -54,9 +361,33 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
|
|||||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
|
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</FieldAnchor>
|
||||||
|
|
||||||
|
<FieldAnchor id="field-anti-raid">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('modulePages.automod.antiRaidTitle')}</CardTitle>
|
<CardTitle>{t('modulePages.automod.antiRaidTitle')}</CardTitle>
|
||||||
@@ -67,10 +398,12 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
|
|||||||
<Label>{t('modulePages.automod.antiRaidEnabled')}</Label>
|
<Label>{t('modulePages.automod.antiRaidEnabled')}</Label>
|
||||||
<Switch
|
<Switch
|
||||||
checked={value.antiRaidEnabled}
|
checked={value.antiRaidEnabled}
|
||||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, antiRaidEnabled: checked }))}
|
onCheckedChange={(checked) =>
|
||||||
|
setValue((prev) => ({ ...prev, antiRaidEnabled: checked }))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.automod.antiRaidJoinThreshold')}</Label>
|
<Label>{t('modulePages.automod.antiRaidJoinThreshold')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -78,7 +411,10 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
|
|||||||
min={1}
|
min={1}
|
||||||
value={value.antiRaidJoinThreshold}
|
value={value.antiRaidJoinThreshold}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setValue((prev) => ({ ...prev, antiRaidJoinThreshold: Number(event.target.value) || 1 }))
|
setValue((prev) => ({
|
||||||
|
...prev,
|
||||||
|
antiRaidJoinThreshold: Number(event.target.value) || 1
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -89,15 +425,44 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
|
|||||||
min={1}
|
min={1}
|
||||||
value={value.antiRaidWindowSeconds}
|
value={value.antiRaidWindowSeconds}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setValue((prev) => ({ ...prev, antiRaidWindowSeconds: Number(event.target.value) || 1 }))
|
setValue((prev) => ({
|
||||||
|
...prev,
|
||||||
|
antiRaidWindowSeconds: Number(event.target.value) || 1
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{t('modulePages.automod.antiRaidActionHint')}</p>
|
<p className="text-sm text-muted-foreground">{t('modulePages.automod.antiRaidActionHint')}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</FieldAnchor>
|
||||||
|
|
||||||
|
<FieldAnchor id="field-anti-nuke">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('modulePages.automod.antiNukeTitle')}</CardTitle>
|
<CardTitle>{t('modulePages.automod.antiNukeTitle')}</CardTitle>
|
||||||
@@ -108,7 +473,9 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
|
|||||||
<Label>{t('modulePages.automod.antiNukeEnabled')}</Label>
|
<Label>{t('modulePages.automod.antiNukeEnabled')}</Label>
|
||||||
<Switch
|
<Switch
|
||||||
checked={value.antiNukeEnabled}
|
checked={value.antiNukeEnabled}
|
||||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, antiNukeEnabled: checked }))}
|
onCheckedChange={(checked) =>
|
||||||
|
setValue((prev) => ({ ...prev, antiNukeEnabled: checked }))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
@@ -119,7 +486,10 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
|
|||||||
min={1}
|
min={1}
|
||||||
value={value.antiNukeBanThreshold}
|
value={value.antiNukeBanThreshold}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setValue((prev) => ({ ...prev, antiNukeBanThreshold: Number(event.target.value) || 1 }))
|
setValue((prev) => ({
|
||||||
|
...prev,
|
||||||
|
antiNukeBanThreshold: Number(event.target.value) || 1
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,7 +500,10 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
|
|||||||
min={1}
|
min={1}
|
||||||
value={value.antiNukeChannelThreshold}
|
value={value.antiNukeChannelThreshold}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setValue((prev) => ({ ...prev, antiNukeChannelThreshold: Number(event.target.value) || 1 }))
|
setValue((prev) => ({
|
||||||
|
...prev,
|
||||||
|
antiNukeChannelThreshold: Number(event.target.value) || 1
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,23 +514,33 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
|
|||||||
min={1}
|
min={1}
|
||||||
value={value.antiNukeWindowSeconds}
|
value={value.antiNukeWindowSeconds}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setValue((prev) => ({ ...prev, antiNukeWindowSeconds: Number(event.target.value) || 1 }))
|
setValue((prev) => ({
|
||||||
|
...prev,
|
||||||
|
antiNukeWindowSeconds: Number(event.target.value) || 1
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<FieldAnchor id="field-lockdown">
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label>{t('modulePages.automod.lockdownActive')}</Label>
|
<Label>{t('modulePages.automod.lockdownActive')}</Label>
|
||||||
<p className="text-sm text-muted-foreground">{t('modulePages.automod.lockdownActiveHint')}</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('modulePages.automod.lockdownActiveHint')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={value.lockdownActive}
|
checked={value.lockdownActive}
|
||||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, lockdownActive: checked }))}
|
onCheckedChange={(checked) =>
|
||||||
|
setValue((prev) => ({ ...prev, lockdownActive: checked }))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</FieldAnchor>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</SettingsForm>
|
</SettingsForm>
|
||||||
|
|||||||
@@ -2,10 +2,14 @@
|
|||||||
|
|
||||||
import type { BirthdayConfigDashboard } from '@nexumi/shared';
|
import type { BirthdayConfigDashboard } from '@nexumi/shared';
|
||||||
import { useId } from 'react';
|
import { useId } from 'react';
|
||||||
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
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 { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { PlaceholderHelp } from '@/components/ui/placeholder-help';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import type { SettingsSaveResult } from '@/components/settings/settings-form';
|
import type { SettingsSaveResult } from '@/components/settings/settings-form';
|
||||||
@@ -35,8 +39,6 @@ interface BirthdaysFormProps {
|
|||||||
|
|
||||||
export function BirthdaysForm({ guildId, initialValue }: BirthdaysFormProps) {
|
export function BirthdaysForm({ guildId, initialValue }: BirthdaysFormProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const channelId = useId();
|
|
||||||
const roleId = useId();
|
|
||||||
const timezoneId = useId();
|
const timezoneId = useId();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -48,28 +50,47 @@ export function BirthdaysForm({ guildId, initialValue }: BirthdaysFormProps) {
|
|||||||
<CardDescription>{t('modulePages.birthdays.description')}</CardDescription>
|
<CardDescription>{t('modulePages.birthdays.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
<FieldAnchor id="field-birthdays-enabled">
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
<Label>{t('modulePages.birthdays.enabledLabel')}</Label>
|
<Label>{t('modulePages.birthdays.enabledLabel')}</Label>
|
||||||
<Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} />
|
<Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} />
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<FieldAnchor id="field-birthdays-channel">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={channelId}>{t('modulePages.birthdays.channelId')}</Label>
|
<Label>{t('modulePages.birthdays.channelId')}</Label>
|
||||||
<Input id={channelId} value={value.channelId} onChange={(event) => setValue((prev) => ({ ...prev, channelId: event.target.value.trim() }))} placeholder="123456789012345678" />
|
<DiscordChannelSelect
|
||||||
|
guildId={guildId}
|
||||||
|
value={value.channelId}
|
||||||
|
onChange={(channelId) => setValue((prev) => ({ ...prev, channelId }))}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-birthdays-role">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={roleId}>{t('modulePages.birthdays.roleId')}</Label>
|
<Label>{t('modulePages.birthdays.roleId')}</Label>
|
||||||
<Input id={roleId} value={value.roleId} onChange={(event) => setValue((prev) => ({ ...prev, roleId: event.target.value.trim() }))} placeholder="123456789012345678" />
|
<DiscordRoleSelect
|
||||||
|
guildId={guildId}
|
||||||
|
value={value.roleId}
|
||||||
|
onChange={(roleId) => setValue((prev) => ({ ...prev, roleId }))}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-birthdays-timezone">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={timezoneId}>{t('modulePages.birthdays.timezone')}</Label>
|
<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" />
|
<Input id={timezoneId} value={value.timezone} onChange={(event) => setValue((prev) => ({ ...prev, timezone: event.target.value }))} placeholder="Europe/Berlin" />
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
</div>
|
</div>
|
||||||
|
<FieldAnchor id="field-birthdays-message">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.birthdays.message')}</Label>
|
<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')} />
|
<Textarea value={value.message ?? ''} onChange={(event) => setValue((prev) => ({ ...prev, message: event.target.value || null }))} placeholder={t('modulePages.birthdays.messagePlaceholder')} />
|
||||||
|
<PlaceholderHelp preset="birthdays" />
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { CaseDashboard } from '@nexumi/shared';
|
import type { CaseDashboard } from '@nexumi/shared';
|
||||||
import { Search } from 'lucide-react';
|
import { Pencil, Search, Trash2 } from 'lucide-react';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
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 [action, setAction] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editReason, setEditReason] = useState('');
|
||||||
|
|
||||||
const fetchCases = useCallback(async () => {
|
const fetchCases = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -47,7 +50,39 @@ export function CasesTable({ guildId, initialCases }: CasesTableProps) {
|
|||||||
}
|
}
|
||||||
}, [action, guildId, search, t]);
|
}, [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 (
|
return (
|
||||||
|
<FieldAnchor id="field-mod-cases">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('modulePages.moderation.casesTitle')}</CardTitle>
|
<CardTitle>{t('modulePages.moderation.casesTitle')}</CardTitle>
|
||||||
@@ -103,6 +138,7 @@ export function CasesTable({ guildId, initialCases }: CasesTableProps) {
|
|||||||
<th className="py-2 pr-4">{t('modulePages.moderation.moderator')}</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.reason')}</th>
|
||||||
<th className="py-2 pr-4">{t('modulePages.moderation.createdAt')}</th>
|
<th className="py-2 pr-4">{t('modulePages.moderation.createdAt')}</th>
|
||||||
|
<th className="py-2 pr-4" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
@@ -112,12 +148,52 @@ export function CasesTable({ guildId, initialCases }: CasesTableProps) {
|
|||||||
<td className="py-2 pr-4">{entry.action}</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.targetUserId}</td>
|
||||||
<td className="py-2 pr-4 font-mono text-xs">{entry.moderatorId}</td>
|
<td className="py-2 pr-4 font-mono text-xs">{entry.moderatorId}</td>
|
||||||
<td className="max-w-64 truncate py-2 pr-4">
|
<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')}
|
{entry.reason ?? t('modulePages.moderation.noReason')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="whitespace-nowrap py-2 pr-4 text-xs text-muted-foreground">
|
<td className="whitespace-nowrap py-2 pr-4 text-xs text-muted-foreground">
|
||||||
{formatDate(entry.createdAt)}
|
{formatDate(entry.createdAt)}
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -126,5 +202,6 @@ export function CasesTable({ guildId, initialCases }: CasesTableProps) {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</FieldAnchor>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,22 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { CommandOverrideDashboard } from '@nexumi/shared';
|
import type {
|
||||||
|
CommandGlobalRules,
|
||||||
|
CommandOverrideDashboard,
|
||||||
|
DiscordChannelOption
|
||||||
|
} from '@nexumi/shared';
|
||||||
import { RotateCcw } from 'lucide-react';
|
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 { toast } from 'sonner';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
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 { Input } from '@/components/ui/input';
|
||||||
import { Switch } from '@/components/ui/switch';
|
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 {
|
interface LocalOverride extends CommandOverrideDashboard {
|
||||||
saving?: boolean;
|
saving?: boolean;
|
||||||
}
|
}
|
||||||
@@ -28,12 +24,35 @@ interface LocalOverride extends CommandOverrideDashboard {
|
|||||||
interface CommandsManagerProps {
|
interface CommandsManagerProps {
|
||||||
guildId: string;
|
guildId: string;
|
||||||
initialCommands: CommandOverrideDashboard[];
|
initialCommands: CommandOverrideDashboard[];
|
||||||
|
initialGlobalRules: CommandGlobalRules;
|
||||||
|
channels: DiscordChannelOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommandsManager({ guildId, initialCommands }: CommandsManagerProps) {
|
export function CommandsManager({
|
||||||
|
guildId,
|
||||||
|
initialCommands,
|
||||||
|
initialGlobalRules,
|
||||||
|
channels
|
||||||
|
}: CommandsManagerProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const [commands, setCommands] = useState<LocalOverride[]>(initialCommands);
|
const [commands, setCommands] = useState<LocalOverride[]>(initialCommands);
|
||||||
const [search, setSearch] = useState('');
|
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 filtered = useMemo(() => {
|
||||||
const query = search.trim().toLowerCase();
|
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) {
|
async function handleSave(entry: LocalOverride) {
|
||||||
patchLocal(entry.commandName, { saving: true });
|
patchLocal(entry.commandName, { saving: true });
|
||||||
try {
|
try {
|
||||||
@@ -64,7 +114,9 @@ export function CommandsManager({ guildId, initialCommands }: CommandsManagerPro
|
|||||||
cooldownSeconds: entry.cooldownSeconds
|
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) {
|
if (!response.ok || !body) {
|
||||||
toast.error(body?.error ?? t('common.saveError'));
|
toast.error(body?.error ?? t('common.saveError'));
|
||||||
return;
|
return;
|
||||||
@@ -81,7 +133,9 @@ export function CommandsManager({ guildId, initialCommands }: CommandsManagerPro
|
|||||||
async function handleReset(entry: LocalOverride) {
|
async function handleReset(entry: LocalOverride) {
|
||||||
patchLocal(entry.commandName, { saving: true });
|
patchLocal(entry.commandName, { saving: true });
|
||||||
try {
|
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;
|
const body = (await response.json().catch(() => null)) as CommandOverrideDashboard | null;
|
||||||
if (!response.ok || !body) {
|
if (!response.ok || !body) {
|
||||||
toast.error(t('common.saveError'));
|
toast.error(t('common.saveError'));
|
||||||
@@ -97,6 +151,51 @@ export function CommandsManager({ guildId, initialCommands }: CommandsManagerPro
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<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>
|
||||||
|
<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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('modulePages.commands.title')}</CardTitle>
|
<CardTitle>{t('modulePages.commands.title')}</CardTitle>
|
||||||
@@ -143,49 +242,69 @@ export function CommandsManager({ guildId, initialCommands }: CommandsManagerPro
|
|||||||
placeholder={t('common.optional')}
|
placeholder={t('common.optional')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1 sm:col-span-2">
|
||||||
<p className="text-xs text-muted-foreground">{t('modulePages.commands.allowedRoleIds')}</p>
|
<p className="text-xs text-muted-foreground">{t('modulePages.commands.allowedRoleIds')}</p>
|
||||||
<Input
|
<DiscordRoleMultiSelect
|
||||||
value={toCsv(entry.allowedRoleIds)}
|
guildId={guildId}
|
||||||
onChange={(event) => patchLocal(entry.commandName, { allowedRoleIds: fromCsv(event.target.value) })}
|
value={entry.allowedRoleIds}
|
||||||
placeholder={t('modulePages.commands.idsPlaceholder')}
|
onChange={(allowedRoleIds) => patchLocal(entry.commandName, { allowedRoleIds })}
|
||||||
|
placeholder={t('modulePages.commands.roleSearchPlaceholder')}
|
||||||
|
disabled={entry.saving}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1 sm:col-span-2">
|
||||||
<p className="text-xs text-muted-foreground">{t('modulePages.commands.deniedRoleIds')}</p>
|
<p className="text-xs text-muted-foreground">{t('modulePages.commands.deniedRoleIds')}</p>
|
||||||
<Input
|
<DiscordRoleMultiSelect
|
||||||
value={toCsv(entry.deniedRoleIds)}
|
guildId={guildId}
|
||||||
onChange={(event) => patchLocal(entry.commandName, { deniedRoleIds: fromCsv(event.target.value) })}
|
value={entry.deniedRoleIds}
|
||||||
placeholder={t('modulePages.commands.idsPlaceholder')}
|
onChange={(deniedRoleIds) => patchLocal(entry.commandName, { deniedRoleIds })}
|
||||||
|
placeholder={t('modulePages.commands.roleSearchPlaceholder')}
|
||||||
|
disabled={entry.saving}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1 sm:col-span-2">
|
||||||
<p className="text-xs text-muted-foreground">{t('modulePages.commands.allowedChannelIds')}</p>
|
<p className="text-xs text-muted-foreground">{t('modulePages.commands.allowedChannelIds')}</p>
|
||||||
<Input
|
<DiscordResourceMultiSelect
|
||||||
value={toCsv(entry.allowedChannelIds)}
|
options={channelOptions}
|
||||||
onChange={(event) =>
|
value={entry.allowedChannelIds}
|
||||||
patchLocal(entry.commandName, { allowedChannelIds: fromCsv(event.target.value) })
|
onChange={(allowedChannelIds) =>
|
||||||
|
patchLocal(entry.commandName, { allowedChannelIds })
|
||||||
}
|
}
|
||||||
placeholder={t('modulePages.commands.idsPlaceholder')}
|
placeholder={t('modulePages.commands.channelSearchPlaceholder')}
|
||||||
|
disabled={entry.saving}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1 sm:col-span-2">
|
||||||
<p className="text-xs text-muted-foreground">{t('modulePages.commands.deniedChannelIds')}</p>
|
<p className="text-xs text-muted-foreground">{t('modulePages.commands.deniedChannelIds')}</p>
|
||||||
<Input
|
<DiscordResourceMultiSelect
|
||||||
value={toCsv(entry.deniedChannelIds)}
|
options={channelOptions}
|
||||||
onChange={(event) =>
|
value={entry.deniedChannelIds}
|
||||||
patchLocal(entry.commandName, { deniedChannelIds: fromCsv(event.target.value) })
|
onChange={(deniedChannelIds) =>
|
||||||
|
patchLocal(entry.commandName, { deniedChannelIds })
|
||||||
}
|
}
|
||||||
placeholder={t('modulePages.commands.idsPlaceholder')}
|
placeholder={t('modulePages.commands.channelSearchPlaceholder')}
|
||||||
|
disabled={entry.saving}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button type="button" variant="ghost" size="sm" disabled={entry.saving} onClick={() => handleReset(entry)}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={entry.saving}
|
||||||
|
onClick={() => void handleReset(entry)}
|
||||||
|
>
|
||||||
<RotateCcw className="size-4" />
|
<RotateCcw className="size-4" />
|
||||||
{t('modulePages.commands.reset')}
|
{t('modulePages.commands.reset')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="outline" size="sm" disabled={entry.saving} onClick={() => handleSave(entry)}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={entry.saving}
|
||||||
|
onClick={() => void handleSave(entry)}
|
||||||
|
>
|
||||||
{entry.saving ? t('common.saving') : t('common.save')}
|
{entry.saving ? t('common.saving') : t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -194,5 +313,6 @@ export function CommandsManager({ guildId, initialCommands }: CommandsManagerPro
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { EconomyConfigDashboard } from '@nexumi/shared';
|
import type { EconomyConfigDashboard } from '@nexumi/shared';
|
||||||
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -44,6 +45,7 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
|
|||||||
<CardDescription>{t('modulePages.economy.description')}</CardDescription>
|
<CardDescription>{t('modulePages.economy.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
<FieldAnchor id="field-economy-enabled">
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
<Label>{t('modulePages.economy.enabledLabel')}</Label>
|
<Label>{t('modulePages.economy.enabledLabel')}</Label>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -51,7 +53,9 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
|
|||||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
|
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FieldAnchor id="field-economy-currency-name">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.economy.currencyName')}</Label>
|
<Label>{t('modulePages.economy.currencyName')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -59,6 +63,8 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
|
|||||||
onChange={(event) => setValue((prev) => ({ ...prev, currencyName: event.target.value }))}
|
onChange={(event) => setValue((prev) => ({ ...prev, currencyName: event.target.value }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-economy-currency-symbol">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.economy.currencySymbol')}</Label>
|
<Label>{t('modulePages.economy.currencySymbol')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -66,6 +72,7 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
|
|||||||
onChange={(event) => setValue((prev) => ({ ...prev, currencySymbol: event.target.value }))}
|
onChange={(event) => setValue((prev) => ({ ...prev, currencySymbol: event.target.value }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -75,6 +82,7 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
|
|||||||
<CardTitle>{t('modulePages.economy.amountsTitle')}</CardTitle>
|
<CardTitle>{t('modulePages.economy.amountsTitle')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FieldAnchor id="field-economy-daily">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.economy.dailyAmount')}</Label>
|
<Label>{t('modulePages.economy.dailyAmount')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -84,6 +92,8 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
|
|||||||
onChange={(event) => setValue((prev) => ({ ...prev, dailyAmount: Number(event.target.value) || 0 }))}
|
onChange={(event) => setValue((prev) => ({ ...prev, dailyAmount: Number(event.target.value) || 0 }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-economy-weekly">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.economy.weeklyAmount')}</Label>
|
<Label>{t('modulePages.economy.weeklyAmount')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -93,6 +103,8 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
|
|||||||
onChange={(event) => setValue((prev) => ({ ...prev, weeklyAmount: Number(event.target.value) || 0 }))}
|
onChange={(event) => setValue((prev) => ({ ...prev, weeklyAmount: Number(event.target.value) || 0 }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-economy-work-min">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.economy.workMin')}</Label>
|
<Label>{t('modulePages.economy.workMin')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -102,6 +114,8 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
|
|||||||
onChange={(event) => setValue((prev) => ({ ...prev, workMin: Number(event.target.value) || 0 }))}
|
onChange={(event) => setValue((prev) => ({ ...prev, workMin: Number(event.target.value) || 0 }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-economy-work-max">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.economy.workMax')}</Label>
|
<Label>{t('modulePages.economy.workMax')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -111,6 +125,8 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
|
|||||||
onChange={(event) => setValue((prev) => ({ ...prev, workMax: Number(event.target.value) || 0 }))}
|
onChange={(event) => setValue((prev) => ({ ...prev, workMax: Number(event.target.value) || 0 }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-economy-work-cooldown">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.economy.workCooldownSeconds')}</Label>
|
<Label>{t('modulePages.economy.workCooldownSeconds')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -122,6 +138,8 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-economy-starting">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.economy.startingBalance')}</Label>
|
<Label>{t('modulePages.economy.startingBalance')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -133,6 +151,7 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user