Compare commits

...

31 Commits

Author SHA1 Message Date
2f01335e61 Merge pull request 'deploy' (#1) from deploy into main
Reviewed-on: #1
2026-07-24 08:41:09 +00:00
smueller
f2406fb6d7 fix: Giveaway bugs 2026-07-24 10:33:56 +02:00
smueller
bd1b55fff7 Implement lockdown management in automod system
- Added functionality to activate and deactivate lockdowns via the automod worker, responding to specific job names.
- Enhanced the anti-raid join handler to apply a verification role when lockdown is active and the anti-raid action is set to VERIFY.
- Updated the automod configuration to ensure default rules are created if missing, improving the setup process for new guilds.
- Introduced new API endpoints for managing cases and warnings, allowing for soft deletion and reason updates.
- Enhanced the moderation dashboard to include warnings management, improving user experience in moderation tasks.
- Updated localization files to reflect new features and improve user guidance in both English and German.
2026-07-24 10:21:49 +02:00
smueller
fba597a6f2 Implement leveling rewards feature with UI and backend support
- Added functionality to manage role rewards based on user levels in the leveling system.
- Updated the LevelingForm component to include fields for defining rewards, including validation for unique levels.
- Enhanced backend logic to handle reward data storage and retrieval, ensuring proper integration with the existing leveling configuration.
- Introduced localization keys for rewards in both English and German, improving user experience.
- Updated dashboard search index to include rewards settings for easier access.
2026-07-23 12:41:13 +02:00
smueller
3af989a83b Remove captcha provider hint from verification form and localization files in English and German 2026-07-23 12:25:49 +02:00
smueller
04e9ffad28 Enhance verification system with multi-provider support and alt-account detection
- Added support for multiple captcha providers including Google reCAPTCHA (v2 and v3), hCaptcha, and Cloudflare Turnstile.
- Introduced new fields in the verification configuration for selecting captcha providers and enabling alt-account detection.
- Implemented logic to handle verification attempts and flag potential alt accounts based on IP and invite code analysis.
- Updated environment configuration to include necessary keys for captcha providers.
- Enhanced the user interface to allow selection of captcha providers in the verification setup.
- Improved backend handling of verification records to store additional data related to captcha provider usage.
2026-07-23 11:28:58 +02:00
smueller
e24f99ad38 Enhance verification module with improved command handling and localization
- Updated the verification command to utilize a new `ephemeral` utility for consistent ephemeral message replies.
- Refactored the `buildVerificationPanel` function to include localization support for panel titles and descriptions.
- Added permission checks to ensure the bot can post in the designated verification channel.
- Introduced a new verification queue in the queue management system for better handling of verification tasks.
- Updated localization files to include new keys for verification-related messages in both English and German.
2026-07-23 10:54:21 +02:00
smueller
4b6736444c Enhance command registration process and improve guild command handling
- Introduced a new `buildCommandBody` function to prevent duplicate command definitions during registration.
- Implemented `clearGuildCommandLeftovers` to remove unused guild-scoped commands, ensuring a clean command picker in Discord.
- Updated command registration logic to clear global commands when using guild-specific commands, improving command visibility and user experience.
- Enhanced logging to provide clearer insights into command registration and cleanup processes.
2026-07-23 10:24:47 +02:00
smueller
629e89f380 Refactor Next.js configuration and remove Sentry integration
- Updated `serverExternalPackages` in the Next.js configuration to exclude `@sentry/node`, improving compatibility with React's RSC/client boundary.
- Removed the `instrumentation.ts` file and refactored Sentry-related code to a no-op, ensuring it does not interfere with the Next.js build process.
- Simplified the dashboard page by integrating `DashboardGuildGrid`, enhancing the user interface and reducing complexity in the component structure.
2026-07-23 10:16:40 +02:00
smueller
c14c2ddaa2 Update Next.js configuration and improve Sentry integration
- Added `serverExternalPackages` to the Next.js configuration to exclude Node-only packages from the webpack bundle, enhancing compatibility with React's RSC/client boundary.
- Refactored Sentry initialization and exception capturing to be asynchronous, ensuring proper handling of the Sentry DSN and preventing potential issues during Next.js builds.
- Updated the `Toaster` component import in the layout to improve UI consistency.
2026-07-23 10:03:00 +02:00
smueller
ffaa1e26fd Enhance bot functionality with new features and improvements
- Updated package dependencies to include `@sentry/node` for error tracking.
- Refactored command routing to incorporate user and guild blacklisting checks, improving command execution safety.
- Enhanced health server metrics to include queue waiting times, providing better insights into system performance.
- Introduced confirmation handling for guild backup commands, streamlining user interactions.
- Improved moderation commands with better error handling and case management, ensuring robust moderation capabilities.
- Added new duration parsing functions to handle timeout durations effectively, enhancing moderation features.
- Updated localization files to reflect new command structures and user guidance improvements.
2026-07-23 09:53:44 +02:00
TheOnlyMace
4e135bcf43 Enhance component message handling and introduce new features
- Added `ComponentMessageBinding` model to manage message components in the database.
- Updated `WelcomeConfig`, `Tag`, and `ScheduledMessage` models to support new component fields.
- Integrated component handling in various modules, including Scheduler and Tags, to improve message customization.
- Enhanced user experience by implementing new commands for creating and managing component messages in the utility module.
- Updated localization files to reflect new component-related features and improve user guidance.
2026-07-22 22:55:37 +02:00
TheOnlyMace
95eed45ec4 Refactor embed handling and enhance user experience across components
- Integrated the new `buildEmbedFromPayload` function in Scheduler, Tags, and Welcome components to streamline embed creation and management.
- Updated validation schemas to require embed content checks, ensuring robust data integrity for embeds.
- Enhanced localization files to include new placeholder descriptions and improve user guidance for embed fields.
- Refactored embed parsing logic to utilize the `WelcomeEmbedSchema`, improving consistency and validation across the application.
2026-07-22 22:30:46 +02:00
TheOnlyMace
e7a3162494 Update placeholders in WelcomeForm and DiscordEmbedBuilder for improved user guidance
- Changed placeholder text in WelcomeForm to use more descriptive keys for leave content.
- Enhanced DiscordEmbedBuilder to accept custom title and description placeholders, improving flexibility for embed creation.
- Updated localization files in English and German to include new placeholder texts, ensuring consistency and clarity for users.
2026-07-22 22:19:16 +02:00
TheOnlyMace
8cfb149a18 Implement PlaceholderHelp component across various forms for enhanced user guidance
- Integrated PlaceholderHelp component into multiple forms including WelcomeForm, TagsManager, and StatsForm to provide contextual hints for available placeholders.
- Updated DiscordEmbedBuilder to support placeholder presets, improving user experience when creating embeds.
- Enhanced localization files to include new placeholder descriptions, ensuring clarity in both English and German.
- Refactored related components to maintain consistency in placeholder handling across the application.
2026-07-22 22:07:08 +02:00
TheOnlyMace
55a4f7bf09 Enhance embed functionality across scheduler and welcome components
- Updated SchedulerManager and WelcomeForm to integrate DiscordEmbedBuilder for managing embeds, replacing JSON text areas with a more user-friendly interface.
- Modified data handling in the scheduler and welcome configurations to support embed structures, ensuring proper parsing and validation.
- Updated localization files to reflect changes in embed handling, improving user guidance and clarity in both English and German.
- Enhanced validation schemas to require embed titles or descriptions where necessary, ensuring robust data integrity.
2026-07-22 21:49:38 +02:00
TheOnlyMace
6a223892dd Refactor queue management to use lazy initialization and improve Redis connection handling
- Updated queue and QueueEvents initialization to be lazy, preventing unnecessary Redis connections during the build process.
- Refactored queue access methods to ensure consistent connection handling across the application.
- Enhanced error handling for Redis connections during production builds to reduce log noise.
2026-07-22 21:38:56 +02:00
TheOnlyMace
b7cf505ca4 Update verification form localization keys for consistency
- Refactored localization keys in the verification form component to use pluralized keys for modes and fail actions, enhancing clarity and consistency across the application.
- Updated corresponding localization files in English and German to reflect the new key structure.
2026-07-22 21:35:37 +02:00
TheOnlyMace
7888ccb7e6 Refactor leveling form to use Discord multiplier editor and enhance localization
- Replaced text area inputs for role and channel multipliers with DiscordMultiplierEditor components for improved user experience.
- Updated state management to handle multiplier entries as objects instead of text, enhancing data integrity.
- Enhanced localization messages for multipliers, including error handling and hints for user guidance.
2026-07-22 21:34:29 +02:00
TheOnlyMace
042f68e777 Refactor Discord channel resource management and update imports
- Consolidated channel type definitions by moving them to discord-channel-types.ts for better organization.
- Removed redundant DASHBOARD_CHANNEL_TYPES and updated exports to streamline channel handling in the application.
- Enhanced type safety by utilizing the ChannelKind type in the discord-channel-select component.
2026-07-22 21:25:58 +02:00
TheOnlyMace
ed2ea23e87 Refactor channel and role selection in various forms to use Discord components
- Replaced standard input fields with DiscordChannelSelect and DiscordRoleSelect components in the BirthdaysForm, FeedsManager, GiveawaysManager, LevelingForm, LoggingForm, SchedulerManager, SelfRolesManager, StatsForm, SuggestionsConfigForm, TagsManager, TempVoiceForm, TicketConfigForm, and StarboardForm for improved user experience and consistency.
- Updated state management to accommodate new component structures, enhancing data handling for channel and role selections across the application.
2026-07-22 21:23:57 +02:00
TheOnlyMace
cf6d071047 Update Prisma scripts in package.json for schema specification
- Modified the prisma:generate script to include a specific schema path for clarity.
- Added a new prisma:migrate script with schema specification to streamline database migrations.
2026-07-22 20:33:08 +02:00
TheOnlyMace
d31660f813 Add global command channel rules and enhance command handling
- Introduced global command channel whitelist and blacklist in the GuildSettings model for improved command access control.
- Implemented command gate logic in the command routing to handle channel and role restrictions, providing user feedback for denied commands.
- Enhanced the CommandsManager component to support global rules and channel/role selection, improving the user interface for managing command permissions.
- Updated localization files to include new keys for global command rules and related messages, ensuring clarity in user interactions.
2026-07-22 20:26:40 +02:00
TheOnlyMace
0fb8195236 Update Nexumi banner assets and adjust SVG positioning for improved visibility
- Replaced the existing PNG banner with an updated version for better quality.
- Adjusted the SVG positioning of the 'N' mark and wordmark to prevent overlap with the Discord avatar, enhancing overall design clarity.
2026-07-22 20:08:43 +02:00
TheOnlyMace
dd93b78c94 Update bot configuration and activity type in schema
- Modified package.json to add a new script for setting the bot banner.
- Changed the default activity type in schema.prisma from 'Playing' to 'Watching' for improved bot presence management.
2026-07-22 20:04:11 +02:00
TheOnlyMace
bb57b842b9 Update bot invite URL permissions across multiple components
- Changed the permissions parameter in the bot invite URL from '8' to '4786708802825463' in env.ts, page.tsx, and lib/env.ts for improved access control.
- Updated the permissions in the landing page bootstrap.php to maintain consistency across the application.
2026-07-22 19:59:23 +02:00
TheOnlyMace
75ac91eecc Add new features and enhancements to the WebUI and dashboard components
- Updated package.json to include new dependencies: `@radix-ui/react-dialog` and `cmdk`.
- Enhanced global styles in globals.css for improved visual depth in dark mode.
- Refactored dashboard page components to improve layout and accessibility, including new icons and responsive design adjustments.
- Implemented suspense handling in commands page for better loading states.
- Improved sidebar navigation with new icons and mobile responsiveness.
- Added FieldAnchor components across various forms for better accessibility and structure.
- Enhanced commands manager with search parameter handling for improved user experience.
- Updated multiple module forms to include FieldAnchor for consistent layout and accessibility.
2026-07-22 19:44:55 +02:00
TheOnlyMace
f1d74f922d Refactor number formatting in landing page for improved localization support
- Updated the number formatting logic in index.php to enhance localization handling for German and English.
- Introduced variables for decimal and thousands separators based on the user's locale, ensuring accurate number representation.
2026-07-22 19:19:43 +02:00
TheOnlyMace
bda5c116fe Update legal documents and localization for compliance and clarity
- Updated .env.example with legal operator details for compliance.
- Refactored Impressum, Privacy, and Terms pages to utilize a new LegalDocument component for improved structure and maintainability.
- Enhanced localization files to include legal content in both German and English, ensuring accurate representation of legal information.
- Improved i18n handling to support locale detection from Accept-Language headers.
2026-07-22 19:18:28 +02:00
TheOnlyMace
8c02b95934 Update README and localization files to remove "self-hosted" references
- Revised the README.md to simplify the description of Nexumi.
- Updated layout.tsx to reflect the change in the bot's description.
- Modified German and English localization files to remove "self-hosted" from the bot's description and terms of service.
2026-07-22 19:10:17 +02:00
TheOnlyMace
496a8239a5 Update environment configuration and enhance Docker setup for production deployment
- Changed NODE_ENV from development to production in .env.example for production readiness.
- Updated WEBUI_URL to point to the new public URL behind Traefik.
- Added internal and external networks in docker-compose.yml for improved service isolation and routing.
- Removed direct port exposure for the WebUI, ensuring it is only accessible through Traefik.
- Refactored landing and login pages in the WebUI to streamline navigation and error handling, redirecting users appropriately based on session status.
2026-07-22 18:53:53 +02:00
197 changed files with 17959 additions and 2360 deletions

View File

@@ -1,9 +1,13 @@
NODE_ENV=development NODE_ENV=production
# Shared by apps/bot (gateway connection) and apps/webui (narrow REST calls + # 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
View File

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

View File

@@ -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

View File

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

View File

@@ -9,13 +9,15 @@
"lint": "eslint src --ext .ts", "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",

View File

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

View File

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

View File

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

View File

@@ -20,6 +20,7 @@ model Guild {
logChannels LogChannel[] 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)

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,11 +4,20 @@ import {
type ChatInputCommandInteraction, type 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;

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import type { Redis } from 'ioredis';
import {
deleteConfirmation,
getConfirmation,
setConfirmation,
takeConfirmation
} from './confirmation-store.js';
function createMemoryRedis(): Redis {
const store = new Map<string, string>();
return {
async set(key: string, value: string) {
store.set(key, value);
return 'OK';
},
async get(key: string) {
return store.get(key) ?? null;
},
async del(key: string) {
store.delete(key);
return 1;
}
} as unknown as Redis;
}
describe('confirmation-store', () => {
it('stores and retrieves pending confirmations', async () => {
const redis = createMemoryRedis();
await setConfirmation(redis, 'moderation', 'tok-1', { action: 'ban', userId: '1' }, 60);
await expect(getConfirmation(redis, 'moderation', 'tok-1')).resolves.toEqual({
action: 'ban',
userId: '1'
});
});
it('takeConfirmation deletes the entry', async () => {
const redis = createMemoryRedis();
await setConfirmation(redis, 'moderation', 'tok-2', { action: 'purge' }, 60);
await expect(takeConfirmation(redis, 'moderation', 'tok-2')).resolves.toEqual({
action: 'purge'
});
await expect(getConfirmation(redis, 'moderation', 'tok-2')).resolves.toBeNull();
});
it('deleteConfirmation removes pending state', async () => {
const redis = createMemoryRedis();
await setConfirmation(redis, 'guildbackup', 'tok-3', { action: 'delete' }, 60);
await deleteConfirmation(redis, 'guildbackup', 'tok-3');
await expect(getConfirmation(redis, 'guildbackup', 'tok-3')).resolves.toBeNull();
});
});

View File

@@ -0,0 +1,57 @@
import type { Redis } from 'ioredis';
function key(namespace: string, token: string): string {
return `confirm:${namespace}:${token}`;
}
export async function setConfirmation<T extends object>(
redis: Redis,
namespace: string,
token: string,
entry: T,
ttlSeconds: number
): Promise<void> {
await redis.set(key(namespace, token), JSON.stringify(entry), 'EX', ttlSeconds);
}
export async function getConfirmation<T>(
redis: Redis,
namespace: string,
token: string
): Promise<T | null> {
const raw = await redis.get(key(namespace, token));
if (!raw) {
return null;
}
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
export async function deleteConfirmation(
redis: Redis,
namespace: string,
token: string
): Promise<void> {
await redis.del(key(namespace, token));
}
export async function takeConfirmation<T>(
redis: Redis,
namespace: string,
token: string
): Promise<T | null> {
const k = key(namespace, token);
const raw = await redis.get(k);
if (!raw) {
return null;
}
await redis.del(k);
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}

View File

@@ -19,6 +19,7 @@ const EnvSchema = z.object({
BACKUP_CRON: z.string().default('0 3 * * *'), BACKUP_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();
} }

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { formatPrometheus, type MetricsSnapshot } from './metrics.js';
describe('confirmation custom_id matchers', () => {
it('detects moderation confirm/cancel prefixes', () => {
const isMod = (id: string) => id.startsWith('mod:confirm:') || id.startsWith('mod:cancel:');
expect(isMod('mod:confirm:abc')).toBe(true);
expect(isMod('mod:cancel:abc')).toBe(true);
expect(isMod('other')).toBe(false);
});
it('detects guildbackup confirm/cancel prefixes', () => {
const isBackup = (id: string) =>
id.startsWith('gbackup:confirm:delete:') ||
id.startsWith('gbackup:confirm:restore:') ||
id.startsWith('gbackup:cancel:');
expect(isBackup('gbackup:confirm:delete:abc')).toBe(true);
expect(isBackup('gbackup:confirm:restore:abc')).toBe(true);
expect(isBackup('gbackup:cancel:abc')).toBe(true);
expect(isBackup('mod:confirm:abc')).toBe(false);
});
});
describe('formatPrometheus', () => {
it('emits core counters and shard gauges', () => {
const snapshot: MetricsSnapshot = {
up: 1,
commandsTotal: 10,
commandsErrors: 2,
commandDurationSumMs: 500,
commandDurationCount: 10,
eventsTotal: 100,
shards: [{ id: 0, ping: 42, guilds: 5, updatedAt: Date.now() }],
queueWaiting: 3
};
const body = formatPrometheus(snapshot);
expect(body).toContain('nexumi_up 1');
expect(body).toContain('nexumi_commands_total 10');
expect(body).toContain('nexumi_commands_errors_total 2');
expect(body).toContain('nexumi_guilds{shard="0"} 5');
expect(body).toContain('nexumi_shard_ping_ms{shard="0"} 42');
expect(body).toContain('nexumi_queue_waiting 3');
});
});

View File

@@ -6,7 +6,17 @@ import {
getCaptchaChallenge, 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
View File

@@ -0,0 +1,141 @@
import {
type ChatInputCommandInteraction,
type GuildMember,
type Interaction
} from 'discord.js';
import type { Locale } from '@nexumi/shared';
import { t } from '@nexumi/shared';
export type HierarchyAction = 'ban' | 'kick' | 'timeout' | 'nick';
export type HierarchyOk = { ok: true; member: GuildMember | null };
export type HierarchyDenied = { ok: false };
/**
* Validates that the moderator and bot can act on the target member.
* Replies ephemerally on denial. For bans, `member` may be null if the user
* already left the guild (ban-by-id is still allowed).
*/
export async function assertModerableMember(
interaction: ChatInputCommandInteraction,
targetUserId: string,
locale: Locale,
action: HierarchyAction
): Promise<HierarchyOk | HierarchyDenied> {
const guild = interaction.guild;
if (!guild) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return { ok: false };
}
if (targetUserId === interaction.user.id) {
await interaction.reply({ content: t(locale, 'moderation.hierarchy.self'), ephemeral: true });
return { ok: false };
}
if (targetUserId === guild.ownerId) {
await interaction.reply({ content: t(locale, 'moderation.hierarchy.owner'), ephemeral: true });
return { ok: false };
}
let member: GuildMember | null = null;
try {
member = await guild.members.fetch(targetUserId);
} catch {
if (action === 'ban') {
return { ok: true, member: null };
}
await interaction.reply({ content: t(locale, 'moderation.hierarchy.notMember'), ephemeral: true });
return { ok: false };
}
const moderator =
interaction.member && 'roles' in interaction.member
? (interaction.member as GuildMember)
: await guild.members.fetch(interaction.user.id);
if (guild.ownerId !== moderator.id) {
if (member.roles.highest.position >= moderator.roles.highest.position) {
await interaction.reply({
content: t(locale, 'moderation.hierarchy.moderator'),
ephemeral: true
});
return { ok: false };
}
}
const botOk =
action === 'ban'
? member.bannable
: action === 'kick'
? member.kickable
: action === 'timeout'
? member.moderatable
: member.manageable;
if (!botOk) {
await interaction.reply({
content: t(locale, 'moderation.hierarchy.bot'),
ephemeral: true
});
return { ok: false };
}
return { ok: true, member };
}
async function replyHierarchyForButton(interaction: Interaction, locale: Locale, key: string): Promise<void> {
if (!interaction.isRepliable()) {
return;
}
const content = t(locale, key);
if (interaction.isButton()) {
await interaction.update({ content, components: [] });
return;
}
if (interaction.replied || interaction.deferred) {
await interaction.followUp({ content, ephemeral: true });
} else {
await interaction.reply({ content, ephemeral: true });
}
}
export async function assertBannableForConfirm(
interaction: Interaction,
locale: Locale,
targetUserId: string
): Promise<boolean> {
const guild = interaction.guild;
if (!guild) {
await replyHierarchyForButton(interaction, locale, 'generic.guildOnly');
return false;
}
if (targetUserId === interaction.user.id) {
await replyHierarchyForButton(interaction, locale, 'moderation.hierarchy.self');
return false;
}
if (targetUserId === guild.ownerId) {
await replyHierarchyForButton(interaction, locale, 'moderation.hierarchy.owner');
return false;
}
try {
const member = await guild.members.fetch(targetUserId);
if (!member.bannable) {
await replyHierarchyForButton(interaction, locale, 'moderation.hierarchy.bot');
return false;
}
const moderator = await guild.members.fetch(interaction.user.id);
if (
guild.ownerId !== moderator.id &&
member.roles.highest.position >= moderator.roles.highest.position
) {
await replyHierarchyForButton(interaction, locale, 'moderation.hierarchy.moderator');
return false;
}
} catch {
// Target left the guild — ban by id is still fine.
}
return true;
}

View File

@@ -14,60 +14,30 @@ import { registerCommands, routeCommand, routeContextMenu } from './commands.js'
import { ensureRecurringJobs, startWorkers } from './jobs.js'; import { 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()) {

View File

@@ -0,0 +1,154 @@
import type {
ButtonInteraction,
ChannelSelectMenuInteraction,
MentionableSelectMenuInteraction,
ModalSubmitInteraction,
RoleSelectMenuInteraction,
StringSelectMenuInteraction,
UserSelectMenuInteraction
} from 'discord.js';
import type { BotContext } from './types.js';
import {
handleModerationConfirmation,
isModerationConfirmation
} from './modules/moderation/confirmations.js';
import { handleVerificationButton } from './modules/verification/handlers.js';
import { isVerificationButton } from './modules/verification/service.js';
import {
handleBlackjackButton,
isBlackjackButton
} from './modules/economy/blackjack-buttons.js';
import {
handleEmbedModal,
handlePollButton,
isEmbedModal,
isPollButton
} from './modules/utility/index.js';
import { handleFunButton, isFunButton } from './modules/fun/index.js';
import { handleGiveawayButton, isGiveawayButton } from './modules/giveaways/index.js';
import { handleTicketInteraction, isTicketInteraction } from './modules/tickets/index.js';
import {
handleSelfRoleInteraction,
isSelfRoleInteraction
} from './modules/selfroles/index.js';
import {
handleSuggestionButton,
isSuggestionButton
} from './modules/suggestions/index.js';
import {
handleTempVoiceInteraction,
isTempVoiceInteraction,
isTempVoiceModal
} from './modules/tempvoice/index.js';
import { handleGuildBackupButton, isGuildBackupButton } from './modules/guildbackup/index.js';
import {
handleComponentsV2Interaction,
isComponentsV2Interaction
} from './modules/components-v2/index.js';
type AnyComponentInteraction =
| ButtonInteraction
| StringSelectMenuInteraction
| UserSelectMenuInteraction
| RoleSelectMenuInteraction
| ChannelSelectMenuInteraction
| MentionableSelectMenuInteraction
| ModalSubmitInteraction;
type InteractionHandler = {
match: (customId: string) => boolean;
handle: (interaction: AnyComponentInteraction, context: BotContext) => Promise<void>;
};
function asButton(interaction: AnyComponentInteraction): ButtonInteraction {
return interaction as ButtonInteraction;
}
function asModal(interaction: AnyComponentInteraction): ModalSubmitInteraction {
return interaction as ModalSubmitInteraction;
}
const handlers: InteractionHandler[] = [
{
match: isModerationConfirmation,
handle: (interaction, context) => handleModerationConfirmation(asButton(interaction), context)
},
{
match: isVerificationButton,
handle: (interaction, context) => handleVerificationButton(asButton(interaction), context)
},
{
match: isBlackjackButton,
handle: (interaction, context) => handleBlackjackButton(asButton(interaction), context)
},
{
match: isPollButton,
handle: (interaction, context) => handlePollButton(asButton(interaction), context)
},
{
match: isFunButton,
handle: (interaction, context) => handleFunButton(asButton(interaction), context)
},
{
match: isGiveawayButton,
handle: (interaction, context) => handleGiveawayButton(asButton(interaction), context)
},
{
match: isTicketInteraction,
handle: (interaction, context) =>
handleTicketInteraction(
interaction as Parameters<typeof handleTicketInteraction>[0],
context
)
},
{
match: isSelfRoleInteraction,
handle: (interaction, context) =>
handleSelfRoleInteraction(
interaction as Parameters<typeof handleSelfRoleInteraction>[0],
context
)
},
{
match: isSuggestionButton,
handle: (interaction, context) => handleSuggestionButton(asButton(interaction), context)
},
{
match: (customId) => isTempVoiceInteraction(customId) || isTempVoiceModal(customId),
handle: (interaction, context) =>
handleTempVoiceInteraction(
interaction as Parameters<typeof handleTempVoiceInteraction>[0],
context
)
},
{
match: isGuildBackupButton,
handle: (interaction, context) => handleGuildBackupButton(asButton(interaction), context)
},
{
match: isEmbedModal,
handle: (interaction, context) => handleEmbedModal(asModal(interaction), context)
},
{
match: isComponentsV2Interaction,
handle: (interaction, context) =>
handleComponentsV2Interaction(
interaction as Parameters<typeof handleComponentsV2Interaction>[0],
context
)
}
];
export async function routeComponentInteraction(
interaction: AnyComponentInteraction,
context: BotContext
): Promise<boolean> {
const customId = interaction.customId;
for (const handler of handlers) {
if (handler.match(customId)) {
await handler.handle(interaction, context);
return true;
}
}
return false;
}

View File

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

View File

@@ -8,16 +8,18 @@ import { logger } from './logger.js';
import type { BotContext } from './types.js'; import 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,

View File

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

View File

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

129
apps/bot/src/metrics.ts Normal file
View File

@@ -0,0 +1,129 @@
import type { Redis } from 'ioredis';
import type { Client } from 'discord.js';
const KEY_COMMANDS_TOTAL = 'nexumi:metrics:commands_total';
const KEY_COMMANDS_ERRORS = 'nexumi:metrics:commands_errors';
const KEY_COMMAND_DURATION_SUM = 'nexumi:metrics:command_duration_ms_sum';
const KEY_COMMAND_DURATION_COUNT = 'nexumi:metrics:command_duration_ms_count';
const KEY_EVENTS_TOTAL = 'nexumi:metrics:events_total';
const shardKey = (id: number) => `nexumi:metrics:shard:${id}`;
export async function recordCommandMetric(
redis: Redis,
opts: { ok: boolean; durationMs: number }
): Promise<void> {
const multi = redis.multi();
multi.incr(KEY_COMMANDS_TOTAL);
if (!opts.ok) {
multi.incr(KEY_COMMANDS_ERRORS);
}
multi.incrbyfloat(KEY_COMMAND_DURATION_SUM, opts.durationMs);
multi.incr(KEY_COMMAND_DURATION_COUNT);
await multi.exec();
}
export async function recordEventMetric(redis: Redis): Promise<void> {
await redis.incr(KEY_EVENTS_TOTAL);
}
export async function publishShardMetrics(redis: Redis, client: Client): Promise<void> {
const ids = client.shard?.ids ?? [0];
const ping = client.ws.ping;
const guilds = client.guilds.cache.size;
const payload = JSON.stringify({
ping,
guilds,
updatedAt: Date.now()
});
await Promise.all(ids.map((id) => redis.set(shardKey(id), payload, 'EX', 120)));
}
export type MetricsSnapshot = {
up: 1;
commandsTotal: number;
commandsErrors: number;
commandDurationSumMs: number;
commandDurationCount: number;
eventsTotal: number;
shards: Array<{ id: number; ping: number; guilds: number; updatedAt: number }>;
queueWaiting?: number;
};
export async function collectMetricsSnapshot(redis: Redis): Promise<MetricsSnapshot> {
const [commandsTotal, commandsErrors, durationSum, durationCount, eventsTotal, shardKeys] =
await Promise.all([
redis.get(KEY_COMMANDS_TOTAL),
redis.get(KEY_COMMANDS_ERRORS),
redis.get(KEY_COMMAND_DURATION_SUM),
redis.get(KEY_COMMAND_DURATION_COUNT),
redis.get(KEY_EVENTS_TOTAL),
redis.keys('nexumi:metrics:shard:*')
]);
const shards: MetricsSnapshot['shards'] = [];
if (shardKeys.length > 0) {
const values = await redis.mget(...shardKeys);
for (let i = 0; i < shardKeys.length; i++) {
const id = Number(shardKeys[i]!.replace('nexumi:metrics:shard:', ''));
const raw = values[i];
if (!raw || Number.isNaN(id)) continue;
try {
const parsed = JSON.parse(raw) as { ping: number; guilds: number; updatedAt: number };
shards.push({ id, ping: parsed.ping, guilds: parsed.guilds, updatedAt: parsed.updatedAt });
} catch {
// ignore malformed
}
}
shards.sort((a, b) => a.id - b.id);
}
return {
up: 1,
commandsTotal: Number(commandsTotal ?? 0),
commandsErrors: Number(commandsErrors ?? 0),
commandDurationSumMs: Number(durationSum ?? 0),
commandDurationCount: Number(durationCount ?? 0),
eventsTotal: Number(eventsTotal ?? 0),
shards
};
}
export function formatPrometheus(snapshot: MetricsSnapshot): string {
const lines: string[] = [
'# HELP nexumi_up 1 if the metrics endpoint is healthy',
'# TYPE nexumi_up gauge',
`nexumi_up ${snapshot.up}`,
'# HELP nexumi_commands_total Total slash commands handled',
'# TYPE nexumi_commands_total counter',
`nexumi_commands_total ${snapshot.commandsTotal}`,
'# HELP nexumi_commands_errors_total Total slash command failures',
'# TYPE nexumi_commands_errors_total counter',
`nexumi_commands_errors_total ${snapshot.commandsErrors}`,
'# HELP nexumi_command_duration_ms_sum Sum of command durations in milliseconds',
'# TYPE nexumi_command_duration_ms_sum counter',
`nexumi_command_duration_ms_sum ${snapshot.commandDurationSumMs}`,
'# HELP nexumi_command_duration_ms_count Count of timed commands',
'# TYPE nexumi_command_duration_ms_count counter',
`nexumi_command_duration_ms_count ${snapshot.commandDurationCount}`,
'# HELP nexumi_events_total Approximate event throughput counter',
'# TYPE nexumi_events_total counter',
`nexumi_events_total ${snapshot.eventsTotal}`,
'# HELP nexumi_guilds Guild count per shard',
'# TYPE nexumi_guilds gauge',
'# HELP nexumi_shard_ping_ms Websocket heartbeat latency per shard',
'# TYPE nexumi_shard_ping_ms gauge'
];
for (const shard of snapshot.shards) {
lines.push(`nexumi_guilds{shard="${shard.id}"} ${shard.guilds}`);
lines.push(`nexumi_shard_ping_ms{shard="${shard.id}"} ${shard.ping}`);
}
if (snapshot.queueWaiting !== undefined) {
lines.push('# HELP nexumi_queue_waiting Approximate waiting jobs across known queues');
lines.push('# TYPE nexumi_queue_waiting gauge');
lines.push(`nexumi_queue_waiting ${snapshot.queueWaiting}`);
}
return `${lines.join('\n')}\n`;
}

View File

@@ -0,0 +1,33 @@
import { createHash } from 'node:crypto';
import { describe, expect, it } from 'vitest';
import { moduleForCommand } from './module-gates.js';
describe('moduleForCommand', () => {
it('maps moderation commands', () => {
expect(moduleForCommand('ban')).toBe('moderation');
expect(moduleForCommand('warn')).toBe('moderation');
expect(moduleForCommand('case')).toBe('moderation');
});
it('maps redis-only and prisma modules', () => {
expect(moduleForCommand('giveaway')).toBe('giveaways');
expect(moduleForCommand('tag')).toBe('tags');
expect(moduleForCommand('backup')).toBe('guildbackup');
expect(moduleForCommand('economy' as string)).toBeNull();
expect(moduleForCommand('balance')).toBe('economy');
});
it('leaves core commands unmapped', () => {
expect(moduleForCommand('help')).toBeNull();
expect(moduleForCommand('info')).toBeNull();
expect(moduleForCommand('gdpr')).toBeNull();
});
});
describe('feature flag rollout hashing', () => {
it('is stable for the same guild/key pair', () => {
const a = createHash('sha256').update('moderation:guild-1').digest().readUInt32BE(0) % 100;
const b = createHash('sha256').update('moderation:guild-1').digest().readUInt32BE(0) % 100;
expect(a).toBe(b);
});
});

View File

@@ -0,0 +1,275 @@
import type { DashboardModuleId } from '@nexumi/shared';
import type { PrismaClient } from '@prisma/client';
import type { Redis } from 'ioredis';
import { createHash } from 'node:crypto';
const REDIS_ONLY_MODULES = new Set<DashboardModuleId>([
'giveaways',
'tags',
'selfroles',
'feeds',
'scheduler',
'guildbackup',
'messages'
]);
/** Slash command name → dashboard module. Core/GDPR/utility (except gated) omit. */
const COMMAND_MODULE_MAP: Record<string, DashboardModuleId> = {
ban: 'moderation',
unban: 'moderation',
kick: 'moderation',
timeout: 'moderation',
untimeout: 'moderation',
warn: 'moderation',
purge: 'moderation',
slowmode: 'moderation',
lock: 'moderation',
unlock: 'moderation',
nick: 'moderation',
case: 'moderation',
modnote: 'moderation',
automod: 'automod',
welcome: 'welcome',
verify: 'verification',
rank: 'leveling',
leaderboard: 'leveling',
xp: 'leveling',
balance: 'economy',
daily: 'economy',
weekly: 'economy',
work: 'economy',
pay: 'economy',
gamble: 'economy',
slots: 'economy',
blackjack: 'economy',
coinflip: 'economy',
shop: 'economy',
inventory: 'economy',
eco: 'economy',
'8ball': 'fun',
dice: 'fun',
rps: 'fun',
choose: 'fun',
trivia: 'fun',
tictactoe: 'fun',
connect4: 'fun',
hangman: 'fun',
meme: 'fun',
cat: 'fun',
dog: 'fun',
giveaway: 'giveaways',
ticket: 'tickets',
selfroles: 'selfroles',
tag: 'tags',
starboard: 'starboard',
suggest: 'suggestions',
suggestion: 'suggestions',
birthday: 'birthdays',
voice: 'tempvoice',
invites: 'stats',
stats: 'stats',
feeds: 'feeds',
schedule: 'scheduler',
announce: 'scheduler',
backup: 'guildbackup',
embed: 'messages'
};
function redisModulesKey(guildId: string): string {
return `dashboard:modules:${guildId}`;
}
function funConfigKey(guildId: string): string {
return `fun:config:${guildId}`;
}
function hashPercent(input: string): number {
const digest = createHash('sha256').update(input).digest();
return digest.readUInt32BE(0) % 100;
}
export function moduleForCommand(commandName: string): DashboardModuleId | null {
return COMMAND_MODULE_MAP[commandName] ?? null;
}
export async function isFeatureFlagEnabled(
prisma: PrismaClient,
key: string,
guildId: string | null
): Promise<boolean> {
const flag = await prisma.featureFlag.findUnique({ where: { key } });
if (!flag) {
return true;
}
if (guildId && flag.whitelistGuildIds.includes(guildId)) {
return true;
}
if (!flag.enabled) {
return false;
}
if (flag.rolloutPercent >= 100) {
return true;
}
if (flag.rolloutPercent <= 0 || !guildId) {
return false;
}
return hashPercent(`${flag.key}:${guildId}`) < flag.rolloutPercent;
}
async function isRedisModuleEnabled(
redis: Redis,
guildId: string,
moduleId: DashboardModuleId
): Promise<boolean> {
const value = await redis.hget(redisModulesKey(guildId), moduleId);
if (value === null) {
return true;
}
return value === 'true';
}
async function isFunEnabled(redis: Redis, guildId: string): Promise<boolean> {
const raw = await redis.get(funConfigKey(guildId));
if (!raw) {
return true;
}
try {
const parsed = JSON.parse(raw) as { enabled?: boolean };
return parsed.enabled ?? true;
} catch {
return true;
}
}
export async function isModuleEnabled(
prisma: PrismaClient,
redis: Redis,
guildId: string,
moduleId: DashboardModuleId
): Promise<boolean> {
const flagOk = await isFeatureFlagEnabled(prisma, moduleId, guildId);
if (!flagOk) {
return false;
}
if (REDIS_ONLY_MODULES.has(moduleId)) {
return isRedisModuleEnabled(redis, guildId, moduleId);
}
switch (moduleId) {
case 'moderation': {
const settings = await prisma.guildSettings.findUnique({
where: { guildId },
select: { moderationEnabled: true }
});
return settings?.moderationEnabled ?? true;
}
case 'automod': {
const config = await prisma.autoModConfig.findUnique({
where: { guildId },
select: { enabled: true }
});
return config?.enabled ?? true;
}
case 'logging': {
const config = await prisma.loggingConfig.findUnique({
where: { guildId },
select: { enabled: true }
});
return config?.enabled ?? true;
}
case 'welcome': {
const config = await prisma.welcomeConfig.findUnique({
where: { guildId },
select: { welcomeEnabled: true, leaveEnabled: true }
});
if (!config) {
return true;
}
return Boolean(config.welcomeEnabled || config.leaveEnabled);
}
case 'verification': {
const config = await prisma.verificationConfig.findUnique({
where: { guildId },
select: { enabled: true }
});
return config?.enabled ?? false;
}
case 'leveling': {
const config = await prisma.levelingConfig.findUnique({
where: { guildId },
select: { enabled: true }
});
return config?.enabled ?? true;
}
case 'economy': {
const config = await prisma.economyConfig.findUnique({
where: { guildId },
select: { enabled: true }
});
return config?.enabled ?? true;
}
case 'fun':
return isFunEnabled(redis, guildId);
case 'tickets': {
const config = await prisma.ticketConfig.findUnique({
where: { guildId },
select: { enabled: true }
});
return config?.enabled ?? true;
}
case 'starboard': {
const config = await prisma.starboardConfig.findUnique({
where: { guildId },
select: { enabled: true }
});
return config?.enabled ?? false;
}
case 'suggestions': {
const config = await prisma.suggestionConfig.findUnique({
where: { guildId },
select: { enabled: true }
});
return config?.enabled ?? true;
}
case 'birthdays': {
const config = await prisma.birthdayConfig.findUnique({
where: { guildId },
select: { enabled: true }
});
return config?.enabled ?? true;
}
case 'tempvoice': {
const config = await prisma.tempVoiceConfig.findUnique({
where: { guildId },
select: { enabled: true }
});
return config?.enabled ?? false;
}
case 'stats': {
const config = await prisma.statsConfig.findUnique({
where: { guildId },
select: { enabled: true }
});
return config?.enabled ?? false;
}
default:
return true;
}
}
export async function isUserBlacklisted(prisma: PrismaClient, userId: string): Promise<boolean> {
const row = await prisma.globalUserBlacklist.findUnique({
where: { userId },
select: { id: true }
});
return Boolean(row);
}
export async function isGuildBlacklisted(prisma: PrismaClient, guildId: string): Promise<boolean> {
const row = await prisma.guildBlacklist.findUnique({
where: { guildId },
select: { id: true }
});
return Boolean(row);
}

View File

@@ -204,6 +204,16 @@ export async function handleAntiRaidJoin(
return; 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,

View File

@@ -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,

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,3 @@
export { giveawayCommands } from './commands.js'; export { 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';

View File

@@ -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(

View File

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

View File

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

View File

@@ -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));

View File

@@ -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: []
}); });
} }

View File

@@ -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) =>

View File

@@ -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({

View File

@@ -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;

View File

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

View File

@@ -1,10 +1,39 @@
export const MAX_TIMEOUT_MS = 28 * 24 * 60 * 60 * 1000;
export class DurationParseError extends Error {
constructor(message: string) {
super(message);
this.name = 'DurationParseError';
}
}
export function parseDuration(input: string): number { 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;
}
}

View File

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

View File

@@ -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);

View File

@@ -1,4 +1,4 @@
import { PermissionFlagsBits } from 'discord.js'; import { PermissionFlagsBits, type InteractionReplyOptions } from 'discord.js';
import { TagResponseTypeSchema, t, tf } from '@nexumi/shared'; import { 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;
} }

View File

@@ -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,

View File

@@ -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'
)
)
); );

View File

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

View File

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

View File

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

View File

@@ -26,6 +26,18 @@ export const verifyCommandData = applyCommandDescription(
{ name: 'Captcha', value: 'CAPTCHA' } { 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')

View File

@@ -1,21 +1,41 @@
import { PermissionFlagsBits } from 'discord.js'; import {
PermissionFlagsBits,
type GuildBasedChannel,
type GuildTextBasedChannel
} from 'discord.js';
import { t } from '@nexumi/shared'; import { 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() });
} }
}; };

View File

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

View File

@@ -1,11 +1,27 @@
import { randomBytes, randomInt, createHash } from 'node:crypto'; import { createHash, randomBytes, randomInt } from 'node:crypto';
import type { CaptchaProvider, VerificationFailAction, VerificationMode } from '@nexumi/shared';
import type { PrismaClient } from '@prisma/client'; import type { 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');
}

View File

@@ -1,4 +1,4 @@
import { PermissionFlagsBits } from 'discord.js'; import { MessageFlags, PermissionFlagsBits, type InteractionReplyOptions } from 'discord.js';
import { t } from '@nexumi/shared'; import { 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 });
} }
}; };

View File

@@ -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 {

View File

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

View File

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

View File

@@ -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
View File

@@ -0,0 +1,36 @@
import * as Sentry from '@sentry/node';
import { env } from './env.js';
import { logger } from './logger.js';
let initialized = false;
export function initSentry(service: 'bot-manager' | 'bot-shard' | 'webui'): void {
if (initialized || !env.SENTRY_DSN) {
return;
}
Sentry.init({
dsn: env.SENTRY_DSN,
environment: env.NODE_ENV,
release: process.env.npm_package_version
? `nexumi@${process.env.npm_package_version}`
: 'nexumi@0.1.0',
tracesSampleRate: env.NODE_ENV === 'production' ? 0.1 : 1.0,
initialScope: {
tags: { service }
}
});
initialized = true;
logger.info({ service }, 'Sentry initialized');
}
export function captureException(error: unknown, context?: Record<string, unknown>): void {
if (!env.SENTRY_DSN) {
return;
}
Sentry.withScope((scope) => {
if (context) {
scope.setExtras(context);
}
Sentry.captureException(error);
});
}

View File

@@ -0,0 +1,37 @@
import type { Client } from 'discord.js';
import { redis } from './redis.js';
import { logger } from './logger.js';
/**
* Returns true when this process owns shard 0 (or has no shard manager = single process).
*/
export function isPrimaryShard(client: Client): boolean {
const ids = client.shard?.ids;
if (!ids || ids.length === 0) {
return true;
}
return ids.includes(0);
}
/**
* Acquires a short-lived Redis lock. Returns true if this caller owns the lock.
*/
export async function acquireOnceLock(lockKey: string, ttlSeconds: number): Promise<boolean> {
const result = await redis.set(lockKey, String(Date.now()), 'EX', ttlSeconds, 'NX');
return result === 'OK';
}
export async function runOncePerCluster(
lockKey: string,
ttlSeconds: number,
fn: () => Promise<void>,
label: string
): Promise<boolean> {
const acquired = await acquireOnceLock(lockKey, ttlSeconds);
if (!acquired) {
logger.info({ lockKey }, `${label} skipped (another process holds the lock)`);
return false;
}
await fn();
return true;
}

View File

@@ -4,7 +4,8 @@ import type { NextConfig } from 'next';
const nextConfig: NextConfig = { 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;

View File

@@ -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",

View File

@@ -1,7 +1,9 @@
import { CaseListQuerySchema } from '@nexumi/shared'; import { CaseListQuerySchema, CaseUpdateDashboardSchema } from '@nexumi/shared';
import { type NextRequest, NextResponse } from 'next/server'; import { 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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,8 +13,8 @@ export async function GET(_request: NextRequest, { params }: RouteParams) {
const { guildId } = await params; 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);

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,11 @@
import { CaseListQuerySchema } from '@nexumi/shared'; import { CaseListQuerySchema, WarningListQuerySchema } from '@nexumi/shared';
import { CasesTable } from '@/components/modules/cases-table'; import { 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>
); );

View File

@@ -1,7 +1,9 @@
import { DASHBOARD_MODULES } from '@nexumi/shared';
import { Activity, MessageSquare, Mic, Users } from 'lucide-react';
import Link from 'next/link'; import 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)}

View File

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

View File

@@ -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>
); );

View File

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

View File

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

View File

@@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,16 @@
'use client';
import type { DashboardGuild, SessionUser } from '@nexumi/shared'; import type { 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>

View File

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

View File

@@ -1,7 +1,36 @@
'use client'; '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>
);
}

View File

@@ -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>

View File

@@ -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>
)} )}

View File

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

View File

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

View File

@@ -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