Compare commits

...

56 Commits

Author SHA1 Message Date
0ef76f5beb Merge pull request 'deploy' (#2) from deploy into main
Reviewed-on: #2
2026-07-29 07:44:02 +00:00
TheOnlyMace
e2bab9cceb Implement leave announcement for banned members and enhance moderation notifications
- Added a `leaveAnnounceBan` field to the `WelcomeConfig` model, allowing for optional announcements in the leave channel when a member is banned.
- Updated the leave handling logic to check for bans and send a notice in the leave channel if the `leaveAnnounceBan` toggle is enabled.
- Enhanced moderation actions (kick and ban) to notify the affected user via DM with the reason for the action.
- Introduced a new `notifyModerationTargetDm` function to streamline DM notifications for moderation actions.
- Updated the WebUI to include a toggle for the `leaveAnnounceBan` feature, improving user configurability.
- Improved localization for new messages related to moderation notifications and leave announcements in both English and German.
- Documented changes in phase tracking to reflect the new leave announcement feature and moderation notification enhancements.
2026-07-25 17:50:18 +02:00
TheOnlyMace
03bb406d82 Enhance AutoMod functionality with default word list and improved filtering
- Introduced a default blocked words list for the WORD_FILTER, including common profanity in both English and German.
- Updated the word matching logic to utilize Unicode word boundaries, reducing false positives.
- Implemented a button in the WebUI to load the default word list, enhancing user experience.
- Added functionality to seed default word filters only when necessary, preventing overwriting user-defined lists.
- Improved localization for new features related to the word list in both English and German.
- Documented changes in phase tracking to reflect the new AutoMod enhancements.
2026-07-25 17:42:06 +02:00
TheOnlyMace
2058713e03 Implement warning system enhancements with unique identifiers
- Added a `warningNumber` field to the `Warning` model for sequential identification of warnings.
- Updated the `createWarning` function to handle the new `warningNumber` logic and notify users via DM upon receiving a warning.
- Refactored moderation commands to utilize the new warning system, including changes to the warning creation and removal processes.
- Enhanced the WebUI to display warning numbers in the warnings table and updated localization for related messages.
- Documented changes in phase tracking to reflect the new warning system features.
2026-07-25 17:31:28 +02:00
TheOnlyMace
061e287390 Enhance job removal logic and scheduler handling
- Updated the `safeRemoveBullJob` function to handle jobs belonging to a Job Scheduler, ensuring they are removed via `removeJobScheduler` instead of direct removal.
- Introduced `removeScheduleJobSchedulers` to streamline the removal of job schedulers associated with scheduled jobs.
- Improved error handling and logging for job removal operations, particularly for legacy and repeatable jobs.
- Refactored related functions in both the bot and WebUI to ensure consistent handling of job scheduling and removal across the application.
2026-07-25 17:22:16 +02:00
TheOnlyMace
f67b9c61d7 Enhance scheduler functionality with timezone support and error handling
- Updated scheduling logic to utilize the guild's timezone for cron expressions and one-time scheduling.
- Introduced a new `getGuildTimezone` function to retrieve the timezone from guild settings.
- Enhanced error handling for scheduling inputs, providing localized error messages for invalid cron expressions and missing schedule times.
- Improved the WebUI to support a new timing mode selection (once vs. cron) and updated input fields accordingly.
- Added localization entries for new scheduling-related messages in both English and German.
- Refactored job scheduling and removal logic to ensure proper handling of scheduled jobs in the queue.
2026-07-25 17:04:38 +02:00
TheOnlyMace
bc4fae5415 Enhance tag management with cooldown feature and related updates
- Added a `cooldownSeconds` field to the `Tag` model, allowing for customizable cooldown periods between tag responses.
- Updated tag command handling to incorporate cooldown logic, preventing spam and improving user experience.
- Enhanced the WebUI to support cooldown configuration for tags, including input validation and localization for cooldown messages.
- Refactored tag-related service functions to manage cooldowns effectively, ensuring accurate tracking and enforcement.
- Improved localization for new cooldown-related messages in both English and German.
2026-07-25 16:50:46 +02:00
TheOnlyMace
5f932f5d63 Refactor ephemeral message handling in ticket interactions
- Updated ticket command and interaction responses to utilize the new `ephemeral()` function for consistent ephemeral message handling.
- Enhanced error handling for member fetching in ticket interactions, ensuring proper responses when members are not found.
- Improved localization for new error messages related to member presence in ticket commands.
- Documented changes in phase tracking to reflect updates in ticket interaction responses.
2026-07-25 16:32:30 +02:00
TheOnlyMace
b5bf214d7f Fix ticket category saving and synchronization error handling
- Updated the ticket category saving logic to handle empty description and emoji fields, ensuring they are set to null if trimmed.
- Enhanced error handling in the `maybeSyncTicketPanel` function to prevent UI errors when synchronization fails, allowing category CRUD operations to succeed.
- Updated the response handling to include support role IDs in the UI after saving, improving data consistency.
- Added validation for support role IDs in the dashboard schemas to ensure correct data types are used.
2026-07-25 16:24:18 +02:00
TheOnlyMace
5c11b68fc0 Enhance ticket management with dashboard actions and priority settings
- Introduced a new `runTicketDashboardActionJob` to handle ticket actions (close, claim, set priority) with Discord side effects.
- Added a priority dropdown in the ticket control panel, allowing users to set ticket urgency levels.
- Updated WebUI to display open tickets with options to claim, close, or adjust priority, enhancing user interaction.
- Improved localization for new ticket management features in both English and German.
- Documented changes in phase tracking to reflect the new ticket dashboard functionalities.
2026-07-25 16:21:51 +02:00
TheOnlyMace
ac5ecc9a1c Enhance ticket management with control panel and support role integration
- Introduced a control panel for ticket interactions, allowing users to claim, close, and manage ticket participants via buttons and user select menus.
- Implemented support role member caching to efficiently add staff to private ticket threads, adhering to Discord's rate limits.
- Updated ticket opening messages to mention support roles, ensuring staff are notified when a ticket is created.
- Enhanced localization for new ticket control features in both English and German.
- Documented changes in phase tracking to reflect the new ticket support functionalities.
2026-07-25 16:16:53 +02:00
TheOnlyMace
dbee58cd81 Implement logging for verification failures and enhance moderation responses
- Introduced a new logging event type `VERIFICATION_FAIL` to capture details of failed verification attempts, including reasons and actions taken.
- Updated moderation commands to include reasons in success messages for actions like ban, kick, and timeout.
- Enhanced the logging of ban and kick actions to include moderator information and reasons fetched from the audit log.
- Improved localization for new logging messages in both English and German.
- Documented changes in the phase tracking documentation to reflect the new logging capabilities and verification failure handling.
2026-07-25 16:14:41 +02:00
TheOnlyMace
1eec10ba80 Implement ticket panel synchronization and configuration updates
- Added `panelChannelId` and `panelMessageId` fields to the `TicketConfig` model for managing ticket panel settings.
- Introduced `syncTicketPanel` function to handle posting and updating the ticket panel in Discord channels via BullMQ.
- Enhanced error handling for ticket panel synchronization, including specific error messages for missing categories and posting failures.
- Updated WebUI components to support panel channel selection and improved error messaging for synchronization issues.
- Added localization entries for new error messages related to ticket panel synchronization in both English and German.
- Refactored ticket category management to trigger panel synchronization upon category creation, update, or deletion.
2026-07-25 16:06:17 +02:00
TheOnlyMace
dba7bb3cdc Enhance Discord emoji selection with Unicode support and improved UI
- Added support for Unicode emojis in the Discord emoji selector, allowing users to search and select from a comprehensive emoji library.
- Refactored the emoji selection component to include a category sidebar and skin tone options, improving user experience and accessibility.
- Updated localization files to include new emoji-related terms and categories in both English and German.
- Introduced a new dependency on `@emoji-mart/data` for better emoji management and search functionality.
- Enhanced the overall layout and functionality of the emoji picker, ensuring a more intuitive interface for users.
2026-07-25 15:59:53 +02:00
TheOnlyMace
ef1803f0ab Implement verification panel synchronization with BullMQ integration
- Introduced a new `verificationPanelSync` job to handle posting and updating the verification panel in Discord channels via BullMQ.
- Enhanced the `syncVerificationPanel` function to manage panel updates and error handling, ensuring proper feedback for synchronization issues.
- Updated the WebUI to support verification panel management, including improved error messages for timeout and posting failures.
- Added localization entries for new error messages related to verification panel synchronization in both English and German.
- Refactored existing verification logic to accommodate the new synchronization process, improving overall functionality and user experience.
2026-07-25 15:55:13 +02:00
TheOnlyMace
67ac16d36b Refactor self-role management to use a role builder interface
- Replaced the text area for role input with a structured role builder, enhancing user experience and reducing input errors.
- Updated the self-role management logic to handle roles as structured objects instead of plain text, improving data integrity.
- Introduced new functions for parsing and validating roles, ensuring compliance with the new builder format.
- Enhanced error handling for role creation and updates, providing clearer feedback for missing or invalid data.
- Updated localization files to reflect changes in role management terminology and instructions.
2026-07-25 15:53:27 +02:00
TheOnlyMace
ba2d144038 Implement self-role panel management with BullMQ integration
- Introduced a new `selfroles` queue to handle self-role panel creation, updates, and deletions via BullMQ jobs.
- Added types for self-role panel jobs, enhancing type safety and clarity in job handling.
- Implemented functions to create, update, and delete self-role panels, ensuring synchronization between the WebUI and Discord.
- Enhanced error handling for self-role panel operations, providing better feedback in case of failures.
- Updated the WebUI API to support self-role panel management, including error responses for synchronization issues.
2026-07-25 15:43:55 +02:00
TheOnlyMace
8513848440 Implement command cooldown management and enhance command execution flow
- Introduced `applyCommandCooldown` function to manage per-command cooldowns after execution, improving command rate limiting.
- Updated command execution flow to include cooldown application, ensuring users are informed of cooldown status.
- Enhanced error handling in interaction replies, allowing for more graceful error management and user feedback.
- Improved the handling of interaction responses across various commands, ensuring consistent user experience.
- Added permission checks for new commands, ensuring only authorized users can execute specific actions.
2026-07-25 15:37:56 +02:00
TheOnlyMace
57cdf847f5 Implement overdue giveaway recovery and enhance giveaway command handling
- Added functionality to recover overdue giveaways during bot startup, ensuring that giveaways that have passed their end time are processed correctly.
- Introduced `cancelGiveawayJob` to safely remove scheduled jobs, preventing issues with locked jobs.
- Updated giveaway command handling to trim IDs and validate existence within the guild context, improving error handling and user feedback.
- Enhanced the role picker component to normalize search queries and handle errors more gracefully, improving user experience.
- Implemented caching strategies for guild roles to avoid stale or empty responses, ensuring more reliable data retrieval.
2026-07-24 21:49:20 +02:00
smueller
c7fb3d7041 Add global /about command and configuration
- Introduced `BotAboutConfig` model for managing the global `/about` message in the owner panel.
- Implemented the `/about` command with localization support and integrated it into the help catalog.
- Enhanced the dashboard with a new owner section for configuring the `/about` message type and content.
- Updated components to handle the new command and its interactions, including support for different message formats (TEXT, EMBED, COMPONENTS_V2).
- Added localization entries for the `/about` command in English and German, improving accessibility for users.
- Documented the implementation in the phase tracking documentation for future reference.
2026-07-24 12:26:00 +02:00
smueller
20593b7173 Enhance dashboard guild access with owner bypass functionality
- Introduced `resolveDashboardGuild` to handle guild resolution for both managed and owner-panel bypass scenarios.
- Updated `GuildLayout` and `WelcomePage` components to utilize the new guild resolution logic, improving access handling.
- Enhanced `DashboardShell` to display an informational banner for users accessing guilds via the owner-panel bypass.
- Added localization support for owner bypass messages in English and German.
- Updated authentication logic to allow owner-panel users to access guilds without Discord Manage Guild permission.
2026-07-24 11:59:16 +02:00
smueller
e45642b6f9 Implement maintenance mode and presence updates in bot and WebUI
- Added maintenance mode functionality in the bot, allowing for a custom message and DND status when enabled.
- Updated the OwnerPresencePage to include read-only access for VIEWER roles, with save permissions restricted to ADMIN roles.
- Enhanced the PresenceForm component to support immediate application of changes and added a confirmation for maintenance mode activation.
- Introduced localization updates for presence-related messages in both English and German, improving user guidance.
- Implemented a presence refresh queue to handle updates efficiently after configuration changes.
2026-07-24 11:11:58 +02:00
smueller
211403d54d Enhance guild management features in the owner panel
- Updated the guilds API to support searching by both guild name and ID, improving user experience.
- Increased the number of guilds retrieved from the database from 100 to 200 for better visibility.
- Implemented invite creation functionality, allowing owners to generate invites directly from the guild management interface.
- Enhanced the UI to display guild names, icons, and member counts, providing a more informative overview.
- Updated localization files to reflect new search capabilities and invite-related messages in both English and German.
2026-07-24 10:59:07 +02:00
smueller
0ebe540c3f Enhance welcome module with embed text rendering and autorole management
- Updated the welcome embed functionality to allow for user mentions in titles, authors, and footers, while maintaining clickable mentions in descriptions.
- Implemented autorole assignment logic with checks for role manageability and hierarchy, including detailed logging for skipped roles.
- Improved the WebUI to display real user and guild data in the welcome embed preview, enhancing user experience.
- Refactored the welcome text rendering to support additional options for mention handling based on the context of the embed fields.
2026-07-24 10:54:33 +02:00
smueller
ccdf9aafe8 fix: Starboard 2026-07-24 10:46:12 +02:00
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
285 changed files with 26444 additions and 3324 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

@@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "BotAboutConfig" (
"id" TEXT NOT NULL DEFAULT 'singleton',
"enabled" BOOLEAN NOT NULL DEFAULT true,
"messageType" TEXT NOT NULL DEFAULT 'EMBED',
"content" TEXT,
"embed" JSONB,
"components" JSONB,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BotAboutConfig_pkey" PRIMARY KEY ("id")
);

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "TicketConfig" ADD COLUMN "panelChannelId" TEXT;
ALTER TABLE "TicketConfig" ADD COLUMN "panelMessageId" TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Tag" ADD COLUMN "cooldownSeconds" INTEGER NOT NULL DEFAULT 15;

View File

@@ -0,0 +1,16 @@
-- AlterTable
ALTER TABLE "Warning" ADD COLUMN "warningNumber" INTEGER;
-- Backfill per-guild sequential numbers (oldest first)
WITH numbered AS (
SELECT id, ROW_NUMBER() OVER (PARTITION BY "guildId" ORDER BY "createdAt" ASC, id ASC) AS rn
FROM "Warning"
)
UPDATE "Warning" AS w
SET "warningNumber" = numbered.rn
FROM numbered
WHERE w.id = numbered.id;
ALTER TABLE "Warning" ALTER COLUMN "warningNumber" SET NOT NULL;
CREATE UNIQUE INDEX "Warning_guildId_warningNumber_key" ON "Warning"("guildId", "warningNumber");

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "WelcomeConfig" ADD COLUMN "leaveAnnounceBan" BOOLEAN NOT NULL DEFAULT false;

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)
} }
@@ -124,6 +130,7 @@ model Case {
model Warning { model Warning {
id String @id @default(cuid()) id String @id @default(cuid())
warningNumber Int
guildId String guildId String
userId String userId String
moderatorId String moderatorId String
@@ -134,6 +141,7 @@ model Warning {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
case Case? @relation(fields: [caseId], references: [id], onDelete: SetNull) case Case? @relation(fields: [caseId], references: [id], onDelete: SetNull)
@@unique([guildId, warningNumber])
@@index([guildId, userId]) @@index([guildId, userId])
} }
@@ -241,8 +249,13 @@ 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?
/// When true, leave channel also posts a notice if the member was banned.
leaveAnnounceBan Boolean @default(false)
welcomeDmEnabled Boolean @default(false) welcomeDmEnabled Boolean @default(false)
welcomeDmContent String? welcomeDmContent String?
userAutoroleIds String[] @default([]) userAutoroleIds String[] @default([])
@@ -262,8 +275,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 +291,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
@@ -477,6 +517,8 @@ model TicketConfig {
mode String @default("CHANNEL") mode String @default("CHANNEL")
categoryId String? categoryId String?
logChannelId String? logChannelId String?
panelChannelId String?
panelMessageId String?
transcriptDm Boolean @default(false) transcriptDm Boolean @default(false)
inactivityHours Int @default(72) inactivityHours Int @default(72)
ratingEnabled Boolean @default(true) ratingEnabled Boolean @default(true)
@@ -551,8 +593,10 @@ 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?
cooldownSeconds Int @default(15)
allowedRoleIds String[] @default([]) allowedRoleIds String[] @default([])
allowedChannelIds String[] @default([]) allowedChannelIds String[] @default([])
createdById String createdById String
@@ -770,6 +814,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 +827,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 +913,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)
@@ -862,6 +921,18 @@ model BotPresenceConfig {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
/// Global `/about` message (Owner panel). Singleton row id = "singleton".
model BotAboutConfig {
id String @id @default("singleton")
enabled Boolean @default(true)
/// TEXT | EMBED | COMPONENTS_V2
messageType String @default("EMBED")
content String?
embed Json?
components Json?
updatedAt DateTime @updatedAt
}
model ChangelogEntry { model ChangelogEntry {
id String @id @default(cuid()) id String @id @default(cuid())
version String version String

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,114 @@
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 };
}
}
}
return { ok: true };
}
/** Sets the per-command cooldown after a successful execute. */
export async function applyCommandCooldown(
interaction: ChatInputCommandInteraction,
context: BotContext
): Promise<void> {
if (!interaction.guildId) {
return;
}
const override = await context.prisma.commandOverride.findUnique({
where: {
guildId_commandName: {
guildId: interaction.guildId,
commandName: interaction.commandName
}
}
});
if (!override?.cooldownSeconds || override.cooldownSeconds <= 0) {
return;
}
const key = `command:cooldown:${interaction.guildId}:${interaction.commandName}:${interaction.user.id}`;
await redis.set(key, '1', 'EX', override.cooldownSeconds);
}

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, applyCommandCooldown, 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,90 @@ 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) {
// Allow first-time setup while the module is still disabled (chicken-egg).
const sub = interaction.options.getSubcommand(false);
const isBootstrapSetup =
interaction.commandName === 'starboard' && sub === 'setup';
if (!isBootstrapSetup) {
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);
await applyCommandCooldown(interaction, context).catch(() => undefined);
} 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 maintenance = await isMaintenanceMode(context);
const ownerIds = env.OWNER_USER_IDS ?? [];
if (maintenance.enabled && !ownerIds.includes(interaction.user.id)) {
await interaction.reply({
content: maintenance.message?.trim() || t(locale, 'generic.maintenance'),
ephemeral: true
});
return;
}
const command = 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,31 @@ 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 { ephemeral } from './interaction-reply.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 +46,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 +87,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 +101,39 @@ 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 {
await runOncePerCluster(
'nexumi:lock:recover-overdue-giveaways',
60,
async () => {
const { recoverOverdueGiveaways } = await import('./modules/giveaways/index.js');
await recoverOverdueGiveaways(context);
},
'Overdue giveaway recovery'
);
} catch (error) {
logger.error({ error }, 'Failed to recover overdue giveaways');
captureException(error, { phase: 'recoverOverdueGiveaways' });
}
}
try { try {
const { applyBotPresence, publishBotStatus } = await import('./presence.js'); const { applyBotPresence, publishBotStatus } = await import('./presence.js');
await applyBotPresence(context); await applyBotPresence(context);
@@ -126,18 +141,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,89 +206,36 @@ 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
});
try {
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()) {
if (interaction.replied || interaction.deferred) { if (interaction.replied || interaction.deferred) {
await interaction.followUp({ content: message, ephemeral: true }); await interaction.followUp({ content: message, ...ephemeral() });
} else { } else {
await interaction.reply({ content: message, ephemeral: true }); await interaction.reply({ content: message, ...ephemeral() });
} }
} }
} catch (replyError) {
logger.warn({ replyError }, 'Failed to send interaction error reply');
}
} }
}); });

View File

@@ -0,0 +1,216 @@
import type {
ButtonInteraction,
ChannelSelectMenuInteraction,
MentionableSelectMenuInteraction,
ModalSubmitInteraction,
RoleSelectMenuInteraction,
StringSelectMenuInteraction,
UserSelectMenuInteraction
} from 'discord.js';
import { decodeComponentCustomId, t, type DashboardModuleId } from '@nexumi/shared';
import { env } from './env.js';
import { getGuildLocale } from './i18n.js';
import { ephemeral } from './interaction-reply.js';
import { isModuleEnabled, moduleForComponent } from './module-gates.js';
import { isMaintenanceMode } from './presence.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
)
}
];
function moduleForComponentsV2(customId: string): DashboardModuleId | null {
const decoded = decodeComponentCustomId(customId);
if (!decoded) {
return null;
}
switch (decoded.source) {
case 'w':
return 'welcome';
case 't':
return 'tags';
case 's':
return 'scheduler';
case 'm':
return 'messages';
default:
return 'messages';
}
}
export async function routeComponentInteraction(
interaction: AnyComponentInteraction,
context: BotContext
): Promise<boolean> {
const customId = interaction.customId;
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const maintenance = await isMaintenanceMode(context);
const ownerIds = env.OWNER_USER_IDS ?? [];
if (maintenance.enabled && !ownerIds.includes(interaction.user.id)) {
if (!interaction.replied && !interaction.deferred) {
await interaction.reply({
content: maintenance.message?.trim() || t(locale, 'generic.maintenance'),
...ephemeral()
});
}
return true;
}
let moduleId = moduleForComponent(customId);
if (!moduleId && isComponentsV2Interaction(customId)) {
moduleId = moduleForComponentsV2(customId);
}
if (moduleId && interaction.guildId) {
const enabled = await isModuleEnabled(
context.prisma,
context.redis,
interaction.guildId,
moduleId
);
if (!enabled) {
if (!interaction.replied && !interaction.deferred) {
await interaction.reply({
content: t(locale, 'generic.moduleDisabled'),
...ephemeral()
});
}
return true;
}
}
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,33 @@ 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 { completeVerification } from './modules/verification/handlers.js'; import { activateLockdown, deactivateLockdown } from './modules/automod/actions.js';
import {
completeVerification,
runVerificationPanelSyncJob
} 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 {
import { endGiveaway, runGiveawayCreateJob } from './modules/giveaways/index.js'; expireSelfRole,
import { closeInactiveTickets } from './modules/tickets/index.js'; runSelfRolePanelCreateJob,
runSelfRolePanelUpdateJob,
runSelfRolePanelDeleteJob
} from './modules/selfroles/index.js';
import type { SelfRoleBehavior, SelfRoleEntry, SelfRoleMode } from '@nexumi/shared';
import {
endGiveaway,
runGiveawayCreateJob,
runGiveawayPauseJob,
runGiveawayDeleteJob,
GiveawayError
} from './modules/giveaways/index.js';
import { closeInactiveTickets, runTicketDashboardActionJob, runTicketPanelSyncJob } 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 +49,7 @@ import {
feedsQueueName, feedsQueueName,
giveawayQueueName, giveawayQueueName,
guildBackupQueueName, guildBackupQueueName,
messagesQueueName,
moderationQueueName, moderationQueueName,
presenceQueue, presenceQueue,
presenceQueueName, presenceQueueName,
@@ -39,6 +57,7 @@ import {
retentionQueue, retentionQueue,
retentionQueueName, retentionQueueName,
scheduleQueueName, scheduleQueueName,
selfrolesQueueName,
suggestionsQueueName, suggestionsQueueName,
ticketQueue, ticketQueue,
ticketQueueName, ticketQueueName,
@@ -63,6 +82,13 @@ type TempBanJob = {
type VerificationCompleteJob = { type VerificationCompleteJob = {
guildId: string; guildId: string;
userId: string; userId: string;
ipHash?: string | null;
userAgentHash?: string | null;
captchaProvider?: string | null;
};
type VerificationPanelSyncJob = {
guildId: string;
}; };
type ReminderSendJob = { type ReminderSendJob = {
@@ -81,6 +107,8 @@ type SelfRoleExpireJob = {
type GiveawayEndJob = { type GiveawayEndJob = {
giveawayId: string; giveawayId: string;
/** When true, end even if the giveaway is paused (dashboard/slash paths). */
force?: boolean;
}; };
type GiveawayCreateJob = { type GiveawayCreateJob = {
@@ -95,6 +123,15 @@ type GiveawayCreateJob = {
requiredMemberDays?: number | null; requiredMemberDays?: number | null;
}; };
type GiveawayPauseJob = {
giveawayId: string;
paused: boolean;
};
type GiveawayDeleteJob = {
giveawayId: string;
};
type BirthdayRoleExpireJob = { type BirthdayRoleExpireJob = {
guildId: string; guildId: string;
@@ -109,6 +146,36 @@ type SuggestionStatusUpdateJob = {
reason: string; reason: string;
}; };
type SelfRolePanelCreateJob = {
guildId: string;
channelId: string;
title: string;
description?: string | null;
mode: SelfRoleMode;
behavior: SelfRoleBehavior;
roles: SelfRoleEntry[];
temporaryDurationMs?: number;
expiresAt?: string | null;
};
type SelfRolePanelUpdateJob = {
panelId: string;
guildId: string;
channelId?: string;
title?: string;
description?: string | null;
mode?: SelfRoleMode;
behavior?: SelfRoleBehavior;
roles?: SelfRoleEntry[];
temporaryDurationMs?: number;
expiresAt?: string | null;
};
type SelfRolePanelDeleteJob = {
panelId: string;
guildId: string;
};
export function startWorkers(context: BotContext): Worker[] { export function startWorkers(context: BotContext): Worker[] {
const moderationWorker = new Worker<TempBanJob>( const moderationWorker = new Worker<TempBanJob>(
moderationQueueName, moderationQueueName,
@@ -117,12 +184,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 +274,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 }
); );
@@ -162,13 +304,21 @@ export function startWorkers(context: BotContext): Worker[] {
logger.error({ jobId: job?.id, error }, 'AutoMod job failed'); logger.error({ jobId: job?.id, error }, 'AutoMod job failed');
}); });
const verificationWorker = new Worker<VerificationCompleteJob>( const verificationWorker = new Worker<VerificationCompleteJob | VerificationPanelSyncJob>(
verificationQueueName, verificationQueueName,
async (job) => { async (job) => {
if (job.name === 'verificationPanelSync') {
return runVerificationPanelSyncJob(context, job.data as VerificationPanelSyncJob);
}
if (job.name !== 'verificationComplete') { if (job.name !== 'verificationComplete') {
return; return;
} }
await completeVerification(context, job.data.guildId, job.data.userId); const data = job.data as VerificationCompleteJob;
await completeVerification(context, data.guildId, data.userId, {
ipHash: data.ipHash,
userAgentHash: data.userAgentHash,
captchaProvider: data.captchaProvider
});
}, },
{ connection: redis } { connection: redis }
); );
@@ -198,6 +348,22 @@ export function startWorkers(context: BotContext): Worker[] {
const ticketWorker = new Worker( const ticketWorker = new Worker(
ticketQueueName, ticketQueueName,
async (job) => { async (job) => {
if (job.name === 'ticketPanelSync') {
return runTicketPanelSyncJob(context, job.data as { guildId: string });
}
if (job.name === 'ticketDashboardAction') {
return runTicketDashboardActionJob(
context,
job.data as {
ticketId: string;
guildId: string;
actorUserId: string;
action: 'close' | 'claim' | 'priority';
reason?: string;
priority?: string;
}
);
}
if (job.name === 'selfRoleExpire') { if (job.name === 'selfRoleExpire') {
const data = job.data as SelfRoleExpireJob; const data = job.data as SelfRoleExpireJob;
await expireSelfRole(context, data.guildId, data.userId, data.roleId); await expireSelfRole(context, data.guildId, data.userId, data.roleId);
@@ -214,16 +380,68 @@ export function startWorkers(context: BotContext): Worker[] {
logger.error({ jobId: job?.id, error }, 'Ticket queue job failed'); logger.error({ jobId: job?.id, error }, 'Ticket queue job failed');
}); });
const giveawayWorker = new Worker<GiveawayEndJob | GiveawayCreateJob>( const selfrolesWorker = new Worker<
SelfRolePanelCreateJob | SelfRolePanelUpdateJob | SelfRolePanelDeleteJob
>(
selfrolesQueueName,
async (job) => {
if (job.name === 'selfRolePanelCreate') {
return runSelfRolePanelCreateJob(context, job.data as SelfRolePanelCreateJob);
}
if (job.name === 'selfRolePanelUpdate') {
return runSelfRolePanelUpdateJob(context, job.data as SelfRolePanelUpdateJob);
}
if (job.name === 'selfRolePanelDelete') {
const data = job.data as SelfRolePanelDeleteJob;
await runSelfRolePanelDeleteJob(context, data.panelId, data.guildId);
}
},
{ connection: redis }
);
selfrolesWorker.on('failed', (job, error) => {
logger.error({ jobId: job?.id, error }, 'Self-roles queue job failed');
});
const giveawayWorker = new Worker<
GiveawayEndJob | GiveawayCreateJob | GiveawayPauseJob | GiveawayDeleteJob
>(
giveawayQueueName, giveawayQueueName,
async (job) => { async (job) => {
if (job.name === 'giveawayEnd') { if (job.name === 'giveawayEnd') {
await endGiveaway(context, (job.data as GiveawayEndJob).giveawayId); try {
const data = job.data as GiveawayEndJob;
if (!data.force) {
const current = await context.prisma.giveaway.findUnique({
where: { id: data.giveawayId }
});
// Timer/recover must not end a paused giveaway.
if (current?.paused) {
return;
}
}
await endGiveaway(context, data.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') {
return runGiveawayCreateJob(context, job.data as GiveawayCreateJob); return runGiveawayCreateJob(context, job.data as GiveawayCreateJob);
} }
if (job.name === 'giveawayPause') {
const data = job.data as GiveawayPauseJob;
return runGiveawayPauseJob(context, data.giveawayId, data.paused);
}
if (job.name === 'giveawayDelete') {
await runGiveawayDeleteJob(context, (job.data as GiveawayDeleteJob).giveawayId);
}
}, },
{ connection: redis } { connection: redis }
); );
@@ -281,6 +499,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) => {
@@ -344,11 +576,13 @@ export function startWorkers(context: BotContext): Worker[] {
verificationWorker, verificationWorker,
reminderWorker, reminderWorker,
ticketWorker, ticketWorker,
selfrolesWorker,
giveawayWorker, giveawayWorker,
birthdayWorker, birthdayWorker,
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,94 @@
import { EmbedBuilder } from 'discord.js';
import {
embedHasContent,
mapEmbedTextFields,
type EmbedTextField,
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.
* `field` lets callers render `{user}` as a mention in the description
* but as a plain display name in title/author/footer.
*/
renderText: (value: string, field: EmbedTextField) => 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;
}

View File

@@ -0,0 +1,35 @@
import type { Job } from 'bullmq';
import { logger } from '../logger.js';
/**
* Removes a BullMQ job when present. Locked (active) jobs cannot be removed —
* those errors are swallowed so callers can still schedule a replacement jobId
* or rely on removeOnComplete after the worker finishes.
* Jobs that belong to a Job Scheduler must be removed via removeJobScheduler;
* direct remove is ignored here.
*/
export async function safeRemoveBullJob(
job: Job | null | undefined,
context?: { label?: string; jobId?: string }
): Promise<void> {
if (!job) {
return;
}
try {
await job.remove();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (
message.includes('locked') ||
message.includes('could not be removed') ||
message.includes('belongs to a job scheduler')
) {
logger.warn(
{ error, jobId: context?.jobId ?? job.id, label: context?.label },
'BullMQ job could not be removed directly; skipping'
);
return;
}
throw error;
}
}

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,34 @@
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('about')).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,310 @@
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;
}
/** Component customId → dashboard module (null = no module gate). */
export function moduleForComponent(customId: string): DashboardModuleId | null {
if (customId.startsWith('mod:confirm:') || customId.startsWith('mod:cancel:')) {
return 'moderation';
}
if (customId.startsWith('verify:')) {
return 'verification';
}
if (customId.startsWith('eco:bj:')) {
return 'economy';
}
if (customId.startsWith('fun:')) {
return 'fun';
}
if (customId.startsWith('gw:')) {
return 'giveaways';
}
if (customId.startsWith('ticket:') || customId === 'ticket:select') {
return 'tickets';
}
if (customId.startsWith('sr:')) {
return 'selfroles';
}
if (customId.startsWith('sug:')) {
return 'suggestions';
}
if (customId.startsWith('tv:')) {
return 'tempvoice';
}
if (customId.startsWith('gbackup:')) {
return 'guildbackup';
}
return 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

@@ -1,6 +1,7 @@
import { ChannelType, PermissionFlagsBits, type Guild, type GuildMember, type Message, type TextChannel } from 'discord.js'; import { ChannelType, PermissionFlagsBits, type Guild, type GuildMember, type Message, type TextChannel } from 'discord.js';
import type { BotContext } from '../../types.js'; import type { BotContext } from '../../types.js';
import { createCase } from '../moderation/service.js'; import { createCase, createWarning, notifyModerationTargetDm } from '../moderation/service.js';
import { getGuildLocale } from '../../i18n.js';
import { import {
getAutoModConfig, getAutoModConfig,
getEnabledAutoModRules, getEnabledAutoModRules,
@@ -41,15 +42,8 @@ async function applyAutoModAction(
const baseReason = `AutoMod (${ruleType}): ${reason}`; const baseReason = `AutoMod (${ruleType}): ${reason}`;
if (action === 'WARN') { if (action === 'WARN') {
await context.prisma.warning.create({ const locale = await getGuildLocale(context.prisma, guild.id);
data: { const modCase = await createCase(context, {
guildId: guild.id,
userId: message.author.id,
moderatorId,
reason: baseReason
}
});
await createCase(context, {
guildId: guild.id, guildId: guild.id,
targetUserId: message.author.id, targetUserId: message.author.id,
moderatorId, moderatorId,
@@ -59,6 +53,16 @@ async function applyAutoModAction(
source: 'automod', source: 'automod',
metadata: { ruleType, automod: true } metadata: { ruleType, automod: true }
}); });
await createWarning(context, {
guildId: guild.id,
userId: message.author.id,
moderatorId,
reason: baseReason,
caseId: modCase.id,
guildName: guild.name,
locale,
notifyUser: message.author
});
return; return;
} }
@@ -81,6 +85,14 @@ async function applyAutoModAction(
if (action === 'KICK') { if (action === 'KICK') {
if (me.permissions.has(PermissionFlagsBits.KickMembers)) { if (me.permissions.has(PermissionFlagsBits.KickMembers)) {
const locale = await getGuildLocale(context.prisma, guild.id);
await notifyModerationTargetDm({
user: message.author,
guildName: guild.name,
reason: baseReason,
locale,
action: 'kick'
});
await message.member.kick(baseReason); await message.member.kick(baseReason);
await createCase(context, { await createCase(context, {
guildId: guild.id, guildId: guild.id,
@@ -98,6 +110,14 @@ async function applyAutoModAction(
if (action === 'BAN') { if (action === 'BAN') {
if (me.permissions.has(PermissionFlagsBits.BanMembers)) { if (me.permissions.has(PermissionFlagsBits.BanMembers)) {
const locale = await getGuildLocale(context.prisma, guild.id);
await notifyModerationTargetDm({
user: message.author,
guildName: guild.name,
reason: baseReason,
locale,
action: 'ban'
});
await guild.members.ban(message.author.id, { reason: baseReason }); await guild.members.ban(message.author.id, { reason: baseReason });
await createCase(context, { await createCase(context, {
guildId: guild.id, guildId: guild.id,
@@ -204,6 +224,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 +244,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 +251,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

@@ -1,8 +1,11 @@
import { SlashCommandBuilder } from 'discord.js'; import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { applyCommandDescription } from '@nexumi/shared'; import { applyCommandDescription } from '@nexumi/shared';
export const automodStatusCommandData = applyCommandDescription( export const automodStatusCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('automod').addSubcommand((s) => new SlashCommandBuilder()
.setName('automod')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addSubcommand((s) =>
applyCommandDescription(s.setName('status'), 'automod.status.description') applyCommandDescription(s.setName('status'), 'automod.status.description')
), ),
'automod.description' 'automod.description'

View File

@@ -1,6 +1,7 @@
import type { Redis } from 'ioredis'; import type { Redis } from 'ioredis';
import { import {
capsRatio, capsRatio,
contentMatchesBlockedWord,
countEmojis, countEmojis,
extractUrls, extractUrls,
hasZalgo, hasZalgo,
@@ -124,15 +125,14 @@ function checkExternalLink(content: string, threshold: AutoModThreshold): Filter
} }
function checkWordFilter(content: string, wordList: AutoModWordList): FilterResult | null { function checkWordFilter(content: string, wordList: AutoModWordList): FilterResult | null {
const lower = content.toLowerCase();
for (const word of wordList.words) { for (const word of wordList.words) {
if (word.length > 0 && lower.includes(word.toLowerCase())) { if (contentMatchesBlockedWord(content, word)) {
return { matched: true, ruleType: 'WORD_FILTER', reason: 'Blocked word' }; return { matched: true, ruleType: 'WORD_FILTER', reason: 'Blocked word' };
} }
} }
for (const pattern of wordList.regexPatterns) { for (const pattern of wordList.regexPatterns) {
try { try {
const regex = new RegExp(pattern, 'i'); const regex = new RegExp(pattern, 'iu');
if (regex.test(content)) { if (regex.test(content)) {
return { matched: true, ruleType: 'WORD_FILTER', reason: 'Blocked pattern' }; return { matched: true, ruleType: 'WORD_FILTER', reason: 'Blocked pattern' };
} }

View File

@@ -1,6 +1,8 @@
import type { PrismaClient } from '@prisma/client'; import type { Prisma, PrismaClient } from '@prisma/client';
import { import {
DEFAULT_AUTOMOD_RULES, DEFAULT_AUTOMOD_RULES,
defaultAutoModWordList,
shouldSeedDefaultBlockedWords,
type AutoModExceptions, type AutoModExceptions,
type AutoModThreshold, type AutoModThreshold,
type AutoModWordList type AutoModWordList
@@ -16,14 +18,37 @@ export async function getAutoModConfig(prisma: PrismaClient, guildId: string) {
return config; return config;
} }
export async function ensureDefaultAutoModRules(prisma: PrismaClient, guildId: string) { async function seedDefaultWordFilterIfNeeded(prisma: PrismaClient, guildId: string) {
await ensureGuild(prisma, guildId); const rule = await prisma.autoModRule.findUnique({
const existing = await prisma.autoModRule.count({ where: { guildId } }); where: { guildId_ruleType: { guildId, ruleType: 'WORD_FILTER' } },
if (existing > 0) { select: { id: true, wordList: true }
});
if (!rule || !shouldSeedDefaultBlockedWords(rule.wordList)) {
return; return;
} }
const existing = parseWordList(rule.wordList);
await prisma.autoModRule.update({
where: { id: rule.id },
data: {
wordList: {
...defaultAutoModWordList(),
regexPatterns: existing.regexPatterns
} as Prisma.InputJsonValue
}
});
}
export async function ensureDefaultAutoModRules(prisma: PrismaClient, guildId: string) {
await ensureGuild(prisma, guildId);
const existing = await prisma.autoModRule.findMany({
where: { guildId },
select: { ruleType: true }
});
const have = new Set(existing.map((row) => row.ruleType));
const missing = DEFAULT_AUTOMOD_RULES.filter((rule) => !have.has(rule.ruleType));
if (missing.length > 0) {
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,
@@ -32,6 +57,8 @@ export async function ensureDefaultAutoModRules(prisma: PrismaClient, guildId: s
enabled: ['SPAM', 'MASS_MENTION', 'INVITE_LINK', 'PHISHING'].includes(rule.ruleType) enabled: ['SPAM', 'MASS_MENTION', 'INVITE_LINK', 'PHISHING'].includes(rule.ruleType)
})) }))
}); });
}
await seedDefaultWordFilterIfNeeded(prisma, guildId);
} }
export async function getEnabledAutoModRules(prisma: PrismaClient, guildId: string) { export async function getEnabledAutoModRules(prisma: PrismaClient, guildId: string) {
@@ -66,7 +93,8 @@ export function parseWordList(raw: unknown): AutoModWordList {
const obj = raw as Record<string, unknown>; const obj = raw as Record<string, unknown>;
return { return {
words: Array.isArray(obj.words) ? obj.words.map(String) : [], words: Array.isArray(obj.words) ? obj.words.map(String) : [],
regexPatterns: Array.isArray(obj.regexPatterns) ? obj.regexPatterns.map(String) : [] regexPatterns: Array.isArray(obj.regexPatterns) ? obj.regexPatterns.map(String) : [],
...(obj.userCleared === true ? { userCleared: true } : {})
}; };
} }

View File

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

View File

@@ -0,0 +1,342 @@
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);
}
case 'a': {
if (ref !== 'singleton') {
return null;
}
const config = await context.prisma.botAboutConfig.findUnique({ where: { id: 'singleton' } });
return parseMessageComponentsV2(config?.components);
}
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

@@ -0,0 +1,98 @@
import {
ABOUT_REDIS_KEY,
BotAboutConfigSchema,
expandBracketChannelMentions,
parseMessageComponentsV2,
t,
type BotAboutConfig,
type Locale,
type WelcomeEmbed
} from '@nexumi/shared';
import type { InteractionReplyOptions, MessageCreateOptions } from 'discord.js';
import { EmbedBuilder } from 'discord.js';
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
import { buildEmbedFromPayload } from '../../lib/embed-payload.js';
import { redis } from '../../redis.js';
import type { BotContext } from '../../types.js';
const NEXUMI_COLOR = 0x6366f1;
function parseEmbed(raw: unknown): WelcomeEmbed | null {
if (!raw || typeof raw !== 'object') {
return null;
}
return raw as WelcomeEmbed;
}
export async function readAboutConfig(context: BotContext): Promise<BotAboutConfig> {
const cached = await redis.get(ABOUT_REDIS_KEY);
if (cached) {
const parsed = BotAboutConfigSchema.safeParse(JSON.parse(cached));
if (parsed.success) {
return parsed.data;
}
}
const row = await context.prisma.botAboutConfig.upsert({
where: { id: 'singleton' },
create: { id: 'singleton' },
update: {}
});
const config = BotAboutConfigSchema.parse({
enabled: row.enabled,
messageType: row.messageType,
content: row.content,
embed: parseEmbed(row.embed),
components: parseMessageComponentsV2(row.components)
});
await redis.set(ABOUT_REDIS_KEY, JSON.stringify(config));
return config;
}
function defaultAboutReply(locale: Locale): InteractionReplyOptions {
return {
embeds: [
new EmbedBuilder()
.setTitle(t(locale, 'core.about.defaultTitle'))
.setDescription(t(locale, 'core.about.defaultBody'))
.setColor(NEXUMI_COLOR)
.setFooter({ text: 'Nexumi' })
]
};
}
export async function buildAboutReply(
context: BotContext,
locale: Locale
): Promise<InteractionReplyOptions | MessageCreateOptions> {
const config = await readAboutConfig(context);
if (!config.enabled) {
return { content: t(locale, 'core.about.disabled'), ephemeral: true };
}
const renderText = (value: string) => expandBracketChannelMentions(value);
if (config.messageType === 'COMPONENTS_V2') {
const payload = buildComponentsV2Payload(config.components, {
source: 'a',
ref: 'singleton',
renderText
});
if (payload) {
return payload;
}
}
if (config.messageType === 'EMBED') {
const embed = buildEmbedFromPayload(config.embed, { renderText });
if (embed) {
return { embeds: [embed] };
}
}
if (config.messageType === 'TEXT' && config.content?.trim()) {
return { content: renderText(config.content) };
}
return defaultAboutReply(locale);
}

View File

@@ -13,6 +13,11 @@ export const infoCommandData = applyCommandDescription(
'core.info.description' 'core.info.description'
); );
export const aboutCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('about'),
'core.about.description'
);
export const inviteCommandData = applyCommandDescription( export const inviteCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('invite'), new SlashCommandBuilder().setName('invite'),
'core.invite.description' 'core.invite.description'

View File

@@ -6,10 +6,12 @@ import { buildBotInviteUrl, env } from '../../env.js';
import { import {
helpCommandData, helpCommandData,
infoCommandData, infoCommandData,
aboutCommandData,
inviteCommandData, inviteCommandData,
supportCommandData supportCommandData
} from './command-definitions.js'; } from './command-definitions.js';
import { HELP_MODULES } from './help-catalog.js'; import { HELP_MODULES } from './help-catalog.js';
import { buildAboutReply } from './about.js';
const NEXUMI_COLOR = 0x6366f1; const NEXUMI_COLOR = 0x6366f1;
const PACKAGE_VERSION = '0.1.0'; const PACKAGE_VERSION = '0.1.0';
@@ -118,6 +120,15 @@ const infoCommand: SlashCommand = {
} }
}; };
const aboutCommand: SlashCommand = {
data: aboutCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const reply = await buildAboutReply(context, locale);
await interaction.reply(reply as import('discord.js').InteractionReplyOptions);
}
};
const inviteCommand: SlashCommand = { const inviteCommand: SlashCommand = {
data: inviteCommandData, data: inviteCommandData,
async execute(interaction, context) { async execute(interaction, context) {
@@ -153,6 +164,7 @@ const supportCommand: SlashCommand = {
export const coreCommands: SlashCommand[] = [ export const coreCommands: SlashCommand[] = [
helpCommand, helpCommand,
infoCommand, infoCommand,
aboutCommand,
inviteCommand, inviteCommand,
supportCommand supportCommand
]; ];

View File

@@ -1,6 +1,6 @@
/** Module id → top-level slash command names for `/help`. */ /** Module id → top-level slash command names for `/help`. */
export const HELP_MODULES: ReadonlyArray<{ id: string; commands: readonly string[] }> = [ export const HELP_MODULES: ReadonlyArray<{ id: string; commands: readonly string[] }> = [
{ id: 'core', commands: ['help', 'info', 'invite', 'support', 'privacy', 'gdpr'] }, { id: 'core', commands: ['help', 'info', 'about', 'invite', 'support', 'privacy', 'gdpr'] },
{ {
id: 'moderation', id: 'moderation',
commands: [ commands: [

View File

@@ -327,7 +327,12 @@ async function handleMediaError(
): Promise<void> { ): Promise<void> {
if (error instanceof FunMediaError) { if (error instanceof FunMediaError) {
const key = error.code === 'disabled' ? 'fun.media.disabled' : 'fun.media.fetchFailed'; const key = error.code === 'disabled' ? 'fun.media.disabled' : 'fun.media.fetchFailed';
await interaction.reply({ content: t(locale, key), ephemeral: true }); const content = t(locale, key);
if (interaction.deferred || interaction.replied) {
await interaction.editReply({ content });
return;
}
await interaction.reply({ content, ephemeral: true });
return; return;
} }
throw error; throw error;
@@ -342,12 +347,13 @@ const memeCommand: SlashCommand = {
try { try {
await assertMediaEnabled(context.redis, guildId); await assertMediaEnabled(context.redis, guildId);
await interaction.deferReply();
const meme = await fetchMeme(); const meme = await fetchMeme();
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle(meme.title) .setTitle(meme.title)
.setImage(meme.url) .setImage(meme.url)
.setColor(0x6366f1); .setColor(0x6366f1);
await interaction.reply({ embeds: [embed] }); await interaction.editReply({ embeds: [embed] });
} catch (error) { } catch (error) {
await handleMediaError(interaction, locale, error); await handleMediaError(interaction, locale, error);
} }
@@ -363,12 +369,13 @@ const catCommand: SlashCommand = {
try { try {
await assertMediaEnabled(context.redis, guildId); await assertMediaEnabled(context.redis, guildId);
await interaction.deferReply();
const url = await fetchCatImage(); const url = await fetchCatImage();
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle(t(locale, 'fun.cat.title')) .setTitle(t(locale, 'fun.cat.title'))
.setImage(url) .setImage(url)
.setColor(0x6366f1); .setColor(0x6366f1);
await interaction.reply({ embeds: [embed] }); await interaction.editReply({ embeds: [embed] });
} catch (error) { } catch (error) {
await handleMediaError(interaction, locale, error); await handleMediaError(interaction, locale, error);
} }
@@ -384,12 +391,13 @@ const dogCommand: SlashCommand = {
try { try {
await assertMediaEnabled(context.redis, guildId); await assertMediaEnabled(context.redis, guildId);
await interaction.deferReply();
const url = await fetchDogImage(); const url = await fetchDogImage();
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle(t(locale, 'fun.dog.title')) .setTitle(t(locale, 'fun.dog.title'))
.setImage(url) .setImage(url)
.setColor(0x6366f1); .setColor(0x6366f1);
await interaction.reply({ embeds: [embed] }); await interaction.editReply({ embeds: [embed] });
} catch (error) { } catch (error) {
await handleMediaError(interaction, locale, error); await handleMediaError(interaction, locale, error);
} }

View File

@@ -1,9 +1,10 @@
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared'; import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
import { SlashCommandBuilder } from 'discord.js'; import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
export const giveawayCommandData = applyCommandDescription( export const giveawayCommandData = applyCommandDescription(
new SlashCommandBuilder() new SlashCommandBuilder()
.setName('giveaway') .setName('giveaway')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addSubcommand((s) => .addSubcommand((s) =>
applyCommandDescription(s.setName('start'), 'giveaway.start.description') applyCommandDescription(s.setName('start'), 'giveaway.start.description')
.addStringOption((o) => .addStringOption((o) =>

View File

@@ -6,6 +6,7 @@ import { requirePermission } from '../../permissions.js';
import { parseDuration } from '../moderation/duration.js'; import { parseDuration } from '../moderation/duration.js';
import { giveawayCommandData } from './command-definitions.js'; import { giveawayCommandData } from './command-definitions.js';
import { import {
cancelGiveawayJob,
deleteGiveaway, deleteGiveaway,
endGiveaway, endGiveaway,
GiveawayError, GiveawayError,
@@ -26,6 +27,17 @@ async function ensureManageGuild(
return requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale); return requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale);
} }
async function replyEphemeral(
interaction: Parameters<SlashCommand['execute']>[0],
content: string
): Promise<void> {
if (interaction.deferred || interaction.replied) {
await interaction.editReply({ content });
return;
}
await interaction.reply({ content, ephemeral: true });
}
const giveawayCommand: SlashCommand = { const giveawayCommand: SlashCommand = {
data: giveawayCommandData, data: giveawayCommandData,
async execute(interaction, context) { async execute(interaction, context) {
@@ -71,6 +83,8 @@ const giveawayCommand: SlashCommand = {
return; return;
} }
await interaction.deferReply({ ephemeral: true });
const giveaway = await startGiveaway( const giveaway = await startGiveaway(
context, context,
{ {
@@ -87,9 +101,8 @@ const giveawayCommand: SlashCommand = {
locale locale
); );
await interaction.reply({ await interaction.editReply({
content: tf(locale, 'giveaway.started', { id: giveaway.id }), content: tf(locale, 'giveaway.started', { id: giveaway.id })
ephemeral: true
}); });
return; return;
} }
@@ -100,20 +113,30 @@ const giveawayCommand: SlashCommand = {
try { try {
if (sub === 'end') { if (sub === 'end') {
const id = interaction.options.getString('id', true); const id = interaction.options.getString('id', true).trim();
const giveaway = await context.prisma.giveaway.findUnique({ where: { id } });
if (!giveaway || giveaway.guildId !== interaction.guildId) {
throw new GiveawayError('not_found');
}
await interaction.deferReply({ ephemeral: true });
await cancelGiveawayJob(id);
await endGiveaway(context, id); await endGiveaway(context, id);
await interaction.reply({ content: t(locale, 'giveaway.ended'), ephemeral: true }); await interaction.editReply({ content: t(locale, 'giveaway.ended') });
return; return;
} }
if (sub === 'reroll') { if (sub === 'reroll') {
const id = interaction.options.getString('id', true); const id = interaction.options.getString('id', true).trim();
const existing = await context.prisma.giveaway.findUnique({ where: { id } });
if (!existing || existing.guildId !== interaction.guildId) {
throw new GiveawayError('not_found');
}
await interaction.deferReply({ ephemeral: true });
const updated = await rerollGiveaway(context, id, locale); const updated = await rerollGiveaway(context, id, locale);
await interaction.reply({ await interaction.editReply({
content: tf(locale, 'giveaway.rerolled', { content: tf(locale, 'giveaway.rerolled', {
winners: updated.winners.map((w) => `<@${w}>`).join(', ') || '—' winners: updated.winners.map((w) => `<@${w}>`).join(', ') || '—'
}), })
ephemeral: true
}); });
return; return;
} }
@@ -136,14 +159,22 @@ const giveawayCommand: SlashCommand = {
} }
if (sub === 'delete') { if (sub === 'delete') {
const id = interaction.options.getString('id', true); const id = interaction.options.getString('id', true).trim();
const existing = await context.prisma.giveaway.findUnique({ where: { id } });
if (!existing || existing.guildId !== interaction.guildId) {
throw new GiveawayError('not_found');
}
await deleteGiveaway(context, id); await deleteGiveaway(context, id);
await interaction.reply({ content: t(locale, 'giveaway.deleted'), ephemeral: true }); await interaction.reply({ content: t(locale, 'giveaway.deleted'), ephemeral: true });
return; return;
} }
if (sub === 'pause') { if (sub === 'pause') {
const id = interaction.options.getString('id', true); const id = interaction.options.getString('id', true).trim();
const existing = await context.prisma.giveaway.findUnique({ where: { id } });
if (!existing || existing.guildId !== interaction.guildId) {
throw new GiveawayError('not_found');
}
const updated = await pauseGiveaway(context, id, locale); const updated = await pauseGiveaway(context, id, locale);
await interaction.reply({ await interaction.reply({
content: t(locale, updated.paused ? 'giveaway.paused' : 'giveaway.resumed'), content: t(locale, updated.paused ? 'giveaway.paused' : 'giveaway.resumed'),
@@ -152,10 +183,7 @@ const giveawayCommand: SlashCommand = {
} }
} catch (error) { } catch (error) {
if (error instanceof GiveawayError) { if (error instanceof GiveawayError) {
await interaction.reply({ await replyEphemeral(interaction, t(locale, `giveaway.error.${error.code}`));
content: t(locale, `giveaway.error.${error.code}`),
ephemeral: true
});
return; return;
} }
throw error; throw error;

View File

@@ -1,3 +1,11 @@
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,
runGiveawayPauseJob,
runGiveawayDeleteJob,
recoverOverdueGiveaways,
GiveawayError
} from './service.js';

View File

@@ -22,25 +22,46 @@ export class GiveawayError extends Error {
} }
} }
export function giveawayEndJobId(giveawayId: string): string {
return `giveaway-end-${giveawayId}`;
}
/**
* Removes a scheduled/failed end job if present. Locked (active) jobs cannot be
* removed — callers must not invoke this from inside the giveawayEnd worker.
*/
export async function cancelGiveawayJob(giveawayId: string): Promise<void> {
const job = await giveawayQueue.getJob(giveawayEndJobId(giveawayId));
if (!job) {
return;
}
try {
await job.remove();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes('locked') || message.includes('could not be removed')) {
logger.warn({ giveawayId, error }, 'Giveaway end job is locked; skipping remove');
return;
}
throw error;
}
}
export async function scheduleGiveawayEnd(giveawayId: string, endsAt: Date): Promise<void> { export async function scheduleGiveawayEnd(giveawayId: string, endsAt: Date): Promise<void> {
await cancelGiveawayJob(giveawayId);
const delay = Math.max(0, endsAt.getTime() - Date.now()); const delay = Math.max(0, endsAt.getTime() - Date.now());
await giveawayQueue.add( await giveawayQueue.add(
'giveawayEnd', 'giveawayEnd',
{ giveawayId }, { giveawayId },
{ {
delay, delay,
jobId: `giveaway-end-${giveawayId}`, jobId: giveawayEndJobId(giveawayId),
removeOnComplete: true, removeOnComplete: true,
removeOnFail: 50 removeOnFail: 50
} }
); );
} }
export async function cancelGiveawayJob(giveawayId: string): Promise<void> {
const job = await giveawayQueue.getJob(`giveaway-end-${giveawayId}`);
await job?.remove();
}
function requirementLines(locale: 'de' | 'en', giveaway: Giveaway): string[] { function requirementLines(locale: 'de' | 'en', giveaway: Giveaway): string[] {
const lines: string[] = []; const lines: string[] = [];
if (giveaway.requiredRoleId) { if (giveaway.requiredRoleId) {
@@ -59,6 +80,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 +112,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,31 +337,89 @@ async function dmWinners(
} }
} }
export async function endGiveaway(context: BotContext, giveawayId: string): Promise<void> { /**
* Draws winners and marks the giveaway ended. Does **not** cancel BullMQ jobs —
* the slash-command / dashboard paths cancel the delayed job first; the
* `giveawayEnd` worker must never remove its own locked job (that threw and
* left giveaways stuck with the join button still visible).
*
* Uses a conditional update so concurrent ends (timer + slash + recover)
* cannot each write different winners.
*/
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);
const locale = await getGuildLocale(context.prisma, giveaway.guildId); const locale = await getGuildLocale(context.prisma, giveaway.guildId);
const guild = await context.client.guilds.fetch(giveaway.guildId); const guild = await context.client.guilds.fetch(giveaway.guildId);
const eligible = await filterEligibleEntrants(context, guild, giveaway); const eligible = await filterEligibleEntrants(context, guild, giveaway);
const winners = pickRandomWinners(eligible, giveaway.winnerCount); const winners = pickRandomWinners(eligible, giveaway.winnerCount);
const endedAt = new Date();
const updated = await context.prisma.giveaway.update({ const claimed = await context.prisma.giveaway.updateMany({
where: { id: giveawayId }, where: { id: giveawayId, endedAt: null },
data: { data: {
endedAt: new Date(), endedAt,
winners, winners,
paused: false paused: false
} }
}); });
if (claimed.count === 0) {
const current = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
if (!current) {
throw new GiveawayError('not_found');
}
throw new GiveawayError('already_ended');
}
const updated = await context.prisma.giveaway.findUniqueOrThrow({ where: { id: giveawayId } });
await updateGiveawayMessage(context, updated, locale); await updateGiveawayMessage(context, updated, locale);
if (winners.length > 0) { if (winners.length > 0) {
await dmWinners(context, updated, winners, locale); await dmWinners(context, updated, winners, locale);
} }
return updated;
}
/**
* Ends giveaways whose `endsAt` has passed but `endedAt` is still null
* (e.g. after a failed/locked BullMQ job). Skips paused giveaways.
*/
export async function recoverOverdueGiveaways(context: BotContext): Promise<number> {
const overdue = await context.prisma.giveaway.findMany({
where: {
endedAt: null,
paused: false,
endsAt: { lte: new Date() }
},
take: 100,
orderBy: { endsAt: 'asc' }
});
let recovered = 0;
for (const giveaway of overdue) {
try {
await cancelGiveawayJob(giveaway.id);
await endGiveaway(context, giveaway.id);
recovered += 1;
} catch (error) {
if (error instanceof GiveawayError && error.code === 'already_ended') {
continue;
}
logger.error({ error, giveawayId: giveaway.id }, 'Failed to recover overdue giveaway');
}
}
if (recovered > 0) {
logger.info({ recovered }, 'Recovered overdue giveaways');
}
return recovered;
} }
export async function rerollGiveaway( export async function rerollGiveaway(
@@ -371,22 +453,61 @@ export async function rerollGiveaway(
export async function pauseGiveaway( export async function pauseGiveaway(
context: BotContext, context: BotContext,
giveawayId: string, giveawayId: string,
locale: 'de' | 'en' locale: 'de' | 'en',
forcePaused?: boolean
): Promise<Giveaway> { ): 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 || giveaway.endedAt) {
throw new GiveawayError('not_found'); throw new GiveawayError('not_found');
} }
const nextPaused = forcePaused ?? !giveaway.paused;
if (nextPaused === giveaway.paused) {
await updateGiveawayMessage(context, giveaway, locale);
return giveaway;
}
if (nextPaused) {
// Stop the end timer while paused; remaining wall-clock is preserved via endsAt.
await cancelGiveawayJob(giveawayId);
}
const updated = await context.prisma.giveaway.update({ const updated = await context.prisma.giveaway.update({
where: { id: giveawayId }, where: { id: giveawayId },
data: { paused: !giveaway.paused } data: { paused: nextPaused }
}); });
if (!nextPaused) {
// Resume: re-schedule with delay based on remaining time until endsAt
// (delay 0 if endsAt already passed while paused → ends immediately).
await scheduleGiveawayEnd(updated.id, updated.endsAt);
}
await updateGiveawayMessage(context, updated, locale); await updateGiveawayMessage(context, updated, locale);
return updated; return updated;
} }
/**
* Dashboard entry points — same Discord side-effects as slash commands.
*/
export async function runGiveawayPauseJob(
context: BotContext,
giveawayId: string,
paused: boolean
): Promise<{ id: string; paused: boolean }> {
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
if (!giveaway) {
throw new GiveawayError('not_found');
}
const locale = await getGuildLocale(context.prisma, giveaway.guildId);
const updated = await pauseGiveaway(context, giveawayId, locale, paused);
return { id: updated.id, paused: updated.paused };
}
export async function runGiveawayDeleteJob(context: BotContext, giveawayId: string): Promise<void> {
await deleteGiveaway(context, giveawayId);
}
export async function deleteGiveaway(context: BotContext, giveawayId: string): Promise<void> { export async function deleteGiveaway(context: BotContext, giveawayId: string): Promise<void> {
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } }); const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
if (!giveaway) { if (!giveaway) {

View File

@@ -1,9 +1,10 @@
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared'; import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
import { SlashCommandBuilder } from 'discord.js'; import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
export const backupCommandData = applyCommandDescription( export const backupCommandData = applyCommandDescription(
new SlashCommandBuilder() new SlashCommandBuilder()
.setName('backup') .setName('backup')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addSubcommand((s) => .addSubcommand((s) =>
applyCommandDescription(s.setName('create'), 'guildbackup.create.description').addStringOption( applyCommandDescription(s.setName('create'), 'guildbackup.create.description').addStringOption(
(o) => applyOptionDescription(o.setName('name'), 'guildbackup.create.options.name') (o) => applyOptionDescription(o.setName('name'), 'guildbackup.create.options.name')

View File

@@ -55,17 +55,17 @@ const backupCommand: SlashCommand = {
return; return;
} }
await interaction.deferReply({ ephemeral: true });
try { try {
const backup = await createGuildBackup(context, guild, name, interaction.user.id); const backup = await createGuildBackup(context, guild, name, interaction.user.id);
await interaction.reply({ await interaction.editReply({
content: tf(locale, 'guildbackup.created', { id: backup.id, name: backup.name }), content: tf(locale, 'guildbackup.created', { id: backup.id, name: backup.name })
ephemeral: true
}); });
} catch (error) { } catch (error) {
if (error instanceof GuildBackupError) { if (error instanceof GuildBackupError) {
await interaction.reply({ await interaction.editReply({
content: t(locale, `guildbackup.error.${error.code}`), content: t(locale, `guildbackup.error.${error.code}`)
ephemeral: true
}); });
return; return;
} }
@@ -75,6 +75,9 @@ const backupCommand: SlashCommand = {
} }
if (sub === 'list') { if (sub === 'list') {
if (!(await ensureManageGuild(interaction, locale))) {
return;
}
const backups = await listGuildBackups(context, guild.id); const backups = await listGuildBackups(context, guild.id);
if (backups.length === 0) { if (backups.length === 0) {
await interaction.reply({ await interaction.reply({
@@ -100,6 +103,9 @@ const backupCommand: SlashCommand = {
} }
if (sub === 'info') { if (sub === 'info') {
if (!(await ensureManageGuild(interaction, locale))) {
return;
}
const backupId = interaction.options.getString('id', true); const backupId = interaction.options.getString('id', true);
const backup = await getGuildBackup(context, guild.id, backupId); const backup = await getGuildBackup(context, guild.id, backupId);
if (!backup) { if (!backup) {
@@ -154,7 +160,7 @@ const backupCommand: SlashCommand = {
return; return;
} }
await promptDeleteConfirmation(interaction, locale, backupId); await promptDeleteConfirmation(interaction, context, locale, backupId);
return; return;
} }
@@ -195,7 +201,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

@@ -10,6 +10,9 @@ import type { LogEventType } from '@nexumi/shared';
import { ensureGuild } from '../../guild.js'; import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.js'; import { logger } from '../../logger.js';
/** Audit-log entries can lag a moment behind the gateway event. */
const AUDIT_LOG_MAX_AGE_MS = 15_000;
export async function getLoggingConfig(prisma: BotContext['prisma'], guildId: string) { export async function getLoggingConfig(prisma: BotContext['prisma'], guildId: string) {
await ensureGuild(prisma, guildId); await ensureGuild(prisma, guildId);
let config = await prisma.loggingConfig.findUnique({ where: { guildId } }); let config = await prisma.loggingConfig.findUnique({ where: { guildId } });
@@ -19,6 +22,36 @@ export async function getLoggingConfig(prisma: BotContext['prisma'], guildId: st
return config; return config;
} }
/**
* Discord does not send ban/kick reasons over the gateway.
* Reasons must be read from the audit log (requires View Audit Log).
*/
async function fetchRecentAuditEntry(
guild: Guild,
type: AuditLogEvent,
targetId: string
): Promise<{ reason: string | null; executorId: string | null } | null> {
try {
const auditLogs = await guild.fetchAuditLogs({ type, limit: 5 });
const entry = auditLogs.entries.find((item) => {
if (item.targetId !== targetId) {
return false;
}
return Date.now() - item.createdTimestamp <= AUDIT_LOG_MAX_AGE_MS;
});
if (!entry) {
return null;
}
return {
reason: entry.reason ?? null,
executorId: entry.executorId ?? entry.executor?.id ?? null
};
} catch (error) {
logger.warn({ error, guildId: guild.id, type }, 'Failed to fetch audit log for logging');
return null;
}
}
export async function getLogChannelId( export async function getLogChannelId(
prisma: BotContext['prisma'], prisma: BotContext['prisma'],
guildId: string, guildId: string,
@@ -124,6 +157,45 @@ export async function logModAction(
await sendLogEmbed(context, guild, 'MOD_ACTION', 'Moderation Action', description, 0xef4444); await sendLogEmbed(context, guild, 'MOD_ACTION', 'Moderation Action', description, 0xef4444);
} }
export async function logVerificationFail(
context: BotContext,
input: {
guildId: string;
userId: string;
userTag?: string;
reason: string;
detail?: string;
failAction?: string;
matchedUserId?: string | null;
}
): Promise<void> {
const guild = await context.client.guilds.fetch(input.guildId).catch(() => null);
if (!guild) {
return;
}
const lines = [
`**User:** ${input.userTag ? `${input.userTag} ` : ''}(<@${input.userId}>) (\`${input.userId}\`)`,
`**Reason:** ${input.reason}`
];
if (input.detail) {
lines.push(`**Detail:** ${input.detail}`);
}
if (input.matchedUserId) {
lines.push(`**Matched user:** <@${input.matchedUserId}> (\`${input.matchedUserId}\`)`);
}
if (input.failAction) {
lines.push(`**Fail action:** ${input.failAction}`);
}
await sendLogEmbed(
context,
guild,
'VERIFICATION_FAIL',
'Verification Failed',
lines.join('\n'),
0xf97316
);
}
export async function handleChannelDeleteForAntiNuke( export async function handleChannelDeleteForAntiNuke(
context: BotContext, context: BotContext,
guild: Guild, guild: Guild,
@@ -253,6 +325,29 @@ export async function registerLoggingEvents(context: BotContext): Promise<void>
if (memberIgnored(config, member as GuildMember, member.user.bot)) { if (memberIgnored(config, member as GuildMember, member.user.bot)) {
return; return;
} }
const kickEntry = await fetchRecentAuditEntry(
member.guild,
AuditLogEvent.MemberKick,
member.id
);
if (kickEntry) {
await sendLogEmbed(
context,
member.guild,
'MEMBER_LEAVE',
'Member Kicked',
[
`**User:** ${member.user.tag} (<@${member.id}>)`,
`**Moderator:** ${kickEntry.executorId ? `<@${kickEntry.executorId}>` : '—'}`,
`**Reason:** ${kickEntry.reason ?? '—'}`
].join('\n'),
0xf59e0b
);
return;
}
await sendLogEmbed( await sendLogEmbed(
context, context,
member.guild, member.guild,
@@ -265,12 +360,19 @@ export async function registerLoggingEvents(context: BotContext): Promise<void>
client.on('guildBanAdd', async (ban) => { client.on('guildBanAdd', async (ban) => {
const guild = ban.guild; const guild = ban.guild;
await handleBanForAntiNuke(context, guild); await handleBanForAntiNuke(context, guild);
const audit = await fetchRecentAuditEntry(guild, AuditLogEvent.MemberBanAdd, ban.user.id);
const reason = audit?.reason ?? ban.reason ?? '—';
const executorLine = audit?.executorId
? `\n**Moderator:** <@${audit.executorId}>`
: '';
await sendLogEmbed( await sendLogEmbed(
context, context,
guild, guild,
'MEMBER_BAN', 'MEMBER_BAN',
'Member Banned', 'Member Banned',
`**User:** ${ban.user.tag} (<@${ban.user.id}>)\n**Reason:** ${ban.reason ?? '—'}` `**User:** ${ban.user.tag} (<@${ban.user.id}>)${executorLine}\n**Reason:** ${reason}`
); );
}); });

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 { createCase, scheduleTempBanExpire } from './service.js'; import { assertBannableForConfirm } from '../../hierarchy.js';
import { parseDuration } from './duration.js'; import { createCase, notifyModerationTargetDm, scheduleTempBanExpire } from './service.js';
import { tryParseDuration } from './duration.js';
type BanPayload = { type BanPayload = {
userId: string; userId: string;
@@ -36,8 +37,38 @@ 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 interaction.deferUpdate();
const targetUser = await context.client.users.fetch(payload.userId).catch(() => null);
if (targetUser) {
await notifyModerationTargetDm({
user: targetUser,
guildName: guild.name,
reason: payload.reason,
locale,
action: 'ban',
duration: payload.duration
});
}
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 +78,20 @@ 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.editReply({
content: t(locale, 'moderation.success'), content: tf(locale, 'moderation.success.caseWithReason', {
caseNumber: modCase.caseNumber,
reason: payload.reason
}),
components: [] components: []
}); });
} }
@@ -78,6 +108,8 @@ export async function executePurge(
return; return;
} }
await interaction.deferUpdate();
const channel = await guild.channels.fetch(payload.channelId); const channel = await guild.channels.fetch(payload.channelId);
let deletedCount = 0; let deletedCount = 0;
const regex = payload.regex ? new RegExp(payload.regex, 'i') : null; const regex = payload.regex ? new RegExp(payload.regex, 'i') : null;
@@ -97,7 +129,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,
@@ -121,8 +153,11 @@ export async function executePurge(
} }
}); });
await interaction.update({ await interaction.editReply({
content: t(locale, 'moderation.success'), content: tf(locale, 'moderation.purge.done', {
deletedCount,
caseNumber: modCase.caseNumber
}),
components: [] components: []
}); });
} }
@@ -143,7 +178,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 +191,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 +207,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) =>
@@ -83,9 +96,12 @@ export const warnCommandData = applyCommandDescription(
) )
) )
.addSubcommand((s) => .addSubcommand((s) =>
applyCommandDescription(s.setName('remove'), 'moderation.warn.remove.description').addStringOption( applyCommandDescription(s.setName('remove'), 'moderation.warn.remove.description').addIntegerOption(
(o) => (o) =>
applyOptionDescription(o.setName('warning_id').setRequired(true), 'moderation.warn.options.warning_id') applyOptionDescription(
o.setName('id').setRequired(true).setMinValue(1),
'moderation.warn.options.warning_id'
)
) )
) )
.addSubcommand((s) => .addSubcommand((s) =>
@@ -123,7 +139,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 +156,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 +204,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 +230,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 { applyWarnEscalation, createCase } from './service.js'; import { assertModerableMember } from '../../hierarchy.js';
import { parseDuration } from './duration.js'; import { applyWarnEscalation, createCase, createWarning, notifyModerationTargetDm } from './service.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 {
@@ -48,7 +48,26 @@ async function ensureMod(
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true }); await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return false; return false;
} }
return requirePermission(interaction, permission, locale); return requirePermission(interaction, permission, locale, { botPermissions: permission });
}
async function replySuccessCase(
interaction: ChatInputCommandInteraction,
locale: 'de' | 'en',
caseNumber: number,
reason?: string
): Promise<void> {
const content = reason
? tf(locale, 'moderation.success.caseWithReason', { caseNumber, reason })
: tf(locale, 'moderation.success.case', { caseNumber });
if (interaction.deferred || interaction.replied) {
await interaction.editReply({ content });
return;
}
await interaction.reply({
content,
ephemeral: true
});
} }
const banCommand: SlashCommand = { const banCommand: SlashCommand = {
@@ -59,7 +78,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 +112,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 +120,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, reason);
} }
}; };
@@ -97,9 +131,17 @@ 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 notifyModerationTargetDm({
user,
guildName: interaction.guild!.name,
reason,
locale,
action: 'kick'
});
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 +149,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, reason);
} }
}; };
@@ -117,11 +159,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 +182,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, reason);
} }
}; };
@@ -141,9 +193,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 +204,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, reason);
} }
}; };
@@ -166,7 +219,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'),
@@ -249,30 +314,27 @@ const warnCommand: SlashCommand = {
action: 'WARN_ADD', action: 'WARN_ADD',
reason reason
}); });
await context.prisma.user.upsert({ const { warning } = await createWarning(context, {
where: { id: user.id },
update: {},
create: { id: user.id }
});
const warning = await context.prisma.warning.create({
data: {
guildId: interaction.guildId!, guildId: interaction.guildId!,
userId: user.id, userId: user.id,
moderatorId: interaction.user.id, moderatorId: interaction.user.id,
reason, reason,
caseId: modCase.id caseId: modCase.id,
} guildName: interaction.guild!.name,
locale,
notifyUser: user
}); });
const escalationResult = await applyWarnEscalation( const escalationResult = await applyWarnEscalation(
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
? `${tf(locale, 'moderation.warning.created', { warningId: warning.id })}\n${escalationResult}` ? `${tf(locale, 'moderation.warning.created', { warningNumber: warning.warningNumber })}\n${escalationResult}`
: tf(locale, 'moderation.warning.created', { warningId: warning.id }), : tf(locale, 'moderation.warning.created', { warningNumber: warning.warningNumber }),
ephemeral: true ephemeral: true
}); });
return; return;
@@ -290,8 +352,8 @@ const warnCommand: SlashCommand = {
? t(locale, 'moderation.warning.none') ? t(locale, 'moderation.warning.none')
: warnings : warnings
.map( .map(
(w: { id: string; reason: string | null }) => (w: { warningNumber: number; reason: string | null }) =>
`- ${w.id}: ${w.reason ?? t(locale, 'moderation.warning.defaultReason')}` `- #${w.warningNumber}: ${w.reason ?? t(locale, 'moderation.warning.defaultReason')}`
) )
.join('\n'); .join('\n');
await interaction.reply({ content, ephemeral: true }); await interaction.reply({ content, ephemeral: true });
@@ -299,23 +361,35 @@ const warnCommand: SlashCommand = {
} }
if (sub === 'remove') { if (sub === 'remove') {
const warningId = interaction.options.getString('warning_id', true); const warningNumber = interaction.options.getInteger('id', true);
const warning = await context.prisma.warning.delete({ where: { id: warningId } }); const warning = await context.prisma.warning.findFirst({
await createCase(context, { where: { warningNumber, 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,
...auditFrom(interaction), ...auditFrom(interaction),
action: 'WARN_REMOVE', action: 'WARN_REMOVE',
reason: `Warning ${warningId} removed` reason: `Warning #${warningNumber} 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 +397,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 +426,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 +450,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 +458,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 +467,34 @@ 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) {
await interaction.deferReply({ ephemeral: true });
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 +503,34 @@ 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) {
await interaction.deferReply({ ephemeral: true });
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 +541,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 +555,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 +566,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 +579,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 +594,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 +643,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 +651,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

@@ -1,8 +1,15 @@
import {
PermissionFlagsBits,
type ChatInputCommandInteraction,
type User
} from 'discord.js';
import type { Prisma, Warning } from '@prisma/client';
import { buildCaseMetadata, type CaseAuditSource, t, tf, type Locale } from '@nexumi/shared';
import { logger } from '../../logger.js';
import { safeRemoveBullJob } from '../../lib/safe-remove-job.js';
import { moderationQueue } from '../../queues.js'; 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 { MAX_TIMEOUT_MS } from './duration.js';
import type { Prisma } from '@prisma/client';
import { buildCaseMetadata, type CaseAuditSource } from '@nexumi/shared';
type CreateCaseInput = { type CreateCaseInput = {
guildId: string; guildId: string;
@@ -15,6 +22,152 @@ type CreateCaseInput = {
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
}; };
type CreateWarningInput = {
guildId: string;
userId: string;
moderatorId: string;
reason?: string;
caseId?: string;
guildName: string;
locale: Locale;
/** Discord user to DM; when omitted the user is fetched by id. */
notifyUser?: User;
};
export type CreatedWarning = {
warning: Warning;
warningCount: number;
};
async function notifyWarnedUser(params: {
user: User;
guildName: string;
reason: string;
warningCount: number;
warningNumber: number;
locale: Locale;
}): Promise<void> {
try {
await params.user.send(
tf(params.locale, 'moderation.warning.dm', {
guild: params.guildName,
reason: params.reason,
count: params.warningCount,
warningNumber: params.warningNumber
})
);
} catch (error) {
logger.warn(
{ error, userId: params.user.id, warningNumber: params.warningNumber },
'Failed to DM warned user'
);
}
}
export async function notifyModerationTargetDm(params: {
user: User;
guildName: string;
reason: string;
locale: Locale;
action: 'ban' | 'kick';
duration?: string | null;
}): Promise<void> {
const reason = params.reason || t(params.locale, 'moderation.reason.none');
const key =
params.action === 'kick'
? 'moderation.kick.dm'
: params.duration
? 'moderation.ban.dm.temporary'
: 'moderation.ban.dm';
try {
await params.user.send(
tf(params.locale, key, {
guild: params.guildName,
reason,
...(params.duration ? { duration: params.duration } : {})
})
);
} catch (error) {
logger.warn(
{ error, userId: params.user.id, action: params.action },
'Failed to DM moderation target'
);
}
}
export async function createWarning(
context: BotContext,
input: CreateWarningInput
): Promise<CreatedWarning> {
await context.prisma.user.upsert({
where: { id: input.userId },
update: {},
create: { id: input.userId }
});
let warning: Warning | null = null;
for (let attempt = 0; attempt < 5; attempt++) {
try {
warning = await context.prisma.$transaction(
async (tx) => {
const last = await tx.warning.findFirst({
where: { guildId: input.guildId },
orderBy: { warningNumber: 'desc' },
select: { warningNumber: true }
});
return tx.warning.create({
data: {
guildId: input.guildId,
warningNumber: (last?.warningNumber ?? 0) + 1,
userId: input.userId,
moderatorId: input.moderatorId,
reason: input.reason,
caseId: input.caseId
}
});
},
{ 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 (!warning) {
throw new Error('Failed to create warning');
}
const warningCount = await context.prisma.warning.count({
where: { guildId: input.guildId, userId: input.userId }
});
const reason = input.reason ?? t(input.locale, 'moderation.warning.defaultReason');
const notifyUser =
input.notifyUser ?? (await context.client.users.fetch(input.userId).catch(() => null));
if (notifyUser) {
await notifyWarnedUser({
user: notifyUser,
guildName: input.guildName,
reason,
warningCount,
warningNumber: warning.warningNumber,
locale: input.locale
});
}
return { warning, warningCount };
}
export async function createCase(context: BotContext, input: CreateCaseInput) { export async function createCase(context: BotContext, input: CreateCaseInput) {
await context.prisma.guild.upsert({ await context.prisma.guild.upsert({
where: { id: input.guildId }, where: { id: input.guildId },
@@ -22,18 +175,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 +201,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 +234,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 +246,15 @@ export async function scheduleTempBanExpire(
durationMs: number, durationMs: number,
reason?: string reason?: string
) { ) {
const jobId = tempBanJobId(guildId, userId);
const existing = await moderationQueue.getJob(jobId);
await safeRemoveBullJob(existing, { label: 'tempBanExpire', jobId });
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 +266,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 +287,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,15 +314,28 @@ 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 notifyModerationTargetDm({
user: member.user,
guildName: interaction.guild.name,
reason,
locale,
action: 'kick'
});
await member.kick(reason); await member.kick(reason);
await createCase(context, { await createCase(context, {
guildId, guildId,
@@ -136,12 +347,22 @@ 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 notifyModerationTargetDm({
user: member.user,
guildName: interaction.guild.name,
reason,
locale,
action: 'ban'
});
await interaction.guild.members.ban(userId, { reason }); await interaction.guild.members.ban(userId, { reason });
await createCase(context, { await createCase(context, {
guildId, guildId,
@@ -153,5 +374,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,9 +1,10 @@
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared'; import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
import { SlashCommandBuilder } from 'discord.js'; import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
export const scheduleCommandData = applyCommandDescription( export const scheduleCommandData = applyCommandDescription(
new SlashCommandBuilder() new SlashCommandBuilder()
.setName('schedule') .setName('schedule')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addSubcommand((sub) => .addSubcommand((sub) =>
applyCommandDescription(sub.setName('create'), 'schedule.create.description') applyCommandDescription(sub.setName('create'), 'schedule.create.description')
.addChannelOption((o) => .addChannelOption((o) =>
@@ -51,6 +52,7 @@ export const scheduleCommandData = applyCommandDescription(
export const announceCommandData = applyCommandDescription( export const announceCommandData = applyCommandDescription(
new SlashCommandBuilder() new SlashCommandBuilder()
.setName('announce') .setName('announce')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addChannelOption((o) => .addChannelOption((o) =>
applyOptionDescription(o.setName('channel').setRequired(true), 'schedule.common.options.channel') applyOptionDescription(o.setName('channel').setRequired(true), 'schedule.common.options.channel')
) )

View File

@@ -1,13 +1,23 @@
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 {
DEFAULT_GUILD_TIMEZONE,
WelcomeEmbedSchema,
expandBracketChannelMentions,
parseMessageComponentsV2,
resolveScheduleRunAt,
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 { safeRemoveBullJob } from '../../lib/safe-remove-job.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';
@@ -35,7 +45,7 @@ export type ScheduleCreateInput = AnnouncementInput & {
cron?: string | null; cron?: string | null;
}; };
function parseScheduleWhen(input: string): Date { function parseScheduleWhen(input: string, timeZone: string): Date {
const trimmed = input.trim(); const trimmed = input.trim();
if (/^\d+[smhd]$/i.test(trimmed)) { if (/^\d+[smhd]$/i.test(trimmed)) {
const ms = parseDuration(trimmed); const ms = parseDuration(trimmed);
@@ -45,11 +55,79 @@ function parseScheduleWhen(input: string): Date {
return new Date(Date.now() + ms); return new Date(Date.now() + ms);
} }
const parsed = Date.parse(trimmed); try {
if (Number.isNaN(parsed) || parsed <= Date.now()) { const parsed = resolveScheduleRunAt(trimmed, timeZone);
if (parsed.getTime() <= Date.now()) {
throw new SchedulerError('invalid_when'); throw new SchedulerError('invalid_when');
} }
return new Date(parsed); return parsed;
} catch {
throw new SchedulerError('invalid_when');
}
}
async function getGuildTimezone(prisma: BotContext['prisma'], guildId: string): Promise<string> {
const settings = await prisma.guildSettings.findUnique({
where: { guildId },
select: { timezone: true }
});
return settings?.timezone || DEFAULT_GUILD_TIMEZONE;
}
export function scheduleJobId(scheduleId: string): string {
return `schedule-${scheduleId}`;
}
/** BullMQ delayed instance ids look like `repeat:{schedulerId}:{millis}`. */
const REPEAT_INSTANCE_ID_RE = /^repeat:(.+):(\d+)$/;
function collectSchedulerIds(scheduleId: string, jobId: string | null | undefined): string[] {
const ids = new Set<string>();
ids.add(scheduleJobId(scheduleId));
if (!jobId) {
return [...ids];
}
const repeatMatch = jobId.match(REPEAT_INSTANCE_ID_RE);
if (repeatMatch?.[1]) {
ids.add(repeatMatch[1]);
} else if (!jobId.startsWith('repeat:')) {
ids.add(jobId);
}
return [...ids];
}
async function removeScheduleJobSchedulers(
queue: typeof scheduleQueue,
scheduleId: string,
jobId: string | null | undefined
): Promise<void> {
for (const schedulerId of collectSchedulerIds(scheduleId, jobId)) {
try {
await queue.removeJobScheduler(schedulerId);
} catch (error) {
logger.warn({ schedulerId, error }, 'Failed to remove schedule job scheduler');
}
}
// Legacy / hash-keyed schedulers: match by payload scheduleId.
try {
const schedulers = await queue.getJobSchedulers();
for (const scheduler of schedulers) {
const data = scheduler.template?.data as { scheduleId?: string } | undefined;
if (data?.scheduleId !== scheduleId) {
continue;
}
const id = scheduler.id ?? scheduler.key;
try {
await queue.removeJobScheduler(id);
} catch (error) {
logger.warn({ schedulerId: id, error }, 'Failed to remove matched schedule job scheduler');
}
}
} catch (error) {
logger.warn({ scheduleId, error }, 'Failed to scan job schedulers for schedule');
}
} }
function isValidCron(pattern: string): boolean { function isValidCron(pattern: string): boolean {
@@ -91,23 +169,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 +201,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] };
@@ -148,38 +234,85 @@ async function assertSendableChannel(
async function enqueueScheduleJob( async function enqueueScheduleJob(
schedule: ScheduledMessage, schedule: ScheduledMessage,
cron: string | null, cron: string | null,
runAt: Date | null runAt: Date | null,
timeZone: string
): Promise<string> { ): Promise<string> {
const jobOptions: { const jobId = scheduleJobId(schedule.id);
jobId: string;
removeOnComplete: boolean;
removeOnFail: number;
delay?: number;
repeat?: { pattern: string };
} = {
jobId: `schedule-${schedule.id}`,
removeOnComplete: true,
removeOnFail: 50
};
if (cron) { if (cron) {
jobOptions.repeat = { pattern: cron }; // BullMQ 5 job schedulers honor guild timezone for cron patterns.
} else if (runAt) { await scheduleQueue.upsertJobScheduler(
jobOptions.delay = Math.max(0, runAt.getTime() - Date.now()); jobId,
{ pattern: cron, tz: timeZone },
{
name: 'scheduleSend',
data: { scheduleId: schedule.id },
opts: {
removeOnComplete: true,
removeOnFail: 50
}
}
);
return jobId;
} }
const job = await scheduleQueue.add('scheduleSend', { scheduleId: schedule.id }, jobOptions); if (!runAt) {
throw new SchedulerError('missing_schedule_time');
}
const job = await scheduleQueue.add(
'scheduleSend',
{ scheduleId: schedule.id },
{
jobId,
removeOnComplete: true,
removeOnFail: 50,
delay: Math.max(0, runAt.getTime() - Date.now())
}
);
return String(job.id); return String(job.id);
} }
export async function removeScheduleJob(jobId: string | null): Promise<void> { /**
if (!jobId) { * Removes a one-shot delayed job and/or cron job scheduler. Locked (active)
return; * jobs cannot be removed — do not call this from inside the scheduleSend
* worker for the job that is currently running.
*/
export async function removeScheduleJob(
jobId: string | null,
options?: { scheduleId?: string; cron?: string | null }
): Promise<void> {
if (options?.scheduleId) {
await removeScheduleJobSchedulers(scheduleQueue, options.scheduleId, jobId);
} else if (options?.cron && jobId) {
// Best-effort for callers without scheduleId: extract scheduler from repeat instance id.
for (const schedulerId of collectSchedulerIds('unknown', jobId)) {
if (schedulerId === 'schedule-unknown') {
continue;
}
try {
await scheduleQueue.removeJobScheduler(schedulerId);
} catch (error) {
logger.warn({ schedulerId, error }, 'Failed to remove schedule job scheduler');
}
}
} }
const job = await scheduleQueue.getJob(jobId); // One-shot delayed jobs only — never job.remove() on repeat:* scheduler children.
if (job) { const oneShotIds = new Set<string>();
await job.remove(); if (jobId && !jobId.startsWith('repeat:')) {
oneShotIds.add(jobId);
}
if (options?.scheduleId) {
oneShotIds.add(scheduleJobId(options.scheduleId));
}
for (const id of oneShotIds) {
const job = await scheduleQueue.getJob(id);
if (job?.id && String(job.id).startsWith('repeat:')) {
continue;
}
await safeRemoveBullJob(job, { label: 'schedule', jobId: id });
} }
} }
@@ -211,6 +344,7 @@ export async function createScheduledMessage(
let runAt: Date | null = null; let runAt: Date | null = null;
let cronPattern: string | null = null; let cronPattern: string | null = null;
const timeZone = await getGuildTimezone(context.prisma, input.guildId);
if (cron) { if (cron) {
if (!isValidCron(cron)) { if (!isValidCron(cron)) {
@@ -218,7 +352,7 @@ export async function createScheduledMessage(
} }
cronPattern = cron; cronPattern = cron;
} else if (whenInput) { } else if (whenInput) {
runAt = parseScheduleWhen(whenInput); runAt = parseScheduleWhen(whenInput, timeZone);
} }
await assertSendableChannel(context, input.channelId); await assertSendableChannel(context, input.channelId);
@@ -238,7 +372,7 @@ export async function createScheduledMessage(
} }
}); });
const jobId = await enqueueScheduleJob(schedule, cronPattern, runAt); const jobId = await enqueueScheduleJob(schedule, cronPattern, runAt, timeZone);
return context.prisma.scheduledMessage.update({ return context.prisma.scheduledMessage.update({
where: { id: schedule.id }, where: { id: schedule.id },
data: { jobId } data: { jobId }
@@ -264,7 +398,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
@@ -294,7 +432,7 @@ export async function deleteScheduledMessage(
return false; return false;
} }
await removeScheduleJob(match.jobId); await removeScheduleJob(match.jobId, { scheduleId: match.id, cron: match.cron });
await context.prisma.scheduledMessage.delete({ where: { id: match.id } }); await context.prisma.scheduledMessage.delete({ where: { id: match.id } });
return true; return true;
} }
@@ -322,7 +460,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);
@@ -337,8 +475,10 @@ export async function sendScheduledMessage(context: BotContext, scheduleId: stri
throw error; throw error;
} }
// One-shot: delete the DB row only. Do not remove the active BullMQ job
// from inside its own worker (locked remove throws → retry → duplicate send).
// removeOnComplete on enqueue cleans the job up after the processor returns.
if (!schedule.cron) { if (!schedule.cron) {
await removeScheduleJob(schedule.jobId);
await context.prisma.scheduledMessage.delete({ where: { id: scheduleId } }); await context.prisma.scheduledMessage.delete({ where: { id: scheduleId } });
} }
} }

View File

@@ -3,11 +3,12 @@ import {
applyOptionDescription, applyOptionDescription,
localizedChoice localizedChoice
} from '@nexumi/shared'; } from '@nexumi/shared';
import { ChannelType, SlashCommandBuilder } from 'discord.js'; import { ChannelType, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
export const selfrolesCommandData = applyCommandDescription( export const selfrolesCommandData = applyCommandDescription(
new SlashCommandBuilder() new SlashCommandBuilder()
.setName('selfroles') .setName('selfroles')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addSubcommandGroup((group) => .addSubcommandGroup((group) =>
applyCommandDescription(group.setName('panel'), 'selfroles.panel.description') applyCommandDescription(group.setName('panel'), 'selfroles.panel.description')
.addSubcommand((sub) => .addSubcommand((sub) =>

View File

@@ -4,5 +4,9 @@ export { registerSelfRoleEvents } from './reactions.js';
export { export {
expireSelfRole, expireSelfRole,
scheduleSelfRoleExpiry, scheduleSelfRoleExpiry,
cancelSelfRoleExpiry cancelSelfRoleExpiry,
runSelfRolePanelCreateJob,
runSelfRolePanelUpdateJob,
runSelfRolePanelDeleteJob,
SelfRoleError
} from './service.js'; } from './service.js';

View File

@@ -21,7 +21,9 @@ import {
} from '@nexumi/shared'; } from '@nexumi/shared';
import type { SelfRolePanel } from '@prisma/client'; import type { SelfRolePanel } from '@prisma/client';
import { ensureGuild } from '../../guild.js'; import { ensureGuild } from '../../guild.js';
import { getGuildLocale } from '../../i18n.js';
import { logger } from '../../logger.js'; import { logger } from '../../logger.js';
import { safeRemoveBullJob } from '../../lib/safe-remove-job.js';
import { ticketQueue } from '../../queues.js'; import { ticketQueue } from '../../queues.js';
import type { BotContext } from '../../types.js'; import type { BotContext } from '../../types.js';
@@ -266,13 +268,17 @@ export async function scheduleSelfRoleExpiry(
roleId: string, roleId: string,
expiresAt: Date expiresAt: Date
): Promise<void> { ): Promise<void> {
const jobId = `selfrole-expire-${guildId}-${userId}-${roleId}`;
const existing = await ticketQueue.getJob(jobId);
await safeRemoveBullJob(existing, { label: 'selfRoleExpire', jobId });
const delay = Math.max(0, expiresAt.getTime() - Date.now()); const delay = Math.max(0, expiresAt.getTime() - Date.now());
await ticketQueue.add( await ticketQueue.add(
'selfRoleExpire', 'selfRoleExpire',
{ guildId, userId, roleId }, { guildId, userId, roleId },
{ {
delay, delay,
jobId: `selfrole-expire-${guildId}-${userId}-${roleId}`, jobId,
removeOnComplete: true, removeOnComplete: true,
removeOnFail: 50 removeOnFail: 50
} }
@@ -284,8 +290,9 @@ export async function cancelSelfRoleExpiry(
userId: string, userId: string,
roleId: string roleId: string
): Promise<void> { ): Promise<void> {
const job = await ticketQueue.getJob(`selfrole-expire-${guildId}-${userId}-${roleId}`); const jobId = `selfrole-expire-${guildId}-${userId}-${roleId}`;
await job?.remove(); const job = await ticketQueue.getJob(jobId);
await safeRemoveBullJob(job, { label: 'selfRoleExpire', jobId });
} }
export async function expireSelfRole( export async function expireSelfRole(
@@ -416,6 +423,69 @@ export async function findPanelByMessageId(
}); });
} }
async function postOrEditPanelMessage(
context: BotContext,
panel: SelfRolePanel,
locale: 'de' | 'en',
previous?: { channelId: string; messageId: string | null }
): Promise<SelfRolePanel> {
const mode = SelfRoleModeSchema.parse(panel.mode);
const { entries } = parseRolesJson(panel.roles);
const channelChanged =
previous !== undefined && previous.channelId !== panel.channelId;
if (panel.messageId && !channelChanged) {
try {
const channel = (await context.client.channels.fetch(panel.channelId)) as TextChannel;
const message = await channel.messages.fetch(panel.messageId);
await message.edit({
embeds: [buildPanelEmbed(panel, locale)],
components: buildPanelComponents(panel)
});
if (mode === 'REACTIONS') {
for (const reaction of message.reactions.cache.values()) {
await reaction.remove().catch(() => undefined);
}
await applyReactionEmojis(message, entries);
}
return panel;
} catch (error) {
logger.warn({ error, panelId: panel.id }, 'Failed to edit self-role panel message; recreating');
}
}
if (previous?.messageId && channelChanged) {
try {
const oldChannel = (await context.client.channels.fetch(previous.channelId)) as TextChannel;
const oldMessage = await oldChannel.messages.fetch(previous.messageId);
await oldMessage.delete();
} catch {
// Previous message may already be gone
}
}
const channel = await context.client.channels.fetch(panel.channelId);
if (!channel || !channel.isTextBased() || channel.isDMBased()) {
throw new SelfRoleError('invalid_channel');
}
const message = await channel.send({
embeds: [buildPanelEmbed(panel, locale)],
components: buildPanelComponents(panel)
});
if (mode === 'REACTIONS') {
await applyReactionEmojis(message, entries);
}
return context.prisma.selfRolePanel.update({
where: { id: panel.id },
data: { messageId: message.id }
});
}
export async function createSelfRolePanel( export async function createSelfRolePanel(
context: BotContext, context: BotContext,
params: { params: {
@@ -427,6 +497,7 @@ export async function createSelfRolePanel(
behavior: SelfRoleBehavior; behavior: SelfRoleBehavior;
roles: SelfRoleEntry[]; roles: SelfRoleEntry[];
temporaryDurationMs?: number; temporaryDurationMs?: number;
expiresAt?: Date | null;
}, },
locale: 'de' | 'en' locale: 'de' | 'en'
): Promise<SelfRolePanel> { ): Promise<SelfRolePanel> {
@@ -451,24 +522,12 @@ export async function createSelfRolePanel(
description: params.description ?? null, description: params.description ?? null,
mode: params.mode, mode: params.mode,
behavior: params.behavior, behavior: params.behavior,
roles: serializeRolesJson(params.roles, params.temporaryDurationMs) roles: serializeRolesJson(params.roles, params.temporaryDurationMs),
expiresAt: params.expiresAt ?? null
} }
}); });
const channel = (await context.client.channels.fetch(params.channelId)) as TextChannel; return postOrEditPanelMessage(context, panel, locale);
const message = await channel.send({
embeds: [buildPanelEmbed(panel, locale)],
components: buildPanelComponents(panel)
});
if (params.mode === 'REACTIONS') {
await applyReactionEmojis(message, params.roles);
}
return context.prisma.selfRolePanel.update({
where: { id: panel.id },
data: { messageId: message.id }
});
} }
export async function updateSelfRolePanel( export async function updateSelfRolePanel(
@@ -476,12 +535,14 @@ export async function updateSelfRolePanel(
panelId: string, panelId: string,
guildId: string, guildId: string,
updates: { updates: {
channelId?: string;
title?: string; title?: string;
description?: string | null; description?: string | null;
mode?: SelfRoleMode; mode?: SelfRoleMode;
behavior?: SelfRoleBehavior; behavior?: SelfRoleBehavior;
roles?: SelfRoleEntry[]; roles?: SelfRoleEntry[];
temporaryDurationMs?: number; temporaryDurationMs?: number;
expiresAt?: Date | null;
}, },
locale: 'de' | 'en' locale: 'de' | 'en'
): Promise<SelfRolePanel> { ): Promise<SelfRolePanel> {
@@ -507,39 +568,100 @@ export async function updateSelfRolePanel(
throw new SelfRoleError('reaction_emoji_required'); throw new SelfRoleError('reaction_emoji_required');
} }
const previous = { channelId: existing.channelId, messageId: existing.messageId };
const panel = await context.prisma.selfRolePanel.update({ const panel = await context.prisma.selfRolePanel.update({
where: { id: panelId }, where: { id: panelId },
data: { data: {
channelId: updates.channelId ?? existing.channelId,
title: updates.title ?? existing.title, title: updates.title ?? existing.title,
description: updates.description !== undefined ? updates.description : existing.description, description: updates.description !== undefined ? updates.description : existing.description,
mode, mode,
behavior, behavior,
roles: serializeRolesJson(roles, temporaryDurationMs) roles: serializeRolesJson(roles, temporaryDurationMs),
...(updates.expiresAt !== undefined ? { expiresAt: updates.expiresAt } : {})
} }
}); });
if (panel.messageId) { return postOrEditPanelMessage(context, panel, locale, previous);
try { }
const channel = (await context.client.channels.fetch(panel.channelId)) as TextChannel;
const message = await channel.messages.fetch(panel.messageId);
await message.edit({
embeds: [buildPanelEmbed(panel, locale)],
components: buildPanelComponents(panel)
});
if (mode === 'REACTIONS') { /**
const existingReactions = message.reactions.cache; * BullMQ entry points for dashboard create/update/delete. The WebUI has no
for (const reaction of existingReactions.values()) { * discord.js Client, so panel posting is delegated here (same pattern as
await reaction.remove().catch(() => undefined); * giveaways / dashboard messages).
} */
await applyReactionEmojis(message, roles); export async function runSelfRolePanelCreateJob(
} context: BotContext,
} catch (error) { params: {
logger.warn({ error, panelId }, 'Failed to update self-role panel message'); guildId: string;
} channelId: string;
title: string;
description?: string | null;
mode: SelfRoleMode;
behavior: SelfRoleBehavior;
roles: SelfRoleEntry[];
temporaryDurationMs?: number;
expiresAt?: string | null;
} }
): Promise<{ id: string }> {
const locale = await getGuildLocale(context.prisma, params.guildId);
const panel = await createSelfRolePanel(
context,
{
...params,
expiresAt: params.expiresAt ? new Date(params.expiresAt) : null
},
locale
);
return { id: panel.id };
}
return panel; export async function runSelfRolePanelUpdateJob(
context: BotContext,
params: {
panelId: string;
guildId: string;
channelId?: string;
title?: string;
description?: string | null;
mode?: SelfRoleMode;
behavior?: SelfRoleBehavior;
roles?: SelfRoleEntry[];
temporaryDurationMs?: number;
expiresAt?: string | null;
}
): Promise<{ id: string }> {
const locale = await getGuildLocale(context.prisma, params.guildId);
const panel = await updateSelfRolePanel(
context,
params.panelId,
params.guildId,
{
channelId: params.channelId,
title: params.title,
description: params.description,
mode: params.mode,
behavior: params.behavior,
roles: params.roles,
temporaryDurationMs: params.temporaryDurationMs,
expiresAt:
params.expiresAt === undefined
? undefined
: params.expiresAt
? new Date(params.expiresAt)
: null
},
locale
);
return { id: panel.id };
}
export async function runSelfRolePanelDeleteJob(
context: BotContext,
panelId: string,
guildId: string
): Promise<void> {
await deleteSelfRolePanel(context, panelId, guildId);
} }
export async function deleteSelfRolePanel( export async function deleteSelfRolePanel(

View File

@@ -1,15 +1,19 @@
import { ChannelType } from 'discord.js'; import { ChannelType, PermissionFlagsBits } from 'discord.js';
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared'; import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
import { SlashCommandBuilder } from 'discord.js'; import { SlashCommandBuilder } from 'discord.js';
export const starboardCommandData = applyCommandDescription( export const starboardCommandData = applyCommandDescription(
new SlashCommandBuilder() new SlashCommandBuilder()
.setName('starboard') .setName('starboard')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addSubcommand((s) => .addSubcommand((s) =>
applyCommandDescription(s.setName('setup'), 'starboard.setup.description') applyCommandDescription(s.setName('setup'), 'starboard.setup.description')
.addChannelOption((o) => .addChannelOption((o) =>
applyOptionDescription( applyOptionDescription(
o.setName('channel').addChannelTypes(ChannelType.GuildText).setRequired(true), o
.setName('channel')
.addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement)
.setRequired(true),
'starboard.setup.options.channel' 'starboard.setup.options.channel'
) )
) )
@@ -28,6 +32,9 @@ export const starboardCommandData = applyCommandDescription(
'starboard.setup.options.allow_self_star' 'starboard.setup.options.allow_self_star'
) )
) )
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('status'), 'starboard.status.description')
), ),
'starboard.description' 'starboard.description'
); );

View File

@@ -1,17 +1,18 @@
import { PermissionFlagsBits } from 'discord.js'; import { PermissionFlagsBits } from 'discord.js';
import { t } from '@nexumi/shared'; import { 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';
import { requirePermission } from '../../permissions.js'; import { requirePermission } from '../../permissions.js';
import { ephemeral } from '../../interaction-reply.js';
import { starboardCommandData } from './command-definitions.js'; import { starboardCommandData } from './command-definitions.js';
import { getStarboardConfig, updateStarboardConfig } from './service.js'; import { botCanPostStarboard, getStarboardConfig, updateStarboardConfig } from './service.js';
const starboardCommand: SlashCommand = { const starboardCommand: SlashCommand = {
data: starboardCommandData, data: starboardCommandData,
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))) {
@@ -22,11 +23,21 @@ const starboardCommand: SlashCommand = {
const sub = interaction.options.getSubcommand(); const sub = interaction.options.getSubcommand();
if (sub === 'setup') { if (sub === 'setup') {
const channel = interaction.options.getChannel('channel', true); const channelOption = interaction.options.getChannel('channel', true);
const threshold = interaction.options.getInteger('threshold', true); const threshold = interaction.options.getInteger('threshold', true);
const emoji = interaction.options.getString('emoji') ?? '⭐'; const emoji = interaction.options.getString('emoji') ?? '⭐';
const allowSelfStar = interaction.options.getBoolean('allow_self_star') ?? false; const allowSelfStar = interaction.options.getBoolean('allow_self_star') ?? false;
const channel = await interaction.guild!.channels.fetch(channelOption.id).catch(() => null);
const me = interaction.guild!.members.me;
if (!channel || !me || !botCanPostStarboard(channel, me.id)) {
await interaction.reply({
content: t(locale, 'starboard.invalidChannel'),
...ephemeral()
});
return;
}
await updateStarboardConfig(context.prisma, guildId, { await updateStarboardConfig(context.prisma, guildId, {
enabled: true, enabled: true,
channelId: channel.id, channelId: channel.id,
@@ -35,13 +46,30 @@ const starboardCommand: SlashCommand = {
allowSelfStar allowSelfStar
}); });
await interaction.reply({ content: t(locale, 'starboard.setupDone'), ephemeral: true }); await interaction.reply({ content: t(locale, 'starboard.setupDone'), ...ephemeral() });
return; return;
} }
if (sub === 'status') {
const config = await getStarboardConfig(context.prisma, guildId); const config = await getStarboardConfig(context.prisma, guildId);
if (!config.enabled || !config.channelId) { if (!config.enabled || !config.channelId) {
await interaction.reply({ content: t(locale, 'starboard.notConfigured'), ephemeral: true }); await interaction.reply({
content: t(locale, 'starboard.notConfigured'),
...ephemeral()
});
return;
}
await interaction.reply({
content: tf(locale, 'starboard.statusLine', {
channel: `<#${config.channelId}>`,
emoji: config.emoji,
threshold: String(config.threshold),
selfStar: config.allowSelfStar
? t(locale, 'starboard.selfStarOn')
: t(locale, 'starboard.selfStarOff')
}),
...ephemeral()
});
} }
} }
}; };

View File

@@ -1,6 +1,11 @@
import type { Message, PartialMessage } from 'discord.js';
import type { BotContext } from '../../types.js'; import type { BotContext } from '../../types.js';
import { logger } from '../../logger.js'; import { logger } from '../../logger.js';
import { processStarboardReaction } from './service.js'; import {
processStarboardReaction,
processStarboardSourceDelete,
processStarboardSourceUpdate
} from './service.js';
export function registerStarboardEvents(context: BotContext): void { export function registerStarboardEvents(context: BotContext): void {
const handleReaction = async ( const handleReaction = async (
@@ -26,4 +31,48 @@ export function registerStarboardEvents(context: BotContext): void {
} }
await handleReaction(_reaction); await handleReaction(_reaction);
}); });
context.client.on('messageReactionRemoveAll', async (message) => {
try {
if (message.partial) {
await message.fetch().catch(() => null);
}
if (!message.guild || !('author' in message) || !message.author) {
return;
}
await processStarboardSourceUpdate(context, message as Message | PartialMessage);
} catch (error) {
logger.warn({ error }, 'Starboard remove-all handler failed');
}
});
context.client.on('messageReactionRemoveEmoji', async (reaction) => {
await handleReaction(reaction);
});
context.client.on('messageDelete', async (message) => {
try {
await processStarboardSourceDelete(context, message);
} catch (error) {
logger.warn({ error }, 'Starboard source-delete handler failed');
}
});
context.client.on('messageDeleteBulk', async (messages) => {
for (const message of messages.values()) {
try {
await processStarboardSourceDelete(context, message);
} catch (error) {
logger.warn({ error }, 'Starboard bulk-delete handler failed');
}
}
});
context.client.on('messageUpdate', async (_oldMessage, newMessage) => {
try {
await processStarboardSourceUpdate(context, newMessage);
} catch (error) {
logger.warn({ error }, 'Starboard source-update handler failed');
}
});
} }

View File

@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import { emojiMatches, shouldIgnoreMessage } from './service.js';
function reactionEmoji(partial: { id?: string | null; name?: string | null }) {
return {
id: partial.id ?? null,
name: partial.name ?? null,
identifier: partial.id ? `${partial.name}:${partial.id}` : (partial.name ?? ''),
toString() {
if (partial.id) {
return `<:${partial.name}:${partial.id}>`;
}
return partial.name ?? '';
}
} as unknown as import('discord.js').Emoji;
}
describe('starboard emojiMatches', () => {
it('matches unicode emoji', () => {
expect(emojiMatches('⭐', reactionEmoji({ name: '⭐' }))).toBe(true);
});
it('matches custom emoji forms', () => {
const emoji = reactionEmoji({ id: '123', name: 'star' });
expect(emojiMatches('<:star:123>', emoji)).toBe(true);
expect(emojiMatches('star:123', emoji)).toBe(true);
});
});
describe('starboard shouldIgnoreMessage', () => {
const baseConfig = {
id: 'cfg',
guildId: 'g1',
enabled: true,
channelId: 'star-channel',
emoji: '⭐',
threshold: 3,
allowSelfStar: false,
ignoredChannelIds: ['ignored'],
createdAt: new Date(),
updatedAt: new Date()
};
it('ignores bot authors', () => {
const message = {
author: { bot: true },
guild: { id: 'g1' },
channel: {
id: 'c1',
isTextBased: () => true,
isDMBased: () => false,
nsfw: false
}
} as unknown as import('discord.js').Message;
expect(shouldIgnoreMessage(message, baseConfig)).toBe(true);
});
it('ignores the starboard channel itself', () => {
const message = {
author: { bot: false, id: 'u1' },
guild: { id: 'g1' },
channel: {
id: 'star-channel',
isTextBased: () => true,
isDMBased: () => false,
nsfw: false
}
} as unknown as import('discord.js').Message;
expect(shouldIgnoreMessage(message, baseConfig)).toBe(true);
});
it('ignores listed channels', () => {
const message = {
author: { bot: false, id: 'u1' },
guild: { id: 'g1' },
channel: {
id: 'ignored',
isTextBased: () => true,
isDMBased: () => false,
nsfw: false
}
} as unknown as import('discord.js').Message;
expect(shouldIgnoreMessage(message, baseConfig)).toBe(true);
});
});

View File

@@ -1,14 +1,17 @@
import { import {
ChannelType,
EmbedBuilder, EmbedBuilder,
PermissionFlagsBits,
type Emoji, type Emoji,
type GuildBasedChannel,
type Message, type Message,
type PartialMessage,
type PartialMessageReaction, type PartialMessageReaction,
type MessageReaction, type MessageReaction,
type TextChannel type TextChannel
} from 'discord.js'; } from 'discord.js';
import type { PrismaClient, StarboardConfig } from '@prisma/client'; import type { PrismaClient, StarboardConfig } from '@prisma/client';
import { t } from '@nexumi/shared'; import { t, type Locale } from '@nexumi/shared';
import type { Locale } from '@nexumi/shared';
import { ensureGuild } from '../../guild.js'; import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.js'; import { logger } from '../../logger.js';
import type { BotContext } from '../../types.js'; import type { BotContext } from '../../types.js';
@@ -73,6 +76,27 @@ export function shouldIgnoreMessage(message: Message, config: StarboardConfig):
return false; return false;
} }
export function botCanPostStarboard(channel: GuildBasedChannel, meId: string): channel is TextChannel {
if (!channel.isTextBased() || channel.isDMBased()) {
return false;
}
if (
channel.type !== ChannelType.GuildText &&
channel.type !== ChannelType.GuildAnnouncement
) {
return false;
}
const permissions = channel.permissionsFor(meId);
if (!permissions) {
return false;
}
return (
permissions.has(PermissionFlagsBits.ViewChannel) &&
permissions.has(PermissionFlagsBits.SendMessages) &&
permissions.has(PermissionFlagsBits.EmbedLinks)
);
}
export async function countMatchingStars( export async function countMatchingStars(
message: Message, message: Message,
config: StarboardConfig config: StarboardConfig
@@ -109,6 +133,10 @@ function resolveImageUrl(message: Message): string | undefined {
return embedImage ?? undefined; return embedImage ?? undefined;
} }
function authorDisplayName(message: Message): string {
return message.member?.displayName || message.author.displayName || message.author.username;
}
export function buildStarboardEmbed( export function buildStarboardEmbed(
message: Message, message: Message,
starCount: number, starCount: number,
@@ -119,7 +147,7 @@ export function buildStarboardEmbed(
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setColor(0x6366f1) .setColor(0x6366f1)
.setAuthor({ .setAuthor({
name: message.author.tag, name: authorDisplayName(message),
iconURL: message.author.displayAvatarURL() iconURL: message.author.displayAvatarURL()
}) })
.setDescription(content.slice(0, 4096)) .setDescription(content.slice(0, 4096))
@@ -143,6 +171,17 @@ export function buildStarboardEmbed(
return embed; return embed;
} }
async function fetchStarboardTextChannel(
context: BotContext,
channelId: string
): Promise<TextChannel | null> {
const channel = await context.client.channels.fetch(channelId).catch(() => null);
if (!channel || !channel.isTextBased() || channel.isDMBased()) {
return null;
}
return channel as TextChannel;
}
async function removeStarboardEntry(context: BotContext, entryId: string): Promise<void> { async function removeStarboardEntry(context: BotContext, entryId: string): Promise<void> {
const entry = await context.prisma.starboardEntry.findUnique({ where: { id: entryId } }); const entry = await context.prisma.starboardEntry.findUnique({ where: { id: entryId } });
if (!entry) { if (!entry) {
@@ -152,10 +191,14 @@ async function removeStarboardEntry(context: BotContext, entryId: string): Promi
try { try {
const config = await getStarboardConfig(context.prisma, entry.guildId); const config = await getStarboardConfig(context.prisma, entry.guildId);
if (config.channelId) { if (config.channelId) {
const channel = (await context.client.channels.fetch(config.channelId)) as TextChannel; const channel = await fetchStarboardTextChannel(context, config.channelId);
const starboardMessage = await channel.messages.fetch(entry.starboardMessageId).catch(() => null); if (channel) {
const starboardMessage = await channel.messages
.fetch(entry.starboardMessageId)
.catch(() => null);
await starboardMessage?.delete().catch(() => undefined); await starboardMessage?.delete().catch(() => undefined);
} }
}
} catch (error) { } catch (error) {
logger.warn({ error, entryId }, 'Failed to delete starboard message'); logger.warn({ error, entryId }, 'Failed to delete starboard message');
} }
@@ -163,6 +206,82 @@ async function removeStarboardEntry(context: BotContext, entryId: string): Promi
await context.prisma.starboardEntry.delete({ where: { id: entryId } }).catch(() => undefined); await context.prisma.starboardEntry.delete({ where: { id: entryId } }).catch(() => undefined);
} }
export async function removeStarboardEntryBySource(
context: BotContext,
guildId: string,
sourceMessageId: string
): Promise<void> {
const entry = await context.prisma.starboardEntry.findUnique({
where: {
guildId_sourceMessageId: { guildId, sourceMessageId }
}
});
if (entry) {
await removeStarboardEntry(context, entry.id);
}
}
async function createOrRecreateStarboardPost(
context: BotContext,
message: Message,
config: StarboardConfig,
locale: Locale,
starCount: number,
starboardChannel: TextChannel,
existingId?: string
): Promise<void> {
const embed = buildStarboardEmbed(message, starCount, config, locale);
const starboardMessage = await starboardChannel.send({ embeds: [embed] });
if (existingId) {
await context.prisma.starboardEntry.update({
where: { id: existingId },
data: {
starboardMessageId: starboardMessage.id,
starCount,
sourceChannelId: message.channel.id
}
});
return;
}
try {
await context.prisma.starboardEntry.create({
data: {
guildId: message.guild!.id,
sourceChannelId: message.channel.id,
sourceMessageId: message.id,
starboardMessageId: starboardMessage.id,
starCount
}
});
} catch (error) {
// Race: another reaction created the row first — keep the newer post, drop ours.
logger.warn({ error, messageId: message.id }, 'Starboard entry create raced; cleaning up');
await starboardMessage.delete().catch(() => undefined);
const winner = await context.prisma.starboardEntry.findUnique({
where: {
guildId_sourceMessageId: {
guildId: message.guild!.id,
sourceMessageId: message.id
}
}
});
if (winner) {
await context.prisma.starboardEntry.update({
where: { id: winner.id },
data: { starCount }
});
const existingMsg = await starboardChannel.messages
.fetch(winner.starboardMessageId)
.catch(() => null);
if (existingMsg) {
await existingMsg.edit({ embeds: [embed] }).catch(() => undefined);
}
}
}
}
export async function processStarboardMessage(context: BotContext, message: Message): Promise<void> { export async function processStarboardMessage(context: BotContext, message: Message): Promise<void> {
if (!message.guild) { if (!message.guild) {
return; return;
@@ -194,8 +313,13 @@ export async function processStarboardMessage(context: BotContext, message: Mess
return; return;
} }
const starboardChannel = await fetchStarboardTextChannel(context, config.channelId);
if (!starboardChannel) {
logger.warn({ guildId: message.guild.id, channelId: config.channelId }, 'Starboard channel missing');
return;
}
const embed = buildStarboardEmbed(message, starCount, config, locale); const embed = buildStarboardEmbed(message, starCount, config, locale);
const starboardChannel = (await context.client.channels.fetch(config.channelId)) as TextChannel;
if (existing) { if (existing) {
try { try {
@@ -206,21 +330,28 @@ export async function processStarboardMessage(context: BotContext, message: Mess
data: { starCount } data: { starCount }
}); });
} catch (error) { } catch (error) {
logger.warn({ error, messageId: message.id }, 'Failed to update starboard message'); logger.warn({ error, messageId: message.id }, 'Starboard message missing; recreating');
await createOrRecreateStarboardPost(
context,
message,
config,
locale,
starCount,
starboardChannel,
existing.id
);
} }
return; return;
} }
const starboardMessage = await starboardChannel.send({ embeds: [embed] }); await createOrRecreateStarboardPost(
await context.prisma.starboardEntry.create({ context,
data: { message,
guildId: message.guild.id, config,
sourceChannelId: message.channel.id, locale,
sourceMessageId: message.id, starCount,
starboardMessageId: starboardMessage.id, starboardChannel
starCount );
}
});
} }
export async function processStarboardReaction( export async function processStarboardReaction(
@@ -249,3 +380,41 @@ export async function processStarboardReaction(
await processStarboardMessage(context, message as Message); await processStarboardMessage(context, message as Message);
} }
export async function processStarboardSourceDelete(
context: BotContext,
message: Message | PartialMessage
): Promise<void> {
if (!message.guildId || !message.id) {
return;
}
await removeStarboardEntryBySource(context, message.guildId, message.id);
}
export async function processStarboardSourceUpdate(
context: BotContext,
message: Message | PartialMessage
): Promise<void> {
if (!message.guildId || !message.id) {
return;
}
const entry = await context.prisma.starboardEntry.findUnique({
where: {
guildId_sourceMessageId: {
guildId: message.guildId,
sourceMessageId: message.id
}
}
});
if (!entry) {
return;
}
const full = message.partial ? await message.fetch().catch(() => null) : message;
if (!full || !full.guild) {
return;
}
await processStarboardMessage(context, full as Message);
}

View File

@@ -7,8 +7,8 @@ import { invitesCommandData, statsCommandData } from './command-definitions.js';
import { getInviteLeaderboard, getInviteStatsForUser } from './invites.js'; import { getInviteLeaderboard, getInviteStatsForUser } from './invites.js';
import { import {
getStatsConfig, getStatsConfig,
refreshGuildStatsChannels,
StatsSetupSchema, StatsSetupSchema,
updateStatsChannels,
upsertStatsConfig upsertStatsConfig
} from './service.js'; } from './service.js';
@@ -47,7 +47,7 @@ const statsCommand: SlashCommand = {
} }
await upsertStatsConfig(context.prisma, guildId, parsed.data); await upsertStatsConfig(context.prisma, guildId, parsed.data);
await updateStatsChannels(context); await refreshGuildStatsChannels(context, guildId);
await interaction.reply({ content: t(locale, 'stats.setup.done'), ephemeral: true }); await interaction.reply({ content: t(locale, 'stats.setup.done'), ephemeral: true });
return; return;
@@ -64,7 +64,7 @@ const statsCommand: SlashCommand = {
return; return;
} }
await updateStatsChannels(context); await refreshGuildStatsChannels(context, guildId);
await interaction.reply({ content: t(locale, 'stats.refresh.done'), ephemeral: true }); await interaction.reply({ content: t(locale, 'stats.refresh.done'), ephemeral: true });
} }
} }

View File

@@ -4,6 +4,9 @@ export {
ensureStatsRecurringJobs, ensureStatsRecurringJobs,
startStatsWorker, startStatsWorker,
updateStatsChannels, updateStatsChannels,
refreshGuildStatsChannels,
runStatsGuildRefreshJob,
STATS_REFRESH_CRON, STATS_REFRESH_CRON,
STATS_REFRESH_JOB_NAME STATS_REFRESH_JOB_NAME,
STATS_GUILD_REFRESH_JOB_NAME
} from './service.js'; } from './service.js';

View File

@@ -21,6 +21,8 @@ export type StatsSetupInput = z.infer<typeof StatsSetupSchema>;
export const STATS_REFRESH_CRON = '*/10 * * * *'; export const STATS_REFRESH_CRON = '*/10 * * * *';
export const STATS_REFRESH_JOB_NAME = 'statsRefresh'; export const STATS_REFRESH_JOB_NAME = 'statsRefresh';
/** One-shot refresh for a single guild (dashboard save / slash refresh). */
export const STATS_GUILD_REFRESH_JOB_NAME = 'statsRefreshGuild';
export async function getStatsConfig(prisma: BotContext['prisma'], guildId: string) { export async function getStatsConfig(prisma: BotContext['prisma'], guildId: string) {
await ensureGuild(prisma, guildId); await ensureGuild(prisma, guildId);
@@ -97,13 +99,17 @@ async function renameStatsChannel(
}); });
} }
async function refreshGuildStatsChannels(context: BotContext, guildId: string): Promise<void> { export async function refreshGuildStatsChannels(context: BotContext, guildId: string): Promise<void> {
const config = await context.prisma.statsConfig.findUnique({ where: { guildId } }); const config = await context.prisma.statsConfig.findUnique({ where: { guildId } });
if (!config?.enabled) { if (!config?.enabled) {
return; return;
} }
const guild = await context.client.guilds.fetch(guildId).catch(() => null); // withCounts populates approximatePresenceCount / approximateMemberCount.
// GuildPresences is not privileged for us — presence cache would stay empty.
const guild = await context.client.guilds
.fetch({ guild: guildId, withCounts: true })
.catch(() => null);
if (!guild) { if (!guild) {
return; return;
} }
@@ -114,9 +120,7 @@ async function refreshGuildStatsChannels(context: BotContext, guildId: string):
return; return;
} }
await guild.members.fetch().catch(() => undefined); const memberCount = guild.approximateMemberCount ?? guild.memberCount;
const memberCount = guild.memberCount;
const onlineCount = estimateOnlineCount(guild); const onlineCount = estimateOnlineCount(guild);
const boostCount = guild.premiumSubscriptionCount ?? 0; const boostCount = guild.premiumSubscriptionCount ?? 0;
@@ -127,6 +131,13 @@ async function refreshGuildStatsChannels(context: BotContext, guildId: string):
]); ]);
} }
export async function runStatsGuildRefreshJob(
context: BotContext,
data: { guildId: string }
): Promise<void> {
await refreshGuildStatsChannels(context, data.guildId);
}
export async function updateStatsChannels(context: BotContext): Promise<void> { export async function updateStatsChannels(context: BotContext): Promise<void> {
const configs = await context.prisma.statsConfig.findMany({ const configs = await context.prisma.statsConfig.findMany({
where: { enabled: true } where: { enabled: true }
@@ -159,6 +170,15 @@ export function startStatsWorker(context: BotContext): Worker {
async (job) => { async (job) => {
if (job.name === STATS_REFRESH_JOB_NAME) { if (job.name === STATS_REFRESH_JOB_NAME) {
await updateStatsChannels(context); await updateStatsChannels(context);
return;
}
if (job.name === STATS_GUILD_REFRESH_JOB_NAME) {
const guildId = typeof job.data?.guildId === 'string' ? job.data.guildId : null;
if (!guildId) {
logger.warn({ jobId: job.id }, 'statsRefreshGuild missing guildId');
return;
}
await runStatsGuildRefreshJob(context, { guildId });
} }
}, },
{ connection: redis } { connection: redis }

View File

@@ -1,9 +1,10 @@
import { applyCommandDescription, applyOptionDescription, localizedChoice } from '@nexumi/shared'; import { applyCommandDescription, applyOptionDescription, localizedChoice } from '@nexumi/shared';
import { SlashCommandBuilder } from 'discord.js'; import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
export const tagCommandData = applyCommandDescription( export const tagCommandData = applyCommandDescription(
new SlashCommandBuilder() new SlashCommandBuilder()
.setName('tag') .setName('tag')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addSubcommand((sub) => .addSubcommand((sub) =>
applyCommandDescription(sub.setName('create'), 'tag.create.description') applyCommandDescription(sub.setName('create'), 'tag.create.description')
.addStringOption((o) => .addStringOption((o) =>

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;
} }
@@ -172,6 +172,9 @@ const tagCommand: SlashCommand = {
tag.triggerWord tag.triggerWord
? tf(locale, 'tag.info.trigger', { word: tag.triggerWord }) ? tf(locale, 'tag.info.trigger', { word: tag.triggerWord })
: t(locale, 'tag.info.noTrigger'), : t(locale, 'tag.info.noTrigger'),
tag.cooldownSeconds > 0
? tf(locale, 'tag.info.cooldown', { seconds: tag.cooldownSeconds })
: t(locale, 'tag.info.noCooldown'),
tag.allowedRoleIds.length > 0 tag.allowedRoleIds.length > 0
? tf(locale, 'tag.info.roles', { roles: tag.allowedRoleIds.map((id) => `<@&${id}>`).join(', ') }) ? tf(locale, 'tag.info.roles', { roles: tag.allowedRoleIds.map((id) => `<@&${id}>`).join(', ') })
: t(locale, 'tag.info.allRoles'), : t(locale, 'tag.info.allRoles'),
@@ -189,8 +192,12 @@ const tagCommand: SlashCommand = {
} }
} catch (error) { } catch (error) {
if (error instanceof TagError) { if (error instanceof TagError) {
const content =
error.code === 'cooldown' && error.retryAfterSeconds !== undefined
? tf(locale, 'tag.error.cooldown', { seconds: error.retryAfterSeconds })
: t(locale, `tag.error.${error.code}`);
await interaction.reply({ await interaction.reply({
content: t(locale, `tag.error.${error.code}`), content,
ephemeral: true ephemeral: true
}); });
return; return;

View File

@@ -4,8 +4,10 @@ import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js'; import { getGuildLocale } from '../../i18n.js';
import { logger } from '../../logger.js'; import { logger } from '../../logger.js';
import { import {
applyTagCooldown,
buildTagReply, buildTagReply,
findTriggeredTag, findTriggeredTag,
getTagCooldownRemaining,
memberCanUseTag, memberCanUseTag,
TagError TagError
} from './service.js'; } from './service.js';
@@ -31,6 +33,18 @@ export function registerTagEvents(client: Client, context: BotContext): void {
return; return;
} }
// Channel-scoped cooldown: silent skip to avoid spam replies.
if (tag.cooldownSeconds > 0) {
const remaining = await getTagCooldownRemaining(
message.guild.id,
tag.id,
message.channel.id
);
if (remaining > 0) {
return;
}
}
await context.prisma.tag.update({ await context.prisma.tag.update({
where: { id: tag.id }, where: { id: tag.id },
data: { useCount: { increment: 1 } } data: { useCount: { increment: 1 } }
@@ -38,6 +52,7 @@ export function registerTagEvents(client: Client, context: BotContext): void {
const payload = buildTagReply(tag, member, message.guild, ''); const payload = buildTagReply(tag, member, message.guild, '');
await message.reply(payload); await message.reply(payload);
await applyTagCooldown(message.guild.id, tag.id, message.channel.id, tag.cooldownSeconds);
} catch (error) { } catch (error) {
if (error instanceof TagError) { if (error instanceof TagError) {
const locale = await getGuildLocale(context.prisma, message.guild?.id ?? null); const locale = await getGuildLocale(context.prisma, message.guild?.id ?? null);

View File

@@ -1,21 +1,57 @@
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 { redis } from '../../redis.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';
import { matchesTriggerWord, tagCooldownKey } from './tag-helpers.js';
export { matchesTriggerWord, tagCooldownKey } from './tag-helpers.js';
export class TagError extends Error { export class TagError extends Error {
constructor(public readonly code: string) { constructor(
public readonly code: string,
public readonly retryAfterSeconds?: number
) {
super(code); super(code);
} }
} }
/** Returns remaining cooldown seconds, or 0 if ready. */
export async function getTagCooldownRemaining(
guildId: string,
tagId: string,
scopeId: string
): Promise<number> {
const ttl = await redis.ttl(tagCooldownKey(guildId, tagId, scopeId));
return ttl > 0 ? ttl : 0;
}
export async function applyTagCooldown(
guildId: string,
tagId: string,
scopeId: string,
cooldownSeconds: number
): Promise<void> {
if (cooldownSeconds <= 0) {
return;
}
await redis.set(tagCooldownKey(guildId, tagId, scopeId), '1', 'EX', cooldownSeconds);
}
export function normalizeTagName(name: string): string { export function normalizeTagName(name: string): string {
return name.trim().toLowerCase(); return name.trim().toLowerCase();
} }
@@ -50,7 +86,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 +103,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 +114,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,8 +173,10 @@ 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;
cooldownSeconds?: number;
allowedRoleIds?: string[]; allowedRoleIds?: string[];
allowedChannelIds?: string[]; allowedChannelIds?: string[];
createdById: string; createdById: string;
@@ -150,10 +193,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,7 +218,9 @@ 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,
cooldownSeconds: params.cooldownSeconds ?? 15,
allowedRoleIds: params.allowedRoleIds ?? [], allowedRoleIds: params.allowedRoleIds ?? [],
allowedChannelIds: params.allowedChannelIds ?? [], allowedChannelIds: params.allowedChannelIds ?? [],
createdById: params.createdById createdById: params.createdById
@@ -187,8 +236,10 @@ 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;
cooldownSeconds?: number;
allowedRoleIds?: string[]; allowedRoleIds?: string[];
allowedChannelIds?: string[]; allowedChannelIds?: string[];
} }
@@ -201,15 +252,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,8 +284,11 @@ 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,
cooldownSeconds:
updates.cooldownSeconds !== undefined ? updates.cooldownSeconds : existing.cooldownSeconds,
allowedRoleIds: updates.allowedRoleIds ?? existing.allowedRoleIds, allowedRoleIds: updates.allowedRoleIds ?? existing.allowedRoleIds,
allowedChannelIds: updates.allowedChannelIds ?? existing.allowedChannelIds allowedChannelIds: updates.allowedChannelIds ?? existing.allowedChannelIds
} }
@@ -266,18 +328,22 @@ export async function executeTag(
throw new TagError('not_allowed'); throw new TagError('not_allowed');
} }
const scopeId = member?.id ?? channelId;
if (tag.cooldownSeconds > 0) {
const remaining = await getTagCooldownRemaining(guildId, tag.id, scopeId);
if (remaining > 0) {
throw new TagError('cooldown', remaining);
}
}
await context.prisma.tag.update({ await context.prisma.tag.update({
where: { id: tag.id }, where: { id: tag.id },
data: { useCount: { increment: 1 } } data: { useCount: { increment: 1 } }
}); });
return buildTagReply(tag, member, guild, args); await applyTagCooldown(guildId, tag.id, scopeId, tag.cooldownSeconds);
}
export function matchesTriggerWord(content: string, triggerWord: string): boolean { return buildTagReply(tag, member, guild, args);
const escaped = triggerWord.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(`\\b${escaped}\\b`, 'i');
return pattern.test(content);
} }
export async function findTriggeredTag( export async function findTriggeredTag(

View File

@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { matchesTriggerWord, tagCooldownKey } from './tag-helpers.js';
describe('tagCooldownKey', () => {
it('builds a stable redis key', () => {
expect(tagCooldownKey('g1', 't1', 'c1')).toBe('tag:cooldown:g1:t1:c1');
});
});
describe('matchesTriggerWord', () => {
it('matches whole words case-insensitively', () => {
expect(matchesTriggerWord('Hello world', 'hello')).toBe(true);
expect(matchesTriggerWord('say helloworld', 'hello')).toBe(false);
});
});

View File

@@ -0,0 +1,10 @@
/** Redis key for tag cooldowns (guild + tag + user or channel scope). */
export function tagCooldownKey(guildId: string, tagId: string, scopeId: string): string {
return `tag:cooldown:${guildId}:${tagId}:${scopeId}`;
}
export function matchesTriggerWord(content: string, triggerWord: string): boolean {
const escaped = triggerWord.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(`\\b${escaped}\\b`, 'i');
return pattern.test(content);
}

View File

@@ -3,13 +3,14 @@ import {
applyOptionDescription, applyOptionDescription,
localizedChoice localizedChoice
} from '@nexumi/shared'; } from '@nexumi/shared';
import { SlashCommandBuilder } from 'discord.js'; import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
// Discord forbids mixing SUB_COMMAND_GROUP and SUB_COMMAND siblings. // Discord forbids mixing SUB_COMMAND_GROUP and SUB_COMMAND siblings.
// SPEC `/ticket panel create` is represented as subcommand `panel_create`. // SPEC `/ticket panel create` is represented as subcommand `panel_create`.
export const ticketCommandData = applyCommandDescription( export const ticketCommandData = applyCommandDescription(
new SlashCommandBuilder() new SlashCommandBuilder()
.setName('ticket') .setName('ticket')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addSubcommand((s) => .addSubcommand((s) =>
applyCommandDescription(s.setName('panel_create'), 'ticket.panel.create.description') applyCommandDescription(s.setName('panel_create'), 'ticket.panel.create.description')
.addStringOption((o) => .addStringOption((o) =>

View File

@@ -3,12 +3,11 @@ import { 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';
import { requirePermission } from '../../permissions.js'; import { requirePermission } from '../../permissions.js';
import { ephemeral } from '../../interaction-reply.js';
import { ticketCommandData } from './command-definitions.js'; import { ticketCommandData } from './command-definitions.js';
import { import {
addUserToTicket, addUserToTicket,
assertTicketStaff, assertTicketStaff,
buildPanelComponents,
buildPanelEmbed,
claimTicket, claimTicket,
closeTicket, closeTicket,
createCategoryWithRole, createCategoryWithRole,
@@ -17,7 +16,9 @@ import {
removeUserFromTicket, removeUserFromTicket,
renameTicket, renameTicket,
setTicketPriority, setTicketPriority,
syncTicketPanel,
TicketError, TicketError,
TicketPanelError,
upsertCategoryByName upsertCategoryByName
} from './service.js'; } from './service.js';
@@ -26,7 +27,7 @@ async function ensureManageGuild(
locale: 'de' | 'en' locale: 'de' | 'en'
): Promise<boolean> { ): Promise<boolean> {
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 false; return false;
} }
return requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale); return requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale);
@@ -37,7 +38,7 @@ const ticketCommand: 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 (!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;
} }
@@ -54,7 +55,10 @@ const ticketCommand: SlashCommand = {
const categories = await getGuildCategories(context, interaction.guildId!); const categories = await getGuildCategories(context, interaction.guildId!);
if (categories.length === 0) { if (categories.length === 0) {
await interaction.reply({ content: t(locale, 'ticket.error.no_categories'), ephemeral: true }); await interaction.reply({
content: t(locale, 'ticket.error.no_categories'),
...ephemeral()
});
return; return;
} }
@@ -62,15 +66,31 @@ const ticketCommand: SlashCommand = {
if (!channel?.isTextBased() || channel.isDMBased()) { if (!channel?.isTextBased() || channel.isDMBased()) {
await interaction.reply({ await interaction.reply({
content: t(locale, 'ticket.error.invalid_channel'), content: t(locale, 'ticket.error.invalid_channel'),
ephemeral: true ...ephemeral()
}); });
return; return;
} }
const embed = buildPanelEmbed(locale, name, description); try {
const components = buildPanelComponents(categories); await syncTicketPanel(context, interaction.guildId!, {
await channel.send({ embeds: [embed], components }); channelId: channel.id,
await interaction.reply({ content: t(locale, 'ticket.panel.created'), ephemeral: true }); title: name,
description
});
await interaction.reply({ content: t(locale, 'ticket.panel.created'), ...ephemeral() });
} catch (error) {
if (error instanceof TicketPanelError) {
const key =
error.code === 'no_categories'
? 'ticket.error.no_categories'
: error.code === 'not_configured'
? 'ticket.error.invalid_channel'
: 'ticket.error.missing_permission';
await interaction.reply({ content: t(locale, key), ...ephemeral() });
return;
}
throw error;
}
return; return;
} }
@@ -89,18 +109,28 @@ const ticketCommand: SlashCommand = {
); );
await interaction.reply({ await interaction.reply({
content: tf(locale, 'ticket.category.created', { name: category.name }), content: tf(locale, 'ticket.category.created', { name: category.name }),
ephemeral: true ...ephemeral()
}); });
return; return;
} }
const ticket = await findTicketByChannel(context.prisma, interaction.channelId!); const ticket = await findTicketByChannel(context.prisma, interaction.channelId!);
if (!ticket) { if (!ticket) {
await interaction.reply({ content: t(locale, 'ticket.error.not_in_ticket'), ephemeral: true }); await interaction.reply({
content: t(locale, 'ticket.error.not_in_ticket'),
...ephemeral()
});
return; return;
} }
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id).catch(() => null);
if (!member) {
await interaction.reply({
content: t(locale, 'ticket.error.member_not_found'),
...ephemeral()
});
return;
}
try { try {
if (sub === 'close') { if (sub === 'close') {
@@ -115,40 +145,57 @@ const ticketCommand: SlashCommand = {
const isOpener = ticket.openerId === interaction.user.id; const isOpener = ticket.openerId === interaction.user.id;
if (!isStaff && !isOpener) { if (!isStaff && !isOpener) {
await interaction.reply({ content: t(locale, 'ticket.error.not_staff'), ephemeral: true }); await interaction.reply({
content: t(locale, 'ticket.error.not_staff'),
...ephemeral()
});
return; return;
} }
const reason = interaction.options.getString('reason') ?? undefined; const reason = interaction.options.getString('reason') ?? undefined;
await closeTicket(context, ticket, interaction.user.id, locale, reason); await closeTicket(context, ticket, interaction.user.id, locale, reason);
await interaction.reply({ content: t(locale, 'ticket.close.success'), ephemeral: true }); await interaction.reply({ content: t(locale, 'ticket.close.success'), ...ephemeral() });
return; return;
} }
if (sub === 'claim') { if (sub === 'claim') {
await claimTicket(context, ticket, member, locale); await claimTicket(context, ticket, member, locale);
await interaction.reply({ content: t(locale, 'ticket.claim.success'), ephemeral: true }); await interaction.reply({ content: t(locale, 'ticket.claim.success'), ...ephemeral() });
return; return;
} }
if (sub === 'add') { if (sub === 'add') {
const user = interaction.options.getUser('user', true); const user = interaction.options.getUser('user', true);
const target = await interaction.guild!.members.fetch(user.id); const target = await interaction.guild!.members.fetch(user.id).catch(() => null);
if (!target) {
await interaction.reply({
content: t(locale, 'ticket.error.member_not_found'),
...ephemeral()
});
return;
}
await addUserToTicket(context, ticket, member, target, locale); await addUserToTicket(context, ticket, member, target, locale);
await interaction.reply({ await interaction.reply({
content: tf(locale, 'ticket.add.success', { user: `<@${user.id}>` }), content: tf(locale, 'ticket.add.success', { user: `<@${user.id}>` }),
ephemeral: true ...ephemeral()
}); });
return; return;
} }
if (sub === 'remove') { if (sub === 'remove') {
const user = interaction.options.getUser('user', true); const user = interaction.options.getUser('user', true);
const target = await interaction.guild!.members.fetch(user.id); const target = await interaction.guild!.members.fetch(user.id).catch(() => null);
if (!target) {
await interaction.reply({
content: t(locale, 'ticket.error.member_not_found'),
...ephemeral()
});
return;
}
await removeUserFromTicket(context, ticket, member, target, locale); await removeUserFromTicket(context, ticket, member, target, locale);
await interaction.reply({ await interaction.reply({
content: tf(locale, 'ticket.remove.success', { user: `<@${user.id}>` }), content: tf(locale, 'ticket.remove.success', { user: `<@${user.id}>` }),
ephemeral: true ...ephemeral()
}); });
return; return;
} }
@@ -158,7 +205,7 @@ const ticketCommand: SlashCommand = {
await renameTicket(context, ticket, member, name, locale); await renameTicket(context, ticket, member, name, locale);
await interaction.reply({ await interaction.reply({
content: tf(locale, 'ticket.rename.success', { name }), content: tf(locale, 'ticket.rename.success', { name }),
ephemeral: true ...ephemeral()
}); });
return; return;
} }
@@ -170,14 +217,14 @@ const ticketCommand: SlashCommand = {
content: tf(locale, 'ticket.priority.success', { content: tf(locale, 'ticket.priority.success', {
priority: t(locale, `ticket.priority.${level}`) priority: t(locale, `ticket.priority.${level}`)
}), }),
ephemeral: true ...ephemeral()
}); });
} }
} catch (error) { } catch (error) {
if (error instanceof TicketError) { if (error instanceof TicketError) {
await interaction.reply({ await interaction.reply({
content: t(locale, `ticket.error.${error.code}`), content: t(locale, `ticket.error.${error.code}`),
ephemeral: true ...ephemeral()
}); });
return; return;
} }

View File

@@ -1,4 +1,11 @@
export { ticketsCommands } from './commands.js'; export { ticketsCommands } from './commands.js';
export { isTicketInteraction, handleTicketInteraction } from './interactions.js'; export { isTicketInteraction, handleTicketInteraction } from './interactions.js';
export { closeInactiveTickets, checkInactiveTickets } from './service.js'; export {
closeInactiveTickets,
checkInactiveTickets,
runTicketPanelSyncJob,
runTicketDashboardActionJob,
TicketPanelError
} from './service.js';
export { registerTicketEvents } from './events.js'; export { registerTicketEvents } from './events.js';

View File

@@ -1,17 +1,32 @@
import type { import type {
ButtonInteraction, ButtonInteraction,
ModalSubmitInteraction, ModalSubmitInteraction,
StringSelectMenuInteraction StringSelectMenuInteraction,
UserSelectMenuInteraction
} from 'discord.js'; } from 'discord.js';
import { t, tf } from '@nexumi/shared'; import { 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 { ephemeral } from '../../interaction-reply.js';
import { import {
addUserToTicket,
assertTicketStaff,
buildOpenModal, buildOpenModal,
claimTicket,
closeTicket,
findTicketByChannel,
isTicketControlInteraction,
openTicketForCategory, openTicketForCategory,
parseFormFields, parseFormFields,
parseRateButton, parseRateButton,
rateTicket, rateTicket,
removeUserFromTicket,
setTicketPriority,
TICKET_CTRL_ADD,
TICKET_CTRL_CLAIM,
TICKET_CTRL_CLOSE,
TICKET_CTRL_PRIORITY,
TICKET_CTRL_REMOVE,
TICKET_MODAL_PREFIX, TICKET_MODAL_PREFIX,
TICKET_OPEN_PREFIX, TICKET_OPEN_PREFIX,
TICKET_RATE_PREFIX, TICKET_RATE_PREFIX,
@@ -24,37 +39,45 @@ export function isTicketInteraction(customId: string): boolean {
customId.startsWith(TICKET_OPEN_PREFIX) || customId.startsWith(TICKET_OPEN_PREFIX) ||
customId === TICKET_SELECT_ID || customId === TICKET_SELECT_ID ||
customId.startsWith(TICKET_MODAL_PREFIX) || customId.startsWith(TICKET_MODAL_PREFIX) ||
customId.startsWith(TICKET_RATE_PREFIX) customId.startsWith(TICKET_RATE_PREFIX) ||
isTicketControlInteraction(customId)
); );
} }
async function replyTicketError( async function replyTicketError(
interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, interaction:
| ButtonInteraction
| StringSelectMenuInteraction
| ModalSubmitInteraction
| UserSelectMenuInteraction,
locale: 'de' | 'en', locale: 'de' | 'en',
code: string code: string
): Promise<void> { ): Promise<void> {
const content = t(locale, `ticket.error.${code}`); const content = t(locale, `ticket.error.${code}`);
if (interaction.replied || interaction.deferred) { if (interaction.replied || interaction.deferred) {
await interaction.followUp({ content, ephemeral: true }); await interaction.followUp({ content, ...ephemeral() });
} else { } else {
await interaction.reply({ content, ephemeral: true }); await interaction.reply({ content, ...ephemeral() });
} }
} }
export async function handleOpenTicketRequest( async function handleOpenTicketRequest(
interaction: ButtonInteraction | StringSelectMenuInteraction, interaction: ButtonInteraction | StringSelectMenuInteraction,
context: BotContext, context: BotContext,
categoryId: string categoryId: string
): Promise<void> { ): Promise<void> {
const locale = await getGuildLocale(context.prisma, interaction.guildId); const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild() || !interaction.guild) { if (!interaction.inGuild() || !interaction.guild) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true }); await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() });
return; return;
} }
const category = await context.prisma.ticketCategory.findUnique({ where: { id: categoryId } }); const category = await context.prisma.ticketCategory.findUnique({ where: { id: categoryId } });
if (!category || category.guildId !== interaction.guildId) { if (!category || category.guildId !== interaction.guildId) {
await interaction.reply({ content: t(locale, 'ticket.error.category_not_found'), ephemeral: true }); await interaction.reply({
content: t(locale, 'ticket.error.category_not_found'),
...ephemeral()
});
return; return;
} }
@@ -65,7 +88,14 @@ export async function handleOpenTicketRequest(
} }
try { try {
const member = await interaction.guild.members.fetch(interaction.user.id); const member = await interaction.guild.members.fetch(interaction.user.id).catch(() => null);
if (!member) {
await interaction.reply({
content: t(locale, 'ticket.error.member_not_found'),
...ephemeral()
});
return;
}
const ticket = await openTicketForCategory( const ticket = await openTicketForCategory(
context, context,
interaction.guild, interaction.guild,
@@ -76,7 +106,7 @@ export async function handleOpenTicketRequest(
const channelRef = ticket.channelId ?? ticket.threadId; const channelRef = ticket.channelId ?? ticket.threadId;
await interaction.reply({ await interaction.reply({
content: tf(locale, 'ticket.created', { channel: `<#${channelRef}>` }), content: tf(locale, 'ticket.created', { channel: `<#${channelRef}>` }),
ephemeral: true ...ephemeral()
}); });
} catch (error) { } catch (error) {
if (error instanceof TicketError) { if (error instanceof TicketError) {
@@ -87,8 +117,131 @@ export async function handleOpenTicketRequest(
} }
} }
async function handleTicketControl(
interaction: ButtonInteraction | UserSelectMenuInteraction | StringSelectMenuInteraction,
context: BotContext
): Promise<void> {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild() || !interaction.guild) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() });
return;
}
const ticket = await findTicketByChannel(context.prisma, interaction.channelId);
if (!ticket) {
await interaction.reply({ content: t(locale, 'ticket.error.not_in_ticket'), ...ephemeral() });
return;
}
const member = await interaction.guild.members.fetch(interaction.user.id).catch(() => null);
if (!member) {
await interaction.reply({
content: t(locale, 'ticket.error.member_not_found'),
...ephemeral()
});
return;
}
try {
if (interaction.isButton() && interaction.customId === TICKET_CTRL_CLOSE) {
const isOpener = ticket.openerId === interaction.user.id;
let isStaff = false;
try {
await assertTicketStaff(member, ticket);
isStaff = true;
} catch {
isStaff = false;
}
if (!isStaff && !isOpener) {
await interaction.reply({ content: t(locale, 'ticket.error.not_staff'), ...ephemeral() });
return;
}
await interaction.deferReply(ephemeral());
await closeTicket(context, ticket, interaction.user.id, locale);
await interaction.editReply({ content: t(locale, 'ticket.close.success') });
return;
}
if (interaction.isButton() && interaction.customId === TICKET_CTRL_CLAIM) {
await claimTicket(context, ticket, member, locale);
await interaction.reply({ content: t(locale, 'ticket.claim.success'), ...ephemeral() });
return;
}
if (interaction.isStringSelectMenu() && interaction.customId === TICKET_CTRL_PRIORITY) {
const level = interaction.values[0];
if (!level) {
await interaction.reply({ content: t(locale, 'ticket.error.not_found'), ...ephemeral() });
return;
}
await setTicketPriority(context, ticket, member, level, locale);
await interaction.reply({
content: tf(locale, 'ticket.priority.success', {
priority: t(locale, `ticket.priority.${level}`)
}),
...ephemeral()
});
return;
}
if (interaction.isUserSelectMenu() && interaction.customId === TICKET_CTRL_ADD) {
const userId = interaction.values[0];
if (!userId) {
await interaction.reply({ content: t(locale, 'ticket.error.not_found'), ...ephemeral() });
return;
}
const target = await interaction.guild.members.fetch(userId).catch(() => null);
if (!target) {
await interaction.reply({
content: t(locale, 'ticket.error.member_not_found'),
...ephemeral()
});
return;
}
await addUserToTicket(context, ticket, member, target, locale);
await interaction.reply({
content: tf(locale, 'ticket.add.success', { user: `<@${userId}>` }),
...ephemeral()
});
return;
}
if (interaction.isUserSelectMenu() && interaction.customId === TICKET_CTRL_REMOVE) {
const userId = interaction.values[0];
if (!userId) {
await interaction.reply({ content: t(locale, 'ticket.error.not_found'), ...ephemeral() });
return;
}
const target = await interaction.guild.members.fetch(userId).catch(() => null);
if (!target) {
await interaction.reply({
content: t(locale, 'ticket.error.member_not_found'),
...ephemeral()
});
return;
}
await removeUserFromTicket(context, ticket, member, target, locale);
await interaction.reply({
content: tf(locale, 'ticket.remove.success', { user: `<@${userId}>` }),
...ephemeral()
});
}
} catch (error) {
if (error instanceof TicketError) {
await replyTicketError(interaction, locale, error.code);
return;
}
throw error;
}
}
export async function handleTicketInteraction( export async function handleTicketInteraction(
interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, interaction:
| ButtonInteraction
| StringSelectMenuInteraction
| ModalSubmitInteraction
| UserSelectMenuInteraction,
context: BotContext context: BotContext
): Promise<void> { ): Promise<void> {
const locale = await getGuildLocale(context.prisma, interaction.guildId); const locale = await getGuildLocale(context.prisma, interaction.guildId);
@@ -96,13 +249,27 @@ export async function handleTicketInteraction(
if (interaction.isStringSelectMenu() && interaction.customId === TICKET_SELECT_ID) { if (interaction.isStringSelectMenu() && interaction.customId === TICKET_SELECT_ID) {
const categoryId = interaction.values[0]; const categoryId = interaction.values[0];
if (!categoryId) { if (!categoryId) {
await interaction.reply({ content: t(locale, 'ticket.error.category_not_found'), ephemeral: true }); await interaction.reply({
content: t(locale, 'ticket.error.category_not_found'),
...ephemeral()
});
return; return;
} }
await handleOpenTicketRequest(interaction, context, categoryId); await handleOpenTicketRequest(interaction, context, categoryId);
return; return;
} }
if (
interaction.isButton() ||
interaction.isUserSelectMenu() ||
interaction.isStringSelectMenu()
) {
if (isTicketControlInteraction(interaction.customId)) {
await handleTicketControl(interaction, context);
return;
}
}
if (interaction.isButton()) { if (interaction.isButton()) {
if (interaction.customId.startsWith(TICKET_OPEN_PREFIX)) { if (interaction.customId.startsWith(TICKET_OPEN_PREFIX)) {
const categoryId = interaction.customId.slice(TICKET_OPEN_PREFIX.length); const categoryId = interaction.customId.slice(TICKET_OPEN_PREFIX.length);
@@ -122,7 +289,7 @@ export async function handleTicketInteraction(
if (error instanceof TicketError) { if (error instanceof TicketError) {
await interaction.reply({ await interaction.reply({
content: t(locale, `ticket.error.${error.code}`), content: t(locale, `ticket.error.${error.code}`),
ephemeral: true ...ephemeral()
}); });
return; return;
} }
@@ -134,14 +301,17 @@ export async function handleTicketInteraction(
if (interaction.isModalSubmit() && interaction.customId.startsWith(TICKET_MODAL_PREFIX)) { if (interaction.isModalSubmit() && interaction.customId.startsWith(TICKET_MODAL_PREFIX)) {
if (!interaction.inGuild() || !interaction.guild) { if (!interaction.inGuild() || !interaction.guild) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true }); await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() });
return; return;
} }
const categoryId = interaction.customId.slice(TICKET_MODAL_PREFIX.length); const categoryId = interaction.customId.slice(TICKET_MODAL_PREFIX.length);
const category = await context.prisma.ticketCategory.findUnique({ where: { id: categoryId } }); const category = await context.prisma.ticketCategory.findUnique({ where: { id: categoryId } });
if (!category) { if (!category) {
await interaction.reply({ content: t(locale, 'ticket.error.category_not_found'), ephemeral: true }); await interaction.reply({
content: t(locale, 'ticket.error.category_not_found'),
...ephemeral()
});
return; return;
} }
@@ -156,7 +326,14 @@ export async function handleTicketInteraction(
} }
try { try {
const member = await interaction.guild.members.fetch(interaction.user.id); const member = await interaction.guild.members.fetch(interaction.user.id).catch(() => null);
if (!member) {
await interaction.reply({
content: t(locale, 'ticket.error.member_not_found'),
...ephemeral()
});
return;
}
const ticket = await openTicketForCategory( const ticket = await openTicketForCategory(
context, context,
interaction.guild, interaction.guild,
@@ -169,7 +346,7 @@ export async function handleTicketInteraction(
const channelRef = ticket.channelId ?? ticket.threadId; const channelRef = ticket.channelId ?? ticket.threadId;
await interaction.reply({ await interaction.reply({
content: tf(locale, 'ticket.created', { channel: `<#${channelRef}>` }), content: tf(locale, 'ticket.created', { channel: `<#${channelRef}>` }),
ephemeral: true ...ephemeral()
}); });
} catch (error) { } catch (error) {
if (error instanceof TicketError) { if (error instanceof TicketError) {

View File

@@ -10,9 +10,14 @@ import {
StringSelectMenuBuilder, StringSelectMenuBuilder,
TextInputBuilder, TextInputBuilder,
TextInputStyle, TextInputStyle,
UserSelectMenuBuilder,
type APIMessageComponentEmoji,
type Guild, type Guild,
type GuildBasedChannel,
type GuildMember, type GuildMember,
type GuildTextBasedChannel,
type Message, type Message,
type Role,
type SendableChannels, type SendableChannels,
type TextChannel, type TextChannel,
type ThreadChannel type ThreadChannel
@@ -34,6 +39,14 @@ export const TICKET_OPEN_PREFIX = 'ticket:open:';
export const TICKET_SELECT_ID = 'ticket:select'; export const TICKET_SELECT_ID = 'ticket:select';
export const TICKET_MODAL_PREFIX = 'ticket:modal:'; export const TICKET_MODAL_PREFIX = 'ticket:modal:';
export const TICKET_RATE_PREFIX = 'ticket:rate:'; export const TICKET_RATE_PREFIX = 'ticket:rate:';
export const TICKET_CTRL_CLAIM = 'ticket:ctrl:claim';
export const TICKET_CTRL_CLOSE = 'ticket:ctrl:close';
export const TICKET_CTRL_ADD = 'ticket:ctrl:add';
export const TICKET_CTRL_REMOVE = 'ticket:ctrl:remove';
export const TICKET_CTRL_PRIORITY = 'ticket:ctrl:priority';
/** Cap private-thread staff adds to stay within Discord rate limits. */
const MAX_THREAD_SUPPORT_MEMBERS = 50;
export const TicketFormFieldSchema = z.object({ export const TicketFormFieldSchema = z.object({
id: z.string(), id: z.string(),
@@ -51,6 +64,189 @@ export class TicketError extends Error {
} }
} }
export class TicketPanelError extends Error {
constructor(public readonly code: 'not_configured' | 'missing_permission' | 'no_categories') {
super(code);
this.name = 'TicketPanelError';
}
}
function isUnknownChannelError(error: unknown): boolean {
return Boolean(
error &&
typeof error === 'object' &&
'code' in error &&
(error as { code: unknown }).code === 10003
);
}
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)
);
}
function parseButtonEmoji(emoji?: string | null): APIMessageComponentEmoji | undefined {
if (!emoji) {
return undefined;
}
const customMatch = emoji.match(/^<(a)?:(\w+):(\d+)>$/);
if (customMatch) {
return {
id: customMatch[3]!,
name: customMatch[2],
animated: Boolean(customMatch[1])
};
}
if (/^\d+$/.test(emoji)) {
return { id: emoji };
}
return { name: emoji };
}
/**
* `Role#members` only includes cached guild members. Fetch the member list when
* the cache looks empty so support staff can be added to private threads.
*/
async function resolveSupportMembers(guild: Guild, roleIds: string[]): Promise<GuildMember[]> {
if (roleIds.length === 0) {
return [];
}
const roles: Role[] = [];
for (const roleId of roleIds) {
const role =
guild.roles.cache.get(roleId) ?? (await guild.roles.fetch(roleId).catch(() => null));
if (role) {
roles.push(role);
}
}
if (roles.length === 0) {
return [];
}
const cachedCount = roles.reduce((sum, role) => sum + role.members.size, 0);
if (cachedCount === 0) {
await guild.members.fetch().catch((error) => {
logger.warn({ error, guildId: guild.id }, 'Failed to fetch members for ticket support roles');
});
}
const seen = new Set<string>();
const members: GuildMember[] = [];
for (const role of roles) {
for (const member of role.members.values()) {
if (member.user.bot || seen.has(member.id)) {
continue;
}
seen.add(member.id);
members.push(member);
if (members.length >= MAX_THREAD_SUPPORT_MEMBERS) {
return members;
}
}
}
return members;
}
export function isTicketControlInteraction(customId: string): boolean {
return (
customId === TICKET_CTRL_CLAIM ||
customId === TICKET_CTRL_CLOSE ||
customId === TICKET_CTRL_ADD ||
customId === TICKET_CTRL_REMOVE ||
customId === TICKET_CTRL_PRIORITY
);
}
export function buildTicketControlPanel(locale: 'de' | 'en'): {
embeds: EmbedBuilder[];
components: ActionRowBuilder<
ButtonBuilder | UserSelectMenuBuilder | StringSelectMenuBuilder
>[];
} {
const embed = new EmbedBuilder()
.setTitle(t(locale, 'ticket.control.title'))
.setDescription(t(locale, 'ticket.control.description'))
.setColor(0x6366f1);
const buttons = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(TICKET_CTRL_CLAIM)
.setLabel(t(locale, 'ticket.control.claim'))
.setStyle(ButtonStyle.Success),
new ButtonBuilder()
.setCustomId(TICKET_CTRL_CLOSE)
.setLabel(t(locale, 'ticket.control.close'))
.setStyle(ButtonStyle.Danger)
);
const priorityRow = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
new StringSelectMenuBuilder()
.setCustomId(TICKET_CTRL_PRIORITY)
.setPlaceholder(t(locale, 'ticket.control.priorityPlaceholder'))
.addOptions(
{
label: t(locale, 'ticket.priority.LOW'),
value: 'LOW',
description: t(locale, 'ticket.control.priorityLowHint')
},
{
label: t(locale, 'ticket.priority.NORMAL'),
value: 'NORMAL',
description: t(locale, 'ticket.control.priorityNormalHint')
},
{
label: t(locale, 'ticket.priority.HIGH'),
value: 'HIGH',
description: t(locale, 'ticket.control.priorityHighHint')
},
{
label: t(locale, 'ticket.priority.URGENT'),
value: 'URGENT',
description: t(locale, 'ticket.control.priorityUrgentHint')
}
)
);
const addRow = new ActionRowBuilder<UserSelectMenuBuilder>().addComponents(
new UserSelectMenuBuilder()
.setCustomId(TICKET_CTRL_ADD)
.setPlaceholder(t(locale, 'ticket.control.addPlaceholder'))
.setMinValues(1)
.setMaxValues(1)
);
const removeRow = new ActionRowBuilder<UserSelectMenuBuilder>().addComponents(
new UserSelectMenuBuilder()
.setCustomId(TICKET_CTRL_REMOVE)
.setPlaceholder(t(locale, 'ticket.control.removePlaceholder'))
.setMinValues(1)
.setMaxValues(1)
);
return {
embeds: [embed],
components: [buttons, priorityRow, addRow, removeRow]
};
}
async function fetchSendableChannel( async function fetchSendableChannel(
context: BotContext, context: BotContext,
channelId: string channelId: string
@@ -185,8 +381,9 @@ export function buildPanelComponents(categories: TicketCategory[]): ActionRowBui
.setCustomId(openButtonId(category.id)) .setCustomId(openButtonId(category.id))
.setLabel(category.name.slice(0, 80)) .setLabel(category.name.slice(0, 80))
.setStyle(ButtonStyle.Primary); .setStyle(ButtonStyle.Primary);
if (category.emoji) { const emoji = parseButtonEmoji(category.emoji);
button.setEmoji(category.emoji); if (emoji) {
button.setEmoji(emoji);
} }
row.addComponents(button); row.addComponents(button);
} }
@@ -197,17 +394,208 @@ export function buildPanelComponents(categories: TicketCategory[]): ActionRowBui
.setCustomId(TICKET_SELECT_ID) .setCustomId(TICKET_SELECT_ID)
.setPlaceholder('Select a category') .setPlaceholder('Select a category')
.addOptions( .addOptions(
categories.slice(0, 25).map((category) => ({ categories.slice(0, 25).map((category) => {
const emoji = parseButtonEmoji(category.emoji);
return {
label: category.name.slice(0, 100), label: category.name.slice(0, 100),
value: category.id, value: category.id,
description: category.description?.slice(0, 100) ?? undefined, description: category.description?.slice(0, 100) ?? undefined,
emoji: category.emoji ? { name: category.emoji } : undefined emoji
})) };
})
); );
return [new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select)]; return [new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select)];
} }
/**
* Posts or updates the ticket panel. Used by `/ticket panel_create` and by the
* dashboard via BullMQ (`ticketPanelSync`).
*/
export async function syncTicketPanel(
context: BotContext,
guildId: string,
options: {
channelId?: string | null;
title?: string;
description?: string | null;
} = {}
): Promise<{ panelChannelId: string; panelMessageId: string }> {
const locale = await getGuildLocale(context.prisma, guildId);
const config = await getTicketConfig(context.prisma, guildId);
const categories = await getGuildCategories(context, guildId);
if (categories.length === 0) {
throw new TicketPanelError('no_categories');
}
const targetChannelId = options.channelId || config.panelChannelId;
if (!targetChannelId) {
throw new TicketPanelError('not_configured');
}
const guild = await context.client.guilds.fetch(guildId);
const me = guild.members.me;
if (!me) {
throw new TicketPanelError('missing_permission');
}
const channel = await guild.channels.fetch(targetChannelId).catch(() => null);
if (!channel || !botCanPostPanel(channel, me.id)) {
throw new TicketPanelError('missing_permission');
}
const title = options.title ?? t(locale, 'ticket.panel.defaultTitle');
const description =
options.description !== undefined
? options.description
: t(locale, 'ticket.panel.defaultDescription');
const payload = {
embeds: [buildPanelEmbed(locale, title, description)],
components: buildPanelComponents(categories)
};
const sameChannel =
Boolean(config.panelMessageId) && config.panelChannelId === channel.id;
if (sameChannel && config.panelMessageId) {
try {
const existing = await channel.messages.fetch(config.panelMessageId);
await existing.edit(payload);
if (!config.panelChannelId) {
await context.prisma.ticketConfig.update({
where: { guildId },
data: { panelChannelId: channel.id }
});
}
return { panelChannelId: channel.id, panelMessageId: existing.id };
} catch (error) {
logger.warn(
{ error, guildId, messageId: config.panelMessageId },
'Failed to edit ticket panel; recreating'
);
}
}
if (config.panelMessageId && config.panelChannelId && config.panelChannelId !== channel.id) {
try {
const oldChannel = await guild.channels.fetch(config.panelChannelId);
if (oldChannel?.isTextBased() && !oldChannel.isDMBased()) {
const oldMessage = await oldChannel.messages.fetch(config.panelMessageId);
await oldMessage.delete();
}
} catch {
// Previous panel may already be gone
}
}
const message = await channel.send(payload);
await context.prisma.ticketConfig.update({
where: { guildId },
data: {
panelChannelId: channel.id,
panelMessageId: message.id
}
});
return { panelChannelId: channel.id, panelMessageId: message.id };
}
export async function runTicketPanelSyncJob(
context: BotContext,
data: { guildId: string }
): Promise<{ panelChannelId: string; panelMessageId: string }> {
return syncTicketPanel(context, data.guildId);
}
/**
* Dashboard ticket actions (close / claim / priority). Staff checks are done by
* the WebUI (`requireGuildAccess`); the bot applies Discord side effects.
*/
export async function runTicketDashboardActionJob(
context: BotContext,
data: {
ticketId: string;
guildId: string;
actorUserId: string;
action: 'close' | 'claim' | 'priority';
reason?: string;
priority?: string;
}
): Promise<{ id: string; status: string; priority: string; claimedById: string | null }> {
const ticket = await context.prisma.ticket.findFirst({
where: { id: data.ticketId, guildId: data.guildId },
include: { category: true }
});
if (!ticket) {
throw new TicketError('not_found');
}
if (ticket.status === 'CLOSED') {
throw new TicketError('closed');
}
const locale = await getGuildLocale(context.prisma, data.guildId);
if (data.action === 'close') {
await closeTicket(context, ticket, data.actorUserId, locale, data.reason);
const closed = await context.prisma.ticket.findUniqueOrThrow({ where: { id: ticket.id } });
return {
id: closed.id,
status: closed.status,
priority: closed.priority,
claimedById: closed.claimedById
};
}
if (data.action === 'claim') {
if (ticket.claimedById === data.actorUserId) {
throw new TicketError('already_claimed');
}
const updated = await context.prisma.ticket.update({
where: { id: ticket.id },
data: {
claimedById: data.actorUserId,
status: 'CLAIMED',
lastActivityAt: new Date()
}
});
const channelId = ticket.channelId ?? ticket.threadId;
if (channelId) {
const channel = await fetchSendableChannel(context, channelId);
if (channel) {
await channel.send(tf(locale, 'ticket.claimed', { user: `<@${data.actorUserId}>` }));
}
}
return {
id: updated.id,
status: updated.status,
priority: updated.priority,
claimedById: updated.claimedById
};
}
const parsed = TicketPrioritySchema.parse(data.priority);
const updated = await context.prisma.ticket.update({
where: { id: ticket.id },
data: { priority: parsed, lastActivityAt: new Date() }
});
const channelId = ticket.channelId ?? ticket.threadId;
if (channelId) {
const channel = await fetchSendableChannel(context, channelId);
if (channel) {
await channel.send(
tf(locale, 'ticket.prioritySet', { priority: t(locale, `ticket.priority.${parsed}`) })
);
}
}
return {
id: updated.id,
status: updated.status,
priority: updated.priority,
claimedById: updated.claimedById
};
}
export function buildOpenModal(category: TicketCategory, locale: 'de' | 'en'): ModalBuilder { export function buildOpenModal(category: TicketCategory, locale: 'de' | 'en'): ModalBuilder {
const fields = parseFormFields(category.formFields); const fields = parseFormFields(category.formFields);
const modal = new ModalBuilder() const modal = new ModalBuilder()
@@ -328,7 +716,11 @@ async function fetchTicketMessages(
timestamp: message.createdAt.toISOString() timestamp: message.createdAt.toISOString()
})); }));
} catch (error) { } catch (error) {
if (isUnknownChannelError(error)) {
logger.debug({ ticketId: ticket.id }, 'Ticket channel already gone while fetching messages');
} else {
logger.warn({ error, ticketId: ticket.id }, 'Failed to fetch ticket messages'); logger.warn({ error, ticketId: ticket.id }, 'Failed to fetch ticket messages');
}
return []; return [];
} }
} }
@@ -395,13 +787,24 @@ export async function createTicketChannel(
reason: `Ticket opened by ${params.opener.user.tag}` reason: `Ticket opened by ${params.opener.user.tag}`
}); });
await thread.members.add(params.opener.id); await thread.members.add(params.opener.id);
for (const roleId of supportRoleIds) {
const role = params.guild.roles.cache.get(roleId); const supportMembers = await resolveSupportMembers(params.guild, supportRoleIds);
if (role) { for (const member of supportMembers) {
for (const [, member] of role.members) { if (member.id === params.opener.id) {
await thread.members.add(member.id).catch(() => undefined); continue;
} }
await thread.members.add(member.id).catch((error) => {
logger.warn(
{ error, threadId: thread.id, userId: member.id },
'Failed to add support member to ticket thread'
);
});
} }
if (supportRoleIds.length > 0 && supportMembers.length === 0) {
logger.warn(
{ guildId: params.guild.id, supportRoleIds },
'No cached members found for ticket support roles staff may need Server Members intent coverage'
);
} }
threadId = thread.id; threadId = thread.id;
} else { } else {
@@ -436,10 +839,13 @@ export async function createTicketChannel(
const targetId = channelId ?? threadId!; const targetId = channelId ?? threadId!;
const target = (await context.client.channels.fetch(targetId)) as TextChannel | ThreadChannel; const target = (await context.client.channels.fetch(targetId)) as TextChannel | ThreadChannel;
const roleMentions = supportRoleIds.map((roleId) => `<@&${roleId}>`).join(' ');
const introLines = [ const introLines = [
roleMentions || null,
tf(locale, 'ticket.opened', { user: `<@${params.opener.id}>` }), tf(locale, 'ticket.opened', { user: `<@${params.opener.id}>` }),
params.subject ? tf(locale, 'ticket.subjectLine', { subject: params.subject }) : null params.subject ? tf(locale, 'ticket.subjectLine', { subject: params.subject }) : null
].filter(Boolean); ].filter(Boolean) as string[];
if (params.formAnswers && Object.keys(params.formAnswers).length > 0) { if (params.formAnswers && Object.keys(params.formAnswers).length > 0) {
const answerLines = Object.entries(params.formAnswers).map( const answerLines = Object.entries(params.formAnswers).map(
@@ -448,7 +854,17 @@ export async function createTicketChannel(
introLines.push(answerLines.join('\n')); introLines.push(answerLines.join('\n'));
} }
await target.send({ content: introLines.join('\n') }); await target.send({
content: introLines.join('\n'),
allowedMentions: {
roles: supportRoleIds,
users: [params.opener.id]
}
});
const control = buildTicketControlPanel(locale);
await target.send(control);
return ticket; return ticket;
} }
@@ -720,9 +1136,13 @@ export async function closeTicket(
await channel.delete(`Ticket ${ticket.id} closed`); await channel.delete(`Ticket ${ticket.id} closed`);
} }
} catch (error) { } catch (error) {
if (isUnknownChannelError(error)) {
logger.debug({ ticketId: ticket.id }, 'Ticket channel already gone while deleting');
} else {
logger.warn({ error, ticketId: ticket.id }, 'Failed to delete ticket channel'); logger.warn({ error, ticketId: ticket.id }, 'Failed to delete ticket channel');
} }
} }
}
} }
export async function rateTicket( export async function rateTicket(

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

@@ -257,7 +257,9 @@ const emojiCommand: 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 ensureGuildCommand(interaction, locale))) return; if (!(await ensureGuildCommand(interaction, locale))) return;
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale))) { if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale, {
botPermissions: PermissionFlagsBits.ManageGuildExpressions
}))) {
return; return;
} }
@@ -326,7 +328,9 @@ const stickerCommand: 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 ensureGuildCommand(interaction, locale))) return; if (!(await ensureGuildCommand(interaction, locale))) return;
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale))) { if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale, {
botPermissions: PermissionFlagsBits.ManageGuildExpressions
}))) {
return; return;
} }
@@ -419,10 +423,128 @@ const embedCommand: 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 ensureGuildCommand(interaction, locale))) return; if (!(await ensureGuildCommand(interaction, locale))) return;
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageMessages, locale))) { if (!(await requirePermission(interaction, PermissionFlagsBits.ManageMessages, locale, {
botPermissions: PermissionFlagsBits.ManageMessages
}))) {
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

@@ -32,17 +32,18 @@ const translateContextMenu: ContextMenuCommand = {
return; return;
} }
await interaction.deferReply({ ephemeral: true });
const targetLang = locale === 'de' ? 'de' : 'en'; const targetLang = locale === 'de' ? 'de' : 'en';
const translated = await translateText(content, targetLang); const translated = await translateText(content, targetLang);
if (!translated) { if (!translated) {
await interaction.reply({ content: t(locale, 'utility.translate.failed'), ephemeral: true }); await interaction.editReply({ content: t(locale, 'utility.translate.failed') });
return; return;
} }
await interaction.reply({ await interaction.editReply({
content: t(locale, 'utility.translate.result').replace('{text}', translated), content: t(locale, 'utility.translate.result').replace('{text}', translated)
ephemeral: true
}); });
} }
}; };

View File

@@ -2,6 +2,7 @@ import { type TextChannel } from 'discord.js';
import { t, tf } from '@nexumi/shared'; import { t, tf } from '@nexumi/shared';
import type { BotContext } from '../../types.js'; import type { BotContext } from '../../types.js';
import { ensureGuild } from '../../guild.js'; import { ensureGuild } from '../../guild.js';
import { safeRemoveBullJob } from '../../lib/safe-remove-job.js';
import { reminderQueue } from '../../queues.js'; import { reminderQueue } from '../../queues.js';
import { parseWhen, UtilityError } from './service.js'; import { parseWhen, UtilityError } from './service.js';
@@ -107,9 +108,7 @@ export async function deleteReminder(
if (match.jobId) { if (match.jobId) {
const job = await reminderQueue.getJob(match.jobId); const job = await reminderQueue.getJob(match.jobId);
if (job) { await safeRemoveBullJob(job, { label: 'reminder', jobId: match.jobId });
await job.remove();
}
} }
await context.prisma.reminder.delete({ where: { id: match.id } }); await context.prisma.reminder.delete({ where: { id: match.id } });

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

@@ -3,19 +3,17 @@ 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, import { syncVerificationPanel, VerificationPanelError } from './handlers.js';
updateVerificationConfig
} from './service.js';
import { buildVerificationPanel } from './handlers.js';
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 +28,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,42 +49,41 @@ 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;
} }
const panelChannelOption = interaction.options.getChannel('channel'); const panelChannelOption = interaction.options.getChannel('channel');
const panelChannel = panelChannelOption
? await interaction.guild!.channels.fetch(panelChannelOption.id)
: config.channelId
? await interaction.guild!.channels.fetch(config.channelId)
: null;
if (!panelChannel?.isTextBased() || panelChannel.isDMBased()) { try {
await interaction.reply({ content: t(locale, 'verification.invalidChannel'), ephemeral: true }); await syncVerificationPanel(context, guildId, panelChannelOption?.id ?? null);
await interaction.reply({ content: t(locale, 'verification.panelPosted'), ...ephemeral() });
} catch (error) {
if (error instanceof VerificationPanelError) {
const key =
error.code === 'not_configured'
? 'verification.notConfigured'
: 'verification.panelMissingPermission';
await interaction.reply({ content: t(locale, key), ...ephemeral() });
return; return;
} }
throw error;
const panel = buildVerificationPanel(config);
const message = await panelChannel.send(panel);
await context.prisma.verificationConfig.update({
where: { guildId },
data: {
panelChannelId: panelChannel.id,
panelMessageId: message.id
} }
});
await interaction.reply({ content: t(locale, 'verification.panelPosted'), ephemeral: true });
} }
}; };

View File

@@ -5,9 +5,11 @@ import {
EmbedBuilder, EmbedBuilder,
PermissionFlagsBits, PermissionFlagsBits,
type ButtonInteraction, type ButtonInteraction,
type GuildMember type GuildBasedChannel,
type GuildMember,
type GuildTextBasedChannel
} 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,28 +20,104 @@ 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';
import { notifyModerationTargetDm } from '../moderation/service.js';
export class VerificationPanelError extends Error {
constructor(public readonly code: 'not_configured' | 'missing_permission') {
super(code);
this.name = 'VerificationPanelError';
}
}
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)
);
}
export type CompleteVerificationOptions = {
ipHash?: string | null;
userAgentHash?: string | null;
captchaProvider?: string | null;
};
async function applyFailAction( async function applyFailAction(
context: BotContext,
member: GuildMember, member: GuildMember,
failAction: string, failAction: string,
reason: string reason: string
): Promise<void> { ): Promise<void> {
const me = member.guild.members.me; const me = member.guild.members.me;
if (failAction !== 'KICK' && failAction !== 'BAN') {
return;
}
const locale = await getGuildLocale(context.prisma, member.guild.id);
if (failAction === 'KICK' && me?.permissions.has(PermissionFlagsBits.KickMembers)) { if (failAction === 'KICK' && me?.permissions.has(PermissionFlagsBits.KickMembers)) {
await notifyModerationTargetDm({
user: member.user,
guildName: member.guild.name,
reason,
locale,
action: 'kick'
});
await member.kick(reason); await member.kick(reason);
return; return;
} }
if (failAction === 'BAN' && me?.permissions.has(PermissionFlagsBits.BanMembers)) { if (failAction === 'BAN' && me?.permissions.has(PermissionFlagsBits.BanMembers)) {
await notifyModerationTargetDm({
user: member.user,
guildName: member.guild.name,
reason,
locale,
action: 'ban'
});
await member.guild.members.ban(member.id, { reason }); await member.guild.members.ban(member.id, { reason });
} }
} }
async function logVerificationFailure(
context: BotContext,
member: GuildMember,
input: {
reason: string;
detail?: string;
failAction?: string;
matchedUserId?: string | null;
}
): Promise<void> {
const { logVerificationFail } = await import('../logging/events.js');
await logVerificationFail(context, {
guildId: member.guild.id,
userId: member.id,
userTag: member.user.tag,
reason: input.reason,
detail: input.detail,
failAction: input.failAction,
matchedUserId: input.matchedUserId
});
}
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) {
@@ -54,14 +132,108 @@ export async function completeVerification(
const ageDays = accountAgeDays(member.user.createdAt); const ageDays = accountAgeDays(member.user.createdAt);
if (config.minAccountAgeDays > 0 && ageDays < config.minAccountAgeDays) { if (config.minAccountAgeDays > 0 && ageDays < config.minAccountAgeDays) {
await applyFailAction( const failReason = `Verification failed: account too young (${ageDays}d)`;
member, await logVerificationFailure(context, member, {
config.failAction, reason: 'account_too_young',
`Verification failed: account too young (${ageDays}d)` detail: `Account age ${ageDays}d (required ${config.minAccountAgeDays}d)`,
); failAction: config.failAction
});
await applyFailAction(context, member, config.failAction, failReason);
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) {
const failReason = `Verification failed: possible alt account (${match.reason})`;
await logVerificationFailure(context, member, {
reason: 'alt_detected',
detail: `Signal: ${match.reason}`,
failAction: config.failAction,
matchedUserId
});
await applyFailAction(context, member, config.failAction, failReason);
return { ok: false, reason: 'alt_detected' };
}
await logVerificationFailure(context, member, {
reason: 'alt_detected_flagged',
detail: `Signal: ${match.reason} (flagged only, not blocked)`,
failAction: 'NONE',
matchedUserId
});
}
}
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 +254,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,13 +298,97 @@ 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)
); );
return { embeds: [embed], components: [row] }; return { embeds: [embed], components: [row] };
} }
/**
* Posts or updates the verification panel in the configured channel.
* Used by `/verify panel` and by the dashboard via BullMQ (`verificationPanelSync`).
*/
export async function syncVerificationPanel(
context: BotContext,
guildId: string,
preferredChannelId?: string | null
): Promise<{ panelChannelId: string; panelMessageId: string }> {
const locale = await getGuildLocale(context.prisma, guildId);
const config = await getVerificationConfig(context.prisma, guildId);
if (!config.enabled || !config.verifiedRoleId) {
throw new VerificationPanelError('not_configured');
}
const targetChannelId = preferredChannelId || config.channelId;
if (!targetChannelId) {
throw new VerificationPanelError('not_configured');
}
const guild = await context.client.guilds.fetch(guildId);
const me = guild.members.me;
if (!me) {
throw new VerificationPanelError('missing_permission');
}
const channel = await guild.channels.fetch(targetChannelId).catch(() => null);
if (!channel || !botCanPostPanel(channel, me.id)) {
throw new VerificationPanelError('missing_permission');
}
const panel = buildVerificationPanel(config, locale);
const sameChannel =
Boolean(config.panelMessageId) && config.panelChannelId === channel.id;
if (sameChannel && config.panelMessageId) {
try {
const existing = await channel.messages.fetch(config.panelMessageId);
await existing.edit(panel);
return { panelChannelId: channel.id, panelMessageId: existing.id };
} catch (error) {
logger.warn(
{ error, guildId, messageId: config.panelMessageId },
'Failed to edit verification panel; recreating'
);
}
}
if (config.panelMessageId && config.panelChannelId && config.panelChannelId !== channel.id) {
try {
const oldChannel = await guild.channels.fetch(config.panelChannelId);
if (oldChannel?.isTextBased() && !oldChannel.isDMBased()) {
const oldMessage = await oldChannel.messages.fetch(config.panelMessageId);
await oldMessage.delete();
}
} catch {
// Previous panel may already be gone
}
}
const message = await channel.send(panel);
await context.prisma.verificationConfig.update({
where: { guildId },
data: {
panelChannelId: channel.id,
panelMessageId: message.id
}
});
return { panelChannelId: channel.id, panelMessageId: message.id };
}
export async function runVerificationPanelSyncJob(
context: BotContext,
data: { guildId: string }
): Promise<{ panelChannelId: string; panelMessageId: string }> {
return syncVerificationPanel(context, data.guildId);
}
export async function handleVerificationButton( export async function handleVerificationButton(
interaction: ButtonInteraction, interaction: ButtonInteraction,
context: BotContext context: BotContext
@@ -119,25 +402,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,23 +1,75 @@
import { PermissionFlagsBits, type GuildMember } from 'discord.js'; import { PermissionFlagsBits, type GuildMember, type Role } from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { BotContext } from '../../types.js'; import type { BotContext } from '../../types.js';
import { getWelcomeConfig, resolveWelcomePayload } from './service.js'; import { getGuildLocale } from '../../i18n.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';
async function resolveRole(member: GuildMember, roleId: string): Promise<Role | null> {
const cached = member.guild.roles.cache.get(roleId);
if (cached) {
return cached;
}
return member.guild.roles.fetch(roleId).catch(() => null);
}
async function applyAutoroles(member: GuildMember, roleIds: string[]): Promise<void> { async function applyAutoroles(member: GuildMember, roleIds: string[]): Promise<void> {
const me = member.guild.members.me; const uniqueIds = [...new Set(roleIds.filter(Boolean))];
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) { if (uniqueIds.length === 0) {
return; return;
} }
const assignable = roleIds.filter((roleId) => {
const role = member.guild.roles.cache.get(roleId); const me =
return role && role.position < me.roles.highest.position; member.guild.members.me ?? (await member.guild.members.fetchMe().catch(() => null));
}); if (!me) {
logger.warn({ guildId: member.guild.id }, 'Autoroles skipped: bot member unavailable');
return;
}
if (!me.permissions.has(PermissionFlagsBits.ManageRoles)) {
logger.warn({ guildId: member.guild.id }, 'Autoroles skipped: missing ManageRoles permission');
return;
}
const assignable: string[] = [];
for (const roleId of uniqueIds) {
if (roleId === member.guild.id) {
continue;
}
if (member.roles.cache.has(roleId)) {
continue;
}
const role = await resolveRole(member, roleId);
if (!role) {
logger.warn({ guildId: member.guild.id, roleId }, 'Autorole skipped: role not found');
continue;
}
if (role.managed) {
logger.warn({ guildId: member.guild.id, roleId }, 'Autorole skipped: managed role');
continue;
}
if (!role.editable) {
logger.warn(
{
guildId: member.guild.id,
roleId,
rolePosition: role.position,
botHighest: me.roles.highest.position
},
'Autorole skipped: role above bot or not editable'
);
continue;
}
assignable.push(roleId);
}
if (assignable.length === 0) { if (assignable.length === 0) {
return; return;
} }
await member.roles.add(assignable).catch((error) => {
logger.warn({ error, memberId: member.id }, 'Failed to assign autoroles'); await member.roles.add(assignable, 'Nexumi welcome autorole').catch((error) => {
logger.warn({ error, memberId: member.id, assignable }, 'Failed to assign autoroles');
}); });
} }
@@ -41,7 +93,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 +107,34 @@ 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);
if (!config.leaveAnnounceBan || member.user.bot) {
return;
}
const ban = await member.guild.bans.fetch(member.id).catch(() => null);
if (!ban) {
return;
}
const locale = await getGuildLocale(context.prisma, member.guild.id);
const reason = ban.reason ?? t(locale, 'moderation.reason.none');
if ('send' in channel) {
await channel
.send(
tf(locale, 'welcome.leave.bannedNotice', {
user: member.user.tag,
reason
})
)
.catch((error) => {
logger.warn(
{ error, guildId: member.guild.id, userId: member.id },
'Failed to send leave ban notice'
);
});
}
} }
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 { renderWelcomeText } from './service.js'; import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
import type { LeavePayload, WelcomePayload } from './service.js';
import { renderWelcomeEmbedText, 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, field) => renderWelcomeEmbedText(value, member, guild, field),
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') {
@@ -71,14 +77,15 @@ async function renderWelcomeCard(
const title = payload.imageTitle ?? 'Welcome!'; const title = payload.imageTitle ?? 'Welcome!';
const subtitle = const subtitle =
payload.imageSubtitle ?? `${member.displayName} joined ${guild.name}`; payload.imageSubtitle ?? `${member.displayName} joined ${guild.name}`;
const plain = { mentionUser: false as const };
ctx.fillStyle = '#f9fafb'; ctx.fillStyle = '#f9fafb';
ctx.font = 'bold 36px sans-serif'; ctx.font = 'bold 36px sans-serif';
ctx.fillText(renderWelcomeText(title, member, guild).slice(0, 40), 220, 130); ctx.fillText(renderWelcomeText(title, member, guild, plain).slice(0, 40), 220, 130);
ctx.fillStyle = '#d1d5db'; ctx.fillStyle = '#d1d5db';
ctx.font = '24px sans-serif'; ctx.font = '24px sans-serif';
ctx.fillText(renderWelcomeText(subtitle, member, guild).slice(0, 60), 220, 180); ctx.fillText(renderWelcomeText(subtitle, member, guild, plain).slice(0, 60), 220, 180);
return canvas.toBuffer('image/png'); return canvas.toBuffer('image/png');
} }
@@ -95,13 +102,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, field) => renderWelcomeEmbedText(value, member, guild, field),
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);
} }
} }

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